自动更新功能

master
cysamurai 2 months ago
parent 1d5ac78fed
commit a603248159

@ -10,6 +10,11 @@ chmod +x ./scripts/docker/run-build.sh
./scripts/docker/run-build.sh ./scripts/docker/run-build.sh
``` ```
> 默认会在打包结束后自动执行:
> 1) `scripts/inject-deb-bootstrap.sh`(向 deb 注入 `postinst`,安装时自动写源与公钥)
> 2) `scripts/build-apt-repo.sh`(生成可直接部署的 `dist/repo/`
> 3) `scripts/verify-apt-repo.sh`(发布前完整性自检)
只打指定项目: 只打指定项目:
```bash ```bash
@ -25,6 +30,11 @@ chmod +x ./scripts/docker/run-build.sh
- 混合模式:`./scripts/docker/run-build.sh --hybrid` - 混合模式:`./scripts/docker/run-build.sh --hybrid`
- 宿主机构建 `amd64` - 宿主机构建 `amd64`
- 容器构建 `arm64` - 容器构建 `arm64`
- 若仅需 `.deb` 不生成仓库目录:`GENERATE_APT_REPO=0 ./scripts/docker/run-build.sh`
- 默认使用签名 Key`com.jgzy.product`(密钥缺失时会自动创建)
- 如需禁用自动创建密钥:`APT_GPG_AUTO_CREATE=0 ./scripts/docker/run-build.sh`
- 如需跳过 deb 自动初始化注入:`INJECT_DEB_BOOTSTRAP=0 ./scripts/docker/run-build.sh`
- 默认会在生成仓库后自动自检(`verify-apt-repo.sh`);如需跳过:`VERIFY_APT_REPO=0 ./scripts/docker/run-build.sh`
## 2. 打包产物位置 ## 2. 打包产物位置
@ -35,6 +45,11 @@ chmod +x ./scripts/docker/run-build.sh
- `dist/linux-deb/broadcast-client/amd64/*.deb` - `dist/linux-deb/broadcast-client/amd64/*.deb`
- `dist/linux-deb/broadcast-client/arm64/*.deb` - `dist/linux-deb/broadcast-client/arm64/*.deb`
自动生成的 APT 仓库目录(可直接打包上传仓库机):
- `dist/repo/dists/v10/...`
- `dist/repo/pool/main/...`
Tauri 原始输出目录: Tauri 原始输出目录:
- `call-client/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/` - `call-client/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/`
@ -110,3 +125,168 @@ npm run tauri -- icon ./src-tauri/icons/call_icon.png
- 这里使用的是 **`@tauri-apps/cli` 自带的 `icon` 子命令**,无需再装名为 `tauri-icon` 的独立包若文档里写作「tauri-icon 工具」,一般即指该命令。 - 这里使用的是 **`@tauri-apps/cli` 自带的 `icon` 子命令**,无需再装名为 `tauri-icon` 的独立包若文档里写作「tauri-icon 工具」,一般即指该命令。
- 源图若不是 RGBA构建阶段仍可能报 `icon ... is not RGBA`,请在上游导出时勾选透明度。 - 源图若不是 RGBA构建阶段仍可能报 `icon ... is not RGBA`,请在上游导出时勾选透明度。
## 5. Windows Nginx + 内网 APT 仓库(麒麟 V10模板
以下模板用于“客户端检查更新 + 引导 apt 升级”,适用于 `call-client``broadcast-client`
### 5.1 仓库目录模板
```text
repo/
├─ dists/
│ └─ v10/
│ ├─ InRelease
│ ├─ Release
│ ├─ Release.gpg
│ └─ main/
│ ├─ binary-amd64/
│ │ ├─ Packages
│ │ └─ Packages.gz
│ └─ binary-arm64/
│ ├─ Packages
│ └─ Packages.gz
└─ pool/
└─ main/
├─ c/call-client/call-client_<version>_amd64.deb
├─ c/call-client/call-client_<version>_arm64.deb
├─ b/broadcast-client/broadcast-client_<version>_amd64.deb
└─ b/broadcast-client/broadcast-client_<version>_arm64.deb
```
### 5.2 发布步骤(每次发版)
由于仓库机是内网且不安装构建环境,统一在**发布机器**执行:
```bash
cd /path/to/TauriClient
chmod +x ./scripts/docker/run-build.sh ./scripts/build-apt-repo.sh ./scripts/inject-deb-bootstrap.sh ./scripts/verify-apt-repo.sh
./scripts/docker/run-build.sh
```
执行完成后会得到:
- `dist/linux-deb/...`:原始构建产物 `.deb`
- `dist/repo/...`:可直接部署到仓库机的 APT 仓库目录(含索引与签名)
- `dist/repo/zyyun-archive-keyring.asc`:客户端导入用 ASCII 公钥
- `dist/repo/zyyun-archive-keyring.gpg`:客户端可直接使用的 keyring 二进制公钥
最后只需把 `dist/repo` 整体打包上传到仓库机并对外提供 `http://80.12.140.29:80/apt`
> 脚本内置的签名密钥生成命令如下(密钥不存在且 `APT_GPG_AUTO_CREATE=1` 时自动执行):
> `gpg --batch --pinentry-mode loopback --passphrase "" --quick-gen-key "com.jgzy.product" rsa4096 sign 5y`
手工初始化(仅在未使用 deb 自动初始化时作为兜底):
```bash
curl -fsSL http://80.12.140.29:80/apt/zyyun-archive-keyring.asc | sudo gpg --dearmor -o /usr/share/keyrings/zyyun-archive-keyring.gpg
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/zyyun-archive-keyring.gpg] http://80.12.140.29:80/apt v10 main" | sudo tee /etc/apt/sources.list.d/zyyun.list
sudo apt update
```
发布后可手工复核(脚本已在 `run-build.sh` 里自动执行一次):
```bash
bash ./scripts/verify-apt-repo.sh
```
该脚本会校验以下关键项:
- `InRelease`、`Release`、`Release.gpg`
- 公钥文件 `zyyun-archive-keyring.asc`、`zyyun-archive-keyring.gpg`
- 各架构 `Packages.gz` 是否存在且可正常解压
- `pool/main` 下是否存在 `.deb`
#### 5.2.1 deb 自动初始化(已默认启用)
`run-build.sh` 默认会调用 `scripts/inject-deb-bootstrap.sh`,对构建出的 deb 注入 `postinst` 与内置公钥。
用户只需执行 deb 安装(图形安装器或 `dpkg -i`),安装阶段会自动:
1. 安装公钥到 `/usr/share/keyrings/zyyun-archive-keyring.gpg`
2. 写入源文件 `/etc/apt/sources.list.d/zyyun.list`
3. 执行 `apt-get update`(失败不阻断安装)
内置源固定为:
```bash
deb [signed-by=/usr/share/keyrings/zyyun-archive-keyring.gpg] http://80.12.140.29:80/apt v10 main
```
发版前可快速确认某个包已注入 bootstrap
```bash
bash ./scripts/verify-deb-bootstrap.sh dist/linux-deb/call-client/amd64/*.deb
```
或校验全部 deb
```bash
bash ./scripts/verify-deb-bootstrap.sh --all
```
### 5.3 Windows Nginx 反向代理模板
```nginx
server {
listen 80;
server_name 80.12.140.29;
location /apt/ {
proxy_pass http://inner-repo-server/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 300s;
}
}
```
客户端源配置示例:
```bash
deb [arch=amd64 signed-by=/usr/share/keyrings/zyyun-archive-keyring.gpg] http://80.12.140.29:80/apt v10 main
```
### 5.3.1 方案二80 端口被其他服务占用时
如果 `80.12.140.29:80` 已由 IIS / Apache / 其他网关占用,推荐做法是:
1. 保持现有 80 入口不变(外部地址仍为 `http://80.12.140.29:80/apt`)。
2. 让 Nginx 改为监听本机其他端口(例如 `127.0.0.1:8080`)。
3. 由现有 80 入口服务将 `/apt/` 路径反向代理到 `http://127.0.0.1:8080/apt/`
Nginx仓库中转示例
```nginx
server {
listen 127.0.0.1:8080;
server_name localhost;
location /apt/ {
proxy_pass http://inner-repo-server/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
80 入口服务需要保证:
- 对外保留路径 `/apt/`
- 不对 `dists/`、`pool/`、`Packages.gz`、`InRelease` 做重写或鉴权跳转。
- 转发后客户端源配置保持不变:
- `deb [arch=amd64 signed-by=/usr/share/keyrings/zyyun-archive-keyring.gpg] http://80.12.140.29:80/apt v10 main`
### 5.4 客户端交互建议
- “检查更新”按钮只做检测与引导,不在应用内直接执行 `sudo`
- 检测成功有新版本时,提示并复制命令:
- `sudo apt update && sudo apt install --only-upgrade call-client`
- `sudo apt update && sudo apt install --only-upgrade broadcast-client`

@ -15,6 +15,8 @@ use std::{
use chrono::Local; use chrono::Local;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::Manager; use tauri::Manager;
#[cfg(target_os = "linux")]
use std::process::Command;
const SOCKET_PORT: u16 = 9501; const SOCKET_PORT: u16 = 9501;
const SOCKET_STATUS_EVENT: &str = "socket-status"; const SOCKET_STATUS_EVENT: &str = "socket-status";
@ -80,6 +82,18 @@ struct SocketCallEventPayload {
display_text: String, display_text: String,
} }
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AptUpdateCheckResult {
package_name: String,
current_version: String,
installed_version: String,
candidate_version: String,
has_update: bool,
source_available: bool,
update_command: String,
}
/// 应用入口:注册插件、菜单事件与前端命令。 /// 应用入口:注册插件、菜单事件与前端命令。
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
@ -89,6 +103,7 @@ pub fn run() {
start_socket_service, start_socket_service,
stop_socket_service, stop_socket_service,
get_socket_service_status, get_socket_service_status,
check_apt_update,
quit_app quit_app
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
@ -220,6 +235,69 @@ fn get_socket_service_status(state: tauri::State<'_, SocketServiceState>) -> Soc
} }
} }
#[cfg(target_os = "linux")]
fn extract_policy_value(output: &str, key: &str) -> String {
output
.lines()
.find_map(|line| line.trim().strip_prefix(key).map(|value| value.trim().to_string()))
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "(unknown)".to_string())
}
#[tauri::command]
fn check_apt_update(package_name: String, current_version: String) -> Result<AptUpdateCheckResult, String> {
#[cfg(not(target_os = "linux"))]
{
let _ = (&package_name, &current_version);
return Err("当前系统不支持 apt 更新检测,仅支持 Linux。".to_string());
}
#[cfg(target_os = "linux")]
{
let output = Command::new("apt-cache")
.arg("policy")
.arg(&package_name)
.output()
.map_err(|error| format!("执行 apt-cache 失败: {error}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(if stderr.is_empty() {
"apt-cache policy 执行失败".to_string()
} else {
format!("apt-cache policy 执行失败: {stderr}")
});
}
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let installed_version = extract_policy_value(&stdout, "Installed:");
let candidate_version = extract_policy_value(&stdout, "Candidate:");
let baseline_version = if installed_version == "(none)" || installed_version == "(unknown)" {
current_version.clone()
} else {
installed_version.clone()
};
let source_available = candidate_version != "(none)" && candidate_version != "(unknown)";
let has_update = source_available
&& candidate_version != baseline_version
&& !candidate_version.trim().is_empty();
let update_command = format!(
"sudo apt update && sudo apt install --only-upgrade {}",
package_name
);
Ok(AptUpdateCheckResult {
package_name,
current_version,
installed_version,
candidate_version,
has_update,
source_available,
update_command,
})
}
}
fn emit_socket_status(app: &tauri::AppHandle, running: bool) { fn emit_socket_status(app: &tauri::AppHandle, running: bool) {
let _ = app.emit_all( let _ = app.emit_all(
SOCKET_STATUS_EVENT, SOCKET_STATUS_EVENT,

@ -302,6 +302,9 @@
<div class="actions-row"> <div class="actions-row">
<el-button type="primary" @click="saveConfig"></el-button> <el-button type="primary" @click="saveConfig"></el-button>
<el-button type="primary" plain :loading="checkingUpdate" @click="handleCheckUpdate">
检查更新
</el-button>
<el-button type="success" plain :disabled="socketRunning" @click="startSocketService"> <el-button type="success" plain :disabled="socketRunning" @click="startSocketService">
启动 Socket 服务 启动 Socket 服务
</el-button> </el-button>
@ -320,6 +323,7 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { appWindow, currentMonitor } from "@tauri-apps/api/window"; import { appWindow, currentMonitor } from "@tauri-apps/api/window";
import { invoke } from "@tauri-apps/api/tauri"; import { invoke } from "@tauri-apps/api/tauri";
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { ElMessage } from "element-plus";
import type { import type {
BroadcastConfig, BroadcastConfig,
ChildWindowAreaConfig, ChildWindowAreaConfig,
@ -340,12 +344,26 @@ interface SocketStatusPayload {
port: number; port: number;
} }
interface AptUpdateCheckResult {
packageName: string;
currentVersion: string;
installedVersion: string;
candidateVersion: string;
hasUpdate: boolean;
sourceAvailable: boolean;
updateCommand: string;
}
// //
const screenWidth = ref(normalizeScreenWidth(window.screen.width || 1920)); const screenWidth = ref(normalizeScreenWidth(window.screen.width || 1920));
const { config, patchConfig } = useBroadcastConfig(); const { config, patchConfig } = useBroadcastConfig();
const saveMessage = ref("修改后请点击“保存配置”。"); const saveMessage = ref("修改后请点击“保存配置”。");
const activePanels = ref(["base", "segments", "areas", "subtitles"]); const activePanels = ref(["base", "segments", "areas", "subtitles"]);
const socketRunning = ref(false); const socketRunning = ref(false);
const checkingUpdate = ref(false);
const APT_SOURCE_ENTRY =
"deb [arch=amd64 signed-by=/usr/share/keyrings/zyyun-archive-keyring.gpg] http://80.12.140.29:80/apt v10 main";
const APT_SOURCE_SETUP_COMMAND = `echo "${APT_SOURCE_ENTRY}" | sudo tee /etc/apt/sources.list.d/zyyun.list && sudo apt update`;
// store -> draft // store -> draft
const syncingFromStore = ref(false); const syncingFromStore = ref(false);
let socketStatusUnlisten: UnlistenFn | null = null; let socketStatusUnlisten: UnlistenFn | null = null;
@ -532,6 +550,40 @@ async function closeConfigWindow() {
} }
} }
async function handleCheckUpdate() {
if (checkingUpdate.value) {
return;
}
checkingUpdate.value = true;
try {
const result = await invoke<AptUpdateCheckResult>("check_apt_update", {
packageName: "broadcast-client",
currentVersion: "0.1.0",
});
if (!result.sourceAvailable) {
ElMessage.warning(
`未检测到可用更新源,请先执行:${APT_SOURCE_SETUP_COMMAND}`,
);
return;
}
if (!result.hasUpdate) {
ElMessage.success(`当前已是最新版本(${result.installedVersion}`);
return;
}
ElMessage.warning(
`发现新版本 ${result.candidateVersion},请在终端执行:${result.updateCommand}`,
);
} catch (error) {
ElMessage.error(`检查更新失败:${String(error)}`);
} finally {
checkingUpdate.value = false;
}
}
/** /**
* 手动保存配置到持久化存储并广播给同步屏窗口实时生效 * 手动保存配置到持久化存储并广播给同步屏窗口实时生效
*/ */

@ -2,4 +2,5 @@ pub mod config;
pub mod events; pub mod events;
pub mod logger; pub mod logger;
pub mod session; pub mod session;
pub mod update;
pub mod window; pub mod window;

@ -0,0 +1,78 @@
use serde::Serialize;
#[cfg(target_os = "linux")]
use std::process::Command;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AptUpdateCheckResult {
package_name: String,
current_version: String,
installed_version: String,
candidate_version: String,
has_update: bool,
source_available: bool,
update_command: String,
}
#[cfg(target_os = "linux")]
fn extract_policy_value(output: &str, key: &str) -> String {
output
.lines()
.find_map(|line| line.trim().strip_prefix(key).map(|value| value.trim().to_string()))
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "(unknown)".to_string())
}
#[tauri::command]
pub fn check_apt_update(package_name: String, current_version: String) -> Result<AptUpdateCheckResult, String> {
#[cfg(not(target_os = "linux"))]
{
let _ = (&package_name, &current_version);
return Err("当前系统不支持 apt 更新检测,仅支持 Linux。".to_string());
}
#[cfg(target_os = "linux")]
{
let output = Command::new("apt-cache")
.arg("policy")
.arg(&package_name)
.output()
.map_err(|error| format!("执行 apt-cache 失败: {error}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(if stderr.is_empty() {
"apt-cache policy 执行失败".to_string()
} else {
format!("apt-cache policy 执行失败: {stderr}")
});
}
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let installed_version = extract_policy_value(&stdout, "Installed:");
let candidate_version = extract_policy_value(&stdout, "Candidate:");
let baseline_version = if installed_version == "(none)" || installed_version == "(unknown)" {
current_version.clone()
} else {
installed_version.clone()
};
let source_available = candidate_version != "(none)" && candidate_version != "(unknown)";
let has_update = source_available
&& candidate_version != baseline_version
&& !candidate_version.trim().is_empty();
let update_command = format!(
"sudo apt update && sudo apt install --only-upgrade {}",
package_name
);
Ok(AptUpdateCheckResult {
package_name,
current_version,
installed_version,
candidate_version,
has_update,
source_available,
update_command,
})
}
}

@ -6,6 +6,7 @@ use commands::{
events::{emit_to_window, list_windows}, events::{emit_to_window, list_windows},
logger::app_log, logger::app_log,
session::{session_clear, session_get, session_set}, session::{session_clear, session_get, session_set},
update::check_apt_update,
window::{ window::{
close_ticket_window, ensure_main_window, focus_window, open_login_window, open_main_window, close_ticket_window, ensure_main_window, focus_window, open_login_window, open_main_window,
open_ticket_window, quit_app, open_ticket_window, quit_app,
@ -30,6 +31,7 @@ pub fn run() {
app_log, app_log,
emit_to_window, emit_to_window,
list_windows, list_windows,
check_apt_update,
open_ticket_window, open_ticket_window,
close_ticket_window, close_ticket_window,
focus_window, focus_window,

@ -5,6 +5,7 @@ const DEFAULT_SESSION: SessionState = {
empUid: null, empUid: null,
winUid: null, winUid: null,
queueToken: null, queueToken: null,
refreshToken: null,
}; };
function normalizeSession(raw: unknown): SessionState { function normalizeSession(raw: unknown): SessionState {
@ -15,8 +16,9 @@ function normalizeSession(raw: unknown): SessionState {
const winUid = const winUid =
typeof source.winUid === "number" && Number.isFinite(source.winUid) ? source.winUid : null; typeof source.winUid === "number" && Number.isFinite(source.winUid) ? source.winUid : null;
const queueToken = typeof source.queueToken === "string" ? source.queueToken : null; const queueToken = typeof source.queueToken === "string" ? source.queueToken : null;
const refreshToken = typeof source.refreshToken === "string" ? source.refreshToken : null;
return { empUid, winUid, queueToken }; return { empUid, winUid, queueToken, refreshToken };
} }
/** /**

@ -6,6 +6,7 @@ export interface SessionState {
empUid: number | null; empUid: number | null;
winUid: number | null; winUid: number | null;
queueToken: string | null; queueToken: string | null;
refreshToken: string | null;
} }
export interface NativeConfirmOptions { export interface NativeConfirmOptions {

@ -1,10 +1,31 @@
import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios"; import axios, { type AxiosRequestConfig } from "axios";
import { showErrorNative } from "../host/dialog"; import { showErrorNative } from "../host/dialog";
import { getSession } from "../host/session"; import { getSession, setSession } from "../host/session";
import type { SessionState } from "../host/types"; import type { SessionState } from "../host/types";
export const API_QUEUE_CALLER_PATH = "/api/queue/caller"; export const API_QUEUE_CALLER_PATH = "/api/queue/caller";
const DEFAULT_API_PORT = 8845; const DEFAULT_API_PORT = 8845;
const AUTH_LOGIN_PATH = "/auth/login";
const AUTH_REFRESH_PATH = "/auth/refresh";
type ApiEnvelope<T = unknown> = {
code: number;
msg?: string;
message?: string;
data: T;
};
type RefreshTokenResponse = {
queueToken?: string;
refreshToken?: string;
};
type RetryableAxiosRequestConfig = AxiosRequestConfig & {
_retry?: boolean;
_skipAuthRefresh?: boolean;
};
let refreshTokenPromise: Promise<string | null> | null = null;
/** /**
* baseURL * baseURL
@ -36,6 +57,101 @@ const instance = axios.create({
}, },
}); });
function isApiSuccessCode(code: unknown): boolean {
return code === 200 || code === 0;
}
function getApiMessage(payload: unknown): string {
const data = (payload ?? {}) as { message?: unknown; msg?: unknown };
if (typeof data.message === "string" && data.message.trim() !== "") {
return data.message;
}
if (typeof data.msg === "string" && data.msg.trim() !== "") {
return data.msg;
}
return "请求失败";
}
function isAuthEndpoint(url?: string): boolean {
if (!url) {
return false;
}
return url.includes(AUTH_LOGIN_PATH) || url.includes(AUTH_REFRESH_PATH);
}
function setAuthHeader(config: AxiosRequestConfig, queueToken: string): void {
const headers = (config.headers ?? {}) as Record<string, string>;
headers.Authorization = `Bearer ${queueToken}`;
config.headers = headers;
}
async function refreshQueueToken(): Promise<string | null> {
if (refreshTokenPromise) {
return refreshTokenPromise;
}
refreshTokenPromise = (async () => {
const sessionState = await getSession();
const refreshToken = sessionState.refreshToken;
if (!refreshToken) {
return null;
}
const response = await axios.request<ApiEnvelope<RefreshTokenResponse>>({
baseURL: instance.defaults.baseURL,
timeout: instance.defaults.timeout,
method: "POST",
url: AUTH_REFRESH_PATH,
data: { refreshToken },
headers: { "Content-Type": "application/json" },
});
const payload = response.data;
if (!isApiSuccessCode(payload?.code) || !payload?.data?.queueToken) {
return null;
}
const refreshedQueueToken = payload.data.queueToken;
await setSession({
...sessionState,
queueToken: refreshedQueueToken,
refreshToken: payload.data.refreshToken ?? refreshToken,
});
return refreshedQueueToken;
})();
try {
return await refreshTokenPromise;
} finally {
refreshTokenPromise = null;
}
}
function canRefreshAndRetry(config?: RetryableAxiosRequestConfig): boolean {
if (!config) {
return false;
}
if (config._retry || config._skipAuthRefresh) {
return false;
}
return !isAuthEndpoint(config.url);
}
async function retryWithRefreshedToken(config: RetryableAxiosRequestConfig): Promise<any> {
const nextToken = await refreshQueueToken();
if (!nextToken) {
throw new Error("会话已过期,请重新登录");
}
const retryConfig: RetryableAxiosRequestConfig = {
...config,
_retry: true,
};
setAuthHeader(retryConfig, nextToken);
return instance.request(retryConfig);
}
/** /**
* axios * axios
*/ */
@ -63,8 +179,8 @@ instance.interceptors.request.use(
token = null; token = null;
} }
if (token && !config.url?.includes("/login")) { if (token && !isAuthEndpoint(config.url)) {
config.headers.Authorization = `Bearer ${token}`; setAuthHeader(config, token);
} }
return config; return config;
@ -75,22 +191,35 @@ instance.interceptors.request.use(
); );
instance.interceptors.response.use( instance.interceptors.response.use(
(response: AxiosResponse) => { async (response) => {
const { data } = response; const { data } = response;
if (data.code !== 200) { const payload = data as ApiEnvelope;
void showErrorNative(data.message || "请求失败");
if (data.code === 401) { if (!isApiSuccessCode(payload?.code)) {
void showErrorNative("请求 token 过期,请重新登录"); const requestConfig = response.config as RetryableAxiosRequestConfig;
if (payload?.code === 401 && canRefreshAndRetry(requestConfig)) {
return retryWithRefreshedToken(requestConfig);
} }
throw new Error(data.message || "请求失败"); const message = getApiMessage(payload);
await showErrorNative(message);
throw new Error(message);
} }
return data.data; return payload.data;
}, },
async (error) => { async (error) => {
if (error.response) { if (error.response) {
const requestConfig = error.config as RetryableAxiosRequestConfig;
if (error.response.status === 401 && canRefreshAndRetry(requestConfig)) {
try {
return await retryWithRefreshedToken(requestConfig);
} catch {
await showErrorNative("未授权,请重新登录");
throw error;
}
}
switch (error.response.status) { switch (error.response.status) {
case 400: case 400:
await showErrorNative("请求错误"); await showErrorNative("请求错误");

@ -1,6 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Close, Lock, Minus, User } from "@element-plus/icons-vue"; import { Close, Lock, Minus, User } from "@element-plus/icons-vue";
import { ElForm, ElMessage } from "element-plus"; import { ElForm, ElMessage, ElMessageBox } from "element-plus";
import { getVersion } from "@tauri-apps/api/app";
import { writeText } from "@tauri-apps/api/clipboard";
import { invoke } from "@tauri-apps/api/tauri";
import { computed, onMounted, onUnmounted, ref } from "vue"; import { computed, onMounted, onUnmounted, ref } from "vue";
import { api } from "../api"; import { api } from "../api";
import { getAllConfig, mergeConfig } from "../host/config"; import { getAllConfig, mergeConfig } from "../host/config";
@ -23,11 +26,27 @@ const isLoginSuccessed = ref(false);
const selectedWin = ref(""); const selectedWin = ref("");
const cachedWinKey = ref(""); const cachedWinKey = ref("");
const options = ref<Array<{ label: string; value: string }>>([]); const options = ref<Array<{ label: string; value: string }>>([]);
const appVersion = ref("0.1.0");
const checkingUpdate = ref(false);
const APT_SOURCE_ENTRY =
"deb [arch=amd64 signed-by=/usr/share/keyrings/zyyun-archive-keyring.gpg] http://80.12.140.29:80/apt v10 main";
const APT_SOURCE_SETUP_COMMAND = `echo "${APT_SOURCE_ENTRY}" | sudo tee /etc/apt/sources.list.d/zyyun.list && sudo apt update`;
interface AptUpdateCheckResult {
packageName: string;
currentVersion: string;
installedVersion: string;
candidateVersion: string;
hasUpdate: boolean;
sourceAvailable: boolean;
updateCommand: string;
}
let sessionState: SessionState = { let sessionState: SessionState = {
empUid: null, empUid: null,
winUid: null, winUid: null,
queueToken: null, queueToken: null,
refreshToken: null,
}; };
const rules = { const rules = {
@ -96,6 +115,7 @@ async function handleLogin(): Promise<void> {
empUid: loginRes.operatorProfile.empUid, empUid: loginRes.operatorProfile.empUid,
winUid: 0, winUid: 0,
queueToken: loginRes.queueToken, queueToken: loginRes.queueToken,
refreshToken: loginRes.refreshToken,
}; };
await setSession(sessionState); await setSession(sessionState);
@ -228,7 +248,92 @@ function handleInputFocus(event: Event): void {
target.select(); target.select();
} }
/**
* 检查 apt 仓库中是否有新版本并引导复制升级命令
*/
async function handleCheckUpdate(): Promise<void> {
if (checkingUpdate.value) {
return;
}
checkingUpdate.value = true;
try {
const result = await invoke<AptUpdateCheckResult>("check_apt_update", {
packageName: "call-client",
currentVersion: appVersion.value,
});
if (!result.sourceAvailable) {
try {
await ElMessageBox.confirm(
[
"未检测到可用更新源,请先配置固定更新地址:",
APT_SOURCE_ENTRY,
"",
"点击“复制命令”后,在终端执行:",
APT_SOURCE_SETUP_COMMAND,
].join("\n"),
"更新源未配置",
{
confirmButtonText: "复制命令",
cancelButtonText: "关闭",
distinguishCancelAndClose: true,
type: "warning",
},
);
await writeText(APT_SOURCE_SETUP_COMMAND);
showMessage("success", "更新源初始化命令已复制到剪贴板");
} catch {
//
}
return;
}
if (!result.hasUpdate) {
showMessage("success", `当前已是最新版本(${result.installedVersion}`);
return;
}
try {
await ElMessageBox.confirm(
[
`检测到新版本:${result.candidateVersion}`,
`当前版本:${result.installedVersion}`,
"",
"点击“复制命令”后,在终端执行升级:",
result.updateCommand,
].join("\n"),
"发现新版本",
{
confirmButtonText: "复制命令",
cancelButtonText: "关闭",
distinguishCancelAndClose: true,
type: "info",
},
);
await writeText(result.updateCommand);
showMessage("success", "升级命令已复制到剪贴板");
} catch {
//
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
showMessage("error", `检查更新失败:${message}`);
await log("error", `检查更新失败: ${message}`);
} finally {
checkingUpdate.value = false;
}
}
onMounted(async () => { onMounted(async () => {
try {
appVersion.value = await getVersion();
} catch {
appVersion.value = "0.1.0";
}
const config = await getAllConfig(); const config = await getAllConfig();
if (typeof config.last_username === "string" && config.last_username.trim()) { if (typeof config.last_username === "string" && config.last_username.trim()) {
username.value = config.last_username; username.value = config.last_username;
@ -404,7 +509,16 @@ onUnmounted(() => {
</div> </div>
<div class="version-info"> <div class="version-info">
<div class="version">版本号V0.1.0</div> <div class="version">版本号V{{ appVersion }}</div>
<el-button
text
type="primary"
size="small"
:loading="checkingUpdate"
@click="handleCheckUpdate"
>
检查更新
</el-button>
<div class="copyright">© 2023 紫云科技 版权所有</div> <div class="copyright">© 2023 紫云科技 版权所有</div>
</div> </div>
</div> </div>

@ -50,6 +50,7 @@ const sessionState = ref<SessionState>({
empUid: -1, empUid: -1,
winUid: -1, winUid: -1,
queueToken: "", queueToken: "",
refreshToken: "",
}); });
const textColor = ref("#99ccff"); const textColor = ref("#99ccff");
const iconColor = ref("#dcdfe6"); const iconColor = ref("#dcdfe6");
@ -714,6 +715,11 @@ onMounted(async () => {
session.queueToken.trim() !== "" session.queueToken.trim() !== ""
? session.queueToken ? session.queueToken
: null, : null,
refreshToken:
typeof session.refreshToken === "string" &&
session.refreshToken.trim() !== ""
? session.refreshToken
: null,
}; };
} catch (error) { } catch (error) {
await logErr("读取 session 失败", error); await logErr("读取 session 失败", error);
@ -768,104 +774,106 @@ onUnmounted(() => {
<template> <template>
<div class="main-bg" @dblclick.prevent.stop> <div class="main-bg" @dblclick.prevent.stop>
<div class="btn-div"> <div class="btn-div">
<template v-if="buttonPanel === 'main'"> <Transition name="panel-slide" mode="out-in">
<button <div v-if="buttonPanel === 'main'" key="main" class="panel-content">
class="action-button action-button-menu" <button
type="button" class="action-button action-button-menu"
@click="openMoreContextMenu" type="button"
> @click="openMoreContextMenu"
<el-icon class="button-icon"> >
<component :is="MenuIcon" /> <el-icon class="button-icon">
</el-icon> <component :is="MenuIcon" />
</button> </el-icon>
<div class="divider-vertical"></div> </button>
<button <div class="divider-vertical"></div>
v-for="(btn, index) in buttons" <button
:key="index" v-for="(btn, index) in buttons"
type="button" :key="index"
class="action-button" type="button"
:data-action="btn.action" class="action-button"
:class="{ disabled: !btn.enabled }" :data-action="btn.action"
:style="{ color: !btn.enabled ? '#ccc' : textColor }" :class="{ disabled: !btn.enabled }"
@click="handleButtonClick(btn)" :style="{ color: !btn.enabled ? '#ccc' : textColor }"
> @click="handleButtonClick(btn)"
<el-icon >
class="button-icon" <el-icon
:style="{ color: !btn.enabled ? '#ccc' : iconColor }" class="button-icon"
:style="{ color: !btn.enabled ? '#ccc' : iconColor }"
>
<component :is="btn.icon" />
</el-icon>
<span class="button-label">{{ btn.label }}</span>
</button>
</div>
<div v-else-if="buttonPanel === 'more'" key="more" class="panel-content">
<button
class="action-button action-button-panel"
type="button"
@click="handleMoreCommand('main')"
>
<el-icon class="button-icon">
<component :is="User" />
</el-icon>
<span class="button-label">办税员窗口</span>
</button>
<button
class="action-button action-button-panel"
type="button"
@click="handleMoreCommand('ticketList')"
>
<el-icon class="button-icon">
<component :is="Memo" />
</el-icon>
<span class="button-label">票号列表</span>
</button>
<button
class="action-button action-button-panel"
type="button"
@click="handleMoreCommand('logout')"
>
<el-icon class="button-icon">
<component :is="Close" />
</el-icon>
<span class="button-label">退出程序</span>
</button>
<button
class="action-button action-button-panel action-button-back"
type="button"
@click="backToMainPanel"
> >
<component :is="btn.icon" /> <el-icon class="button-icon">
</el-icon> <component :is="Back" />
<span class="button-label">{{ btn.label }}</span> </el-icon>
</button> <span class="button-label">返回</span>
</template> </button>
</div>
<template v-else-if="buttonPanel === 'more'">
<button <div v-else key="pause" class="panel-content">
class="action-button action-button-panel" <button
type="button" v-for="reason in pauseReasonOptions"
@click="handleMoreCommand('main')" :key="reason"
> class="action-button action-button-panel"
<el-icon class="button-icon"> type="button"
<component :is="User" /> @click="confirmPauseReason(reason)"
</el-icon> >
<span class="button-label">办税员窗口</span> <el-icon v-if="getPauseReasonIcon(reason)" class="button-icon">
</button> <component :is="getPauseReasonIcon(reason)" />
<button </el-icon>
class="action-button action-button-panel" <span class="button-label">{{ reason }}</span>
type="button" </button>
@click="handleMoreCommand('ticketList')" <button
> class="action-button action-button-panel action-button-back"
<el-icon class="button-icon"> type="button"
<component :is="Memo" /> @click="backToMainPanel"
</el-icon> >
<span class="button-label">票号列表</span> <el-icon class="button-icon">
</button> <component :is="Back" />
<button </el-icon>
class="action-button action-button-panel" <span class="button-label">返回</span>
type="button" </button>
@click="handleMoreCommand('logout')" </div>
> </Transition>
<el-icon class="button-icon">
<component :is="Close" />
</el-icon>
<span class="button-label">退出程序</span>
</button>
<button
class="action-button action-button-panel action-button-back"
type="button"
@click="backToMainPanel"
>
<el-icon class="button-icon">
<component :is="Back" />
</el-icon>
<span class="button-label">返回</span>
</button>
</template>
<template v-else>
<button
v-for="reason in pauseReasonOptions"
:key="reason"
class="action-button action-button-panel"
type="button"
@click="confirmPauseReason(reason)"
>
<el-icon v-if="getPauseReasonIcon(reason)" class="button-icon">
<component :is="getPauseReasonIcon(reason)" />
</el-icon>
<span class="button-label">{{ reason }}</span>
</button>
<button
class="action-button action-button-panel action-button-back"
type="button"
@click="backToMainPanel"
>
<el-icon class="button-icon">
<component :is="Back" />
</el-icon>
<span class="button-label">返回</span>
</button>
</template>
</div> </div>
<div class="divider-horizontal"></div> <div class="divider-horizontal"></div>
<div class="log-div" data-tauri-drag-region @dblclick.prevent.stop> <div class="log-div" data-tauri-drag-region @dblclick.prevent.stop>
@ -883,6 +891,13 @@ onUnmounted(() => {
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
line-height: 1;
font-family:
"Microsoft YaHei",
"Noto Sans CJK SC",
"PingFang SC",
"Segoe UI",
sans-serif;
background: linear-gradient( background: linear-gradient(
180deg, 180deg,
rgba(53, 64, 94, 0.96) 0%, rgba(53, 64, 94, 0.96) 0%,
@ -901,6 +916,31 @@ onUnmounted(() => {
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
.panel-content {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
}
.panel-slide-enter-active,
.panel-slide-leave-active {
transition:
transform 220ms ease,
opacity 220ms ease;
}
.panel-slide-enter-from {
transform: translateY(-12px);
opacity: 0;
}
.panel-slide-leave-to {
transform: translateY(-12px);
opacity: 0;
}
.action-button { .action-button {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -908,6 +948,7 @@ onUnmounted(() => {
align-items: center; align-items: center;
gap: 2px; gap: 2px;
padding: 5px 4px; padding: 5px 4px;
line-height: 1;
border: none; border: none;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
@ -930,6 +971,8 @@ onUnmounted(() => {
.button-label { .button-label {
margin: 0 1px; margin: 0 1px;
font-size: 16px; font-size: 16px;
line-height: 1;
white-space: nowrap;
} }
.log-div { .log-div {
@ -942,6 +985,7 @@ onUnmounted(() => {
.log-span { .log-span {
font-size: 18px; font-size: 18px;
line-height: 1.2;
color: #99ccff; color: #99ccff;
} }

@ -0,0 +1,206 @@
#!/usr/bin/env bash
# 从 dist/linux-deb 产物生成可部署的 APT 仓库目录repo/)。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
DIST_ROOT="$REPO_ROOT/dist/linux-deb"
OUTPUT_REPO_DIR="$REPO_ROOT/dist/repo"
SUITE="${APT_REPO_SUITE:-v10}"
COMPONENT="${APT_REPO_COMPONENT:-main}"
ARCHES="${APT_REPO_ARCHES:-amd64 arm64}"
GPG_KEY_ID="${APT_GPG_KEY_ID:-com.jgzy.product}"
AUTO_CREATE_GPG_KEY="${APT_GPG_AUTO_CREATE:-1}"
PUBLIC_KEY_ASC_NAME="${APT_PUBLIC_KEY_ASC_NAME:-zyyun-archive-keyring.asc}"
PUBLIC_KEY_GPG_NAME="${APT_PUBLIC_KEY_GPG_NAME:-zyyun-archive-keyring.gpg}"
usage() {
cat <<EOF
用法:
$(basename "$0") [--output-dir <dir>] [--suite <name>] [--component <name>] [项目名...]
环境变量:
APT_GPG_KEY_ID GPG 签名 Key ID默认: com.jgzy.product
APT_GPG_AUTO_CREATE 1/0。密钥不存在时自动执行 quick-gen-key默认: 1
APT_REPO_SUITE 仓库 suite默认: v10
APT_REPO_COMPONENT 仓库 component默认: main
APT_REPO_ARCHES 仓库架构(默认: "amd64 arm64"
EOF
}
PROJECTS=()
while (($# > 0)); do
case "$1" in
--output-dir)
OUTPUT_REPO_DIR="${2:-}"
shift 2
;;
--suite)
SUITE="${2:-}"
shift 2
;;
--component)
COMPONENT="${2:-}"
shift 2
;;
--help|-h)
usage
exit 0
;;
--)
shift
break
;;
-*)
echo "错误: 未知参数 $1" >&2
exit 1
;;
*)
PROJECTS+=("$1")
shift
;;
esac
done
if (($# > 0)); then
PROJECTS+=("$@")
fi
if [[ ! -d "$DIST_ROOT" ]]; then
echo "错误: 未找到 deb 产物目录 $DIST_ROOT" >&2
echo "请先执行 scripts/docker/run-build.sh 或 scripts/build-linux-deb-all.sh" >&2
exit 1
fi
if ! command -v apt-ftparchive >/dev/null 2>&1; then
echo "错误: 未安装 apt-ftparchive请安装 apt-utils" >&2
exit 1
fi
if ! command -v gpg >/dev/null 2>&1; then
echo "错误: 未安装 gpg签名与公钥导出需要" >&2
exit 1
fi
ensure_signing_key() {
local key="$1"
local quick_gen_cmd=(
gpg --batch --pinentry-mode loopback --passphrase ""
--quick-gen-key "$key" rsa4096 sign 5y
)
if gpg --list-secret-keys "$key" >/dev/null 2>&1; then
return 0
fi
if [[ "$AUTO_CREATE_GPG_KEY" != "1" ]]; then
echo "错误: 未找到签名私钥: $key" >&2
echo "请先执行以下命令创建密钥,或设置 APT_GPG_AUTO_CREATE=1 自动创建:" >&2
echo " ${quick_gen_cmd[*]}" >&2
exit 1
fi
echo "==> gpg key not found, generating: $key"
"${quick_gen_cmd[@]}"
}
rm -rf "$OUTPUT_REPO_DIR"
mkdir -p "$OUTPUT_REPO_DIR/pool/$COMPONENT" "$OUTPUT_REPO_DIR/dists/$SUITE/$COMPONENT"
copy_project_debs() {
local project="$1"
local initial="${project:0:1}"
local src_dir="$DIST_ROOT/$project"
local dst_dir="$OUTPUT_REPO_DIR/pool/$COMPONENT/$initial/$project"
if [[ ! -d "$src_dir" ]]; then
echo "警告: 未找到项目产物目录 $src_dir,跳过" >&2
return 0
fi
mkdir -p "$dst_dir"
shopt -s nullglob
local files=( "$src_dir"/amd64/*.deb "$src_dir"/arm64/*.deb )
shopt -u nullglob
if (( ${#files[@]} == 0 )); then
echo "警告: $src_dir 下没有 .deb 文件,跳过" >&2
return 0
fi
cp -v "${files[@]}" "$dst_dir/"
}
if ((${#PROJECTS[@]} == 0)); then
shopt -s nullglob
for project_path in "$DIST_ROOT"/*; do
[[ -d "$project_path" ]] || continue
PROJECTS+=( "$(basename "$project_path")" )
done
shopt -u nullglob
fi
if ((${#PROJECTS[@]} == 0)); then
echo "错误: 未发现任何可发布项目($DIST_ROOT 为空)" >&2
exit 1
fi
ensure_signing_key "$GPG_KEY_ID"
for project in "${PROJECTS[@]}"; do
copy_project_debs "$project"
done
# 为每个架构生成独立 Packages按文件名后缀筛选
for arch in $ARCHES; do
out_dir="$OUTPUT_REPO_DIR/dists/$SUITE/$COMPONENT/binary-$arch"
mkdir -p "$out_dir"
tmp_pool="$OUTPUT_REPO_DIR/.tmp-pool-$arch"
rm -rf "$tmp_pool"
mkdir -p "$tmp_pool"
shopt -s globstar nullglob
arch_files=( "$OUTPUT_REPO_DIR"/pool/$COMPONENT/**/*.deb )
shopt -u globstar nullglob
for deb_file in "${arch_files[@]}"; do
base="$(basename "$deb_file")"
# 兼容 xxx_amd64.deb / xxx_arm64.deb 命名
if [[ "$base" == *"_${arch}.deb" ]]; then
rel="${deb_file#$OUTPUT_REPO_DIR/pool/$COMPONENT/}"
mkdir -p "$tmp_pool/$(dirname "$rel")"
cp -f "$deb_file" "$tmp_pool/$rel"
fi
done
apt-ftparchive packages "$tmp_pool" > "$out_dir/Packages"
gzip -kf "$out_dir/Packages"
rm -rf "$tmp_pool"
done
apt-ftparchive \
-o APT::FTPArchive::Release::Suite="$SUITE" \
-o APT::FTPArchive::Release::Codename="$SUITE" \
-o APT::FTPArchive::Release::Components="$COMPONENT" \
-o APT::FTPArchive::Release::Architectures="$ARCHES" \
release "$OUTPUT_REPO_DIR/dists/$SUITE" > "$OUTPUT_REPO_DIR/dists/$SUITE/Release"
gpg --default-key "$GPG_KEY_ID" -abs \
-o "$OUTPUT_REPO_DIR/dists/$SUITE/Release.gpg" \
"$OUTPUT_REPO_DIR/dists/$SUITE/Release"
gpg --default-key "$GPG_KEY_ID" --clearsign \
-o "$OUTPUT_REPO_DIR/dists/$SUITE/InRelease" \
"$OUTPUT_REPO_DIR/dists/$SUITE/Release"
gpg --armor --export "$GPG_KEY_ID" > "$OUTPUT_REPO_DIR/$PUBLIC_KEY_ASC_NAME"
gpg --export "$GPG_KEY_ID" > "$OUTPUT_REPO_DIR/$PUBLIC_KEY_GPG_NAME"
SIGN_HINT="已签名KEY: $GPG_KEY_ID"
echo ""
echo "==> APT 仓库目录生成完成"
echo " 输出目录: $OUTPUT_REPO_DIR"
echo " Suite: $SUITE, Component: $COMPONENT, Architectures: $ARCHES"
echo " 签名状态: $SIGN_HINT"
echo " 公钥文件: $OUTPUT_REPO_DIR/$PUBLIC_KEY_ASC_NAME"
echo " $OUTPUT_REPO_DIR/$PUBLIC_KEY_GPG_NAME"
echo " 可直接将 dist/repo 打包上传到仓库机。"

@ -6,6 +6,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
IMAGE_TAG="${IMAGE_TAG:-tauri-linux-deb:20.04}" IMAGE_TAG="${IMAGE_TAG:-tauri-linux-deb:20.04}"
RUN_MODE="${RUN_MODE:-docker}" # docker: dual-container all; hybrid: host amd64 + docker arm64 RUN_MODE="${RUN_MODE:-docker}" # docker: dual-container all; hybrid: host amd64 + docker arm64
GENERATE_APT_REPO="${GENERATE_APT_REPO:-1}"
VERIFY_APT_REPO="${VERIFY_APT_REPO:-1}"
INJECT_DEB_BOOTSTRAP="${INJECT_DEB_BOOTSTRAP:-1}"
APT_GPG_KEY_ID="${APT_GPG_KEY_ID:-com.jgzy.product}"
APT_GPG_AUTO_CREATE="${APT_GPG_AUTO_CREATE:-1}"
PROJECT_ARGS=() PROJECT_ARGS=()
while (($# > 0)); do while (($# > 0)); do
@ -26,6 +31,13 @@ while (($# > 0)); do
说明: 说明:
--docker-only 双容器模式:容器内分别构建 amd64 + arm64默认 --docker-only 双容器模式:容器内分别构建 amd64 + arm64默认
--hybrid 宿主机构建 amd64容器构建 arm64 --hybrid 宿主机构建 amd64容器构建 arm64
环境变量:
INJECT_DEB_BOOTSTRAP=1/0 是否注入 deb 安装初始化(默认 1
GENERATE_APT_REPO=1/0 是否在打包结束后生成 dist/repo默认 1
VERIFY_APT_REPO=1/0 生成仓库后执行完整性自检(默认 1
APT_GPG_KEY_ID=<KEYID> 生成仓库时使用的签名 Key默认 com.jgzy.product
APT_GPG_AUTO_CREATE=1/0 缺少签名密钥时自动创建(默认 1
EOF EOF
exit 0 exit 0
;; ;;
@ -127,3 +139,20 @@ else
"$IMAGE_TAG" \ "$IMAGE_TAG" \
bash /work/scripts/docker/container-entry.sh "${SELECTED_PROJECTS[@]}" bash /work/scripts/docker/container-entry.sh "${SELECTED_PROJECTS[@]}"
fi fi
if [[ "$INJECT_DEB_BOOTSTRAP" == "1" ]]; then
echo "==> inject deb bootstrap (postinst + keyring)"
APT_GPG_KEY_ID="$APT_GPG_KEY_ID" APT_GPG_AUTO_CREATE="$APT_GPG_AUTO_CREATE" \
bash "$REPO_ROOT/scripts/inject-deb-bootstrap.sh" "${SELECTED_PROJECTS[@]}"
fi
if [[ "$GENERATE_APT_REPO" == "1" ]]; then
echo "==> generate apt repo (dist/repo)"
APT_GPG_KEY_ID="$APT_GPG_KEY_ID" APT_GPG_AUTO_CREATE="$APT_GPG_AUTO_CREATE" \
bash "$REPO_ROOT/scripts/build-apt-repo.sh" "${SELECTED_PROJECTS[@]}"
if [[ "$VERIFY_APT_REPO" == "1" ]]; then
echo "==> verify apt repo (dist/repo)"
bash "$REPO_ROOT/scripts/verify-apt-repo.sh"
fi
fi

@ -0,0 +1,167 @@
#!/usr/bin/env bash
# 为构建出的 deb 注入 postinst安装时自动写入 apt 源与公钥。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
DIST_ROOT="${DEB_DIST_ROOT:-$REPO_ROOT/dist/linux-deb}"
APT_SOURCE_URL="${APT_SOURCE_URL:-http://80.12.140.29:80/apt}"
APT_SOURCE_SUITE="${APT_SOURCE_SUITE:-v10}"
APT_SOURCE_COMPONENT="${APT_SOURCE_COMPONENT:-main}"
APT_SOURCE_LIST_FILE="${APT_SOURCE_LIST_FILE:-/etc/apt/sources.list.d/zyyun.list}"
APT_KEYRING_PATH="${APT_KEYRING_PATH:-/usr/share/keyrings/zyyun-archive-keyring.gpg}"
APT_BOOTSTRAP_KEY_IN_PKG="${APT_BOOTSTRAP_KEY_IN_PKG:-/usr/share/zyyun/bootstrap/zyyun-archive-keyring.gpg}"
GPG_KEY_ID="${APT_GPG_KEY_ID:-com.jgzy.product}"
AUTO_CREATE_GPG_KEY="${APT_GPG_AUTO_CREATE:-1}"
usage() {
cat <<EOF
用法:
$(basename "$0") [项目名...]
环境变量:
DEB_DIST_ROOT deb 目录(默认: dist/linux-deb
APT_GPG_KEY_ID 签名 Key默认: com.jgzy.product
APT_GPG_AUTO_CREATE 1/0缺失签名密钥时自动创建默认: 1
APT_SOURCE_URL 更新源地址(默认: http://80.12.140.29:80/apt
APT_SOURCE_SUITE 默认: v10
APT_SOURCE_COMPONENT 默认: main
EOF
}
if (($# > 0)); then
case "$1" in
--help|-h)
usage
exit 0
;;
esac
fi
if [[ ! -d "$DIST_ROOT" ]]; then
echo "错误: deb 产物目录不存在: $DIST_ROOT" >&2
exit 1
fi
if ! command -v gpg >/dev/null 2>&1; then
echo "错误: 未安装 gpg注入公钥需要" >&2
exit 1
fi
if ! command -v dpkg-deb >/dev/null 2>&1; then
echo "错误: 未安装 dpkg-debdeb 重新打包需要)" >&2
exit 1
fi
ensure_signing_key() {
local key="$1"
local quick_gen_cmd=(
gpg --batch --pinentry-mode loopback --passphrase ""
--quick-gen-key "$key" rsa4096 sign 5y
)
if gpg --list-secret-keys "$key" >/dev/null 2>&1; then
return 0
fi
if [[ "$AUTO_CREATE_GPG_KEY" != "1" ]]; then
echo "错误: 未找到签名私钥: $key" >&2
echo "请先执行以下命令创建密钥,或设置 APT_GPG_AUTO_CREATE=1 自动创建:" >&2
echo " ${quick_gen_cmd[*]}" >&2
exit 1
fi
echo "==> gpg key not found, generating: $key"
"${quick_gen_cmd[@]}"
}
PROJECTS=()
if ((${#@} > 0)); then
PROJECTS=("$@")
else
shopt -s nullglob
for p in "$DIST_ROOT"/*; do
[[ -d "$p" ]] || continue
PROJECTS+=( "$(basename "$p")" )
done
shopt -u nullglob
fi
if ((${#PROJECTS[@]} == 0)); then
echo "错误: 未发现可注入项目($DIST_ROOT 为空)" >&2
exit 1
fi
ensure_signing_key "$GPG_KEY_ID"
tmp_key="$(mktemp)"
cleanup() {
rm -f "$tmp_key"
}
trap cleanup EXIT
gpg --export "$GPG_KEY_ID" > "$tmp_key"
inject_one_deb() {
local deb_file="$1"
local tmp_dir
tmp_dir="$(mktemp -d)"
dpkg-deb -R "$deb_file" "$tmp_dir" >/dev/null
mkdir -p "$tmp_dir/DEBIAN"
mkdir -p "$tmp_dir$(dirname "$APT_BOOTSTRAP_KEY_IN_PKG")"
install -m 0644 "$tmp_key" "$tmp_dir$APT_BOOTSTRAP_KEY_IN_PKG"
cat > "$tmp_dir/DEBIAN/postinst" <<EOF
#!/bin/sh
set -e
KEY_SRC="$APT_BOOTSTRAP_KEY_IN_PKG"
KEY_DST="$APT_KEYRING_PATH"
LIST_FILE="$APT_SOURCE_LIST_FILE"
SOURCE_LINE='deb [signed-by=$APT_KEYRING_PATH] $APT_SOURCE_URL $APT_SOURCE_SUITE $APT_SOURCE_COMPONENT'
if [ ! -f "\$KEY_SRC" ]; then
echo "warning: bootstrap key file missing: \$KEY_SRC" >&2
else
install -d "\$(dirname "\$KEY_DST")"
install -m 0644 "\$KEY_SRC" "\$KEY_DST"
fi
install -d "\$(dirname "\$LIST_FILE")"
printf '%s\n' "\$SOURCE_LINE" > "\$LIST_FILE"
if command -v apt-get >/dev/null 2>&1; then
apt-get update || true
elif command -v apt >/dev/null 2>&1; then
apt update || true
fi
exit 0
EOF
chmod 0755 "$tmp_dir/DEBIAN/postinst"
dpkg-deb -b "$tmp_dir" "$deb_file" >/dev/null
rm -rf "$tmp_dir"
}
for project in "${PROJECTS[@]}"; do
for arch in amd64 arm64; do
shopt -s nullglob
debs=( "$DIST_ROOT/$project/$arch"/*.deb )
shopt -u nullglob
if (( ${#debs[@]} == 0 )); then
continue
fi
for deb_file in "${debs[@]}"; do
echo "==> inject bootstrap into $(basename "$deb_file")"
inject_one_deb "$deb_file"
done
done
done
echo "==> deb bootstrap 注入完成"
echo " source: $APT_SOURCE_URL $APT_SOURCE_SUITE $APT_SOURCE_COMPONENT"
echo " keyring: $APT_KEYRING_PATH"

@ -0,0 +1,102 @@
#!/usr/bin/env bash
# 校验 APT 仓库目录完整性,避免发布缺文件。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO_DIR="${APT_VERIFY_REPO_DIR:-$REPO_ROOT/dist/repo}"
SUITE="${APT_REPO_SUITE:-v10}"
COMPONENT="${APT_REPO_COMPONENT:-main}"
ARCHES="${APT_REPO_ARCHES:-amd64 arm64}"
PUBLIC_KEY_ASC_NAME="${APT_PUBLIC_KEY_ASC_NAME:-zyyun-archive-keyring.asc}"
PUBLIC_KEY_GPG_NAME="${APT_PUBLIC_KEY_GPG_NAME:-zyyun-archive-keyring.gpg}"
usage() {
cat <<EOF
用法:
$(basename "$0") [--repo-dir <dir>] [--suite <name>] [--component <name>] [--arches "amd64 arm64"]
环境变量:
APT_VERIFY_REPO_DIR 仓库目录(默认: dist/repo
APT_REPO_SUITE 仓库 suite默认: v10
APT_REPO_COMPONENT 仓库 component默认: main
APT_REPO_ARCHES 仓库架构(默认: "amd64 arm64"
EOF
}
while (($# > 0)); do
case "$1" in
--repo-dir)
REPO_DIR="${2:-}"
shift 2
;;
--suite)
SUITE="${2:-}"
shift 2
;;
--component)
COMPONENT="${2:-}"
shift 2
;;
--arches)
ARCHES="${2:-}"
shift 2
;;
--help|-h)
usage
exit 0
;;
-*)
echo "错误: 未知参数 $1" >&2
exit 1
;;
*)
echo "错误: 不支持的位置参数 $1" >&2
exit 1
;;
esac
done
if [[ ! -d "$REPO_DIR" ]]; then
echo "错误: 仓库目录不存在: $REPO_DIR" >&2
exit 1
fi
required_files=(
"$REPO_DIR/dists/$SUITE/InRelease"
"$REPO_DIR/dists/$SUITE/Release"
"$REPO_DIR/dists/$SUITE/Release.gpg"
"$REPO_DIR/$PUBLIC_KEY_ASC_NAME"
"$REPO_DIR/$PUBLIC_KEY_GPG_NAME"
)
for arch in $ARCHES; do
required_files+=("$REPO_DIR/dists/$SUITE/$COMPONENT/binary-$arch/Packages.gz")
done
for file in "${required_files[@]}"; do
if [[ ! -f "$file" ]]; then
echo "错误: 缺少必要文件: $file" >&2
exit 1
fi
done
for arch in $ARCHES; do
pkg_gz="$REPO_DIR/dists/$SUITE/$COMPONENT/binary-$arch/Packages.gz"
if ! gzip -t "$pkg_gz" >/dev/null 2>&1; then
echo "错误: Packages.gz 不是有效 gzip 文件: $pkg_gz" >&2
exit 1
fi
done
if ! find "$REPO_DIR/pool/$COMPONENT" -type f -name "*.deb" | read -r _; then
echo "错误: pool/$COMPONENT 下未发现任何 .deb 包" >&2
exit 1
fi
echo "==> APT 仓库自检通过"
echo " repo: $REPO_DIR"
echo " suite: $SUITE"
echo " component: $COMPONENT"
echo " arches: $ARCHES"

@ -0,0 +1,129 @@
#!/usr/bin/env bash
# 校验 deb 是否已注入 bootstrappostinst + 内置公钥)。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
DIST_ROOT="${DEB_DIST_ROOT:-$REPO_ROOT/dist/linux-deb}"
APT_BOOTSTRAP_KEY_IN_PKG="${APT_BOOTSTRAP_KEY_IN_PKG:-/usr/share/zyyun/bootstrap/zyyun-archive-keyring.gpg}"
usage() {
cat <<EOF
用法:
$(basename "$0") <deb文件路径...>
$(basename "$0") --all
说明:
- 指定 deb 路径时,仅校验这些文件
- 使用 --all 时,扫描 dist/linux-deb 下全部 deb
环境变量:
DEB_DIST_ROOT 默认扫描目录(默认: dist/linux-deb
APT_BOOTSTRAP_KEY_IN_PKG 包内公钥路径(默认: /usr/share/zyyun/bootstrap/zyyun-archive-keyring.gpg
EOF
}
collect_all_debs() {
local root="$1"
if [[ ! -d "$root" ]]; then
return 0
fi
shopt -s globstar nullglob
local all=( "$root"/**/*.deb )
shopt -u globstar nullglob
printf '%s\n' "${all[@]}"
}
check_one_deb() {
local deb_file="$1"
if [[ ! -f "$deb_file" ]]; then
echo "✗ 文件不存在: $deb_file" >&2
return 1
fi
local tmp_ctrl tmp_data
tmp_ctrl="$(mktemp -d)"
tmp_data="$(mktemp -d)"
cleanup_local() {
rm -rf "$tmp_ctrl" "$tmp_data"
}
if ! dpkg-deb -e "$deb_file" "$tmp_ctrl" >/dev/null 2>&1; then
echo "✗ 无法解析控制信息: $deb_file" >&2
cleanup_local
return 1
fi
if ! dpkg-deb -x "$deb_file" "$tmp_data" >/dev/null 2>&1; then
echo "✗ 无法解包数据内容: $deb_file" >&2
cleanup_local
return 1
fi
local postinst_path="$tmp_ctrl/postinst"
if [[ ! -f "$postinst_path" ]]; then
echo "✗ 缺少 postinst: $deb_file" >&2
cleanup_local
return 1
fi
if ! rg -q "SOURCE_LINE=.*80\\.12\\.140\\.29:80/apt" "$postinst_path"; then
echo "✗ postinst 未发现固定源地址配置: $deb_file" >&2
cleanup_local
return 1
fi
if ! rg -q "apt-get update|apt update" "$postinst_path"; then
echo "✗ postinst 未发现 apt update 调用: $deb_file" >&2
cleanup_local
return 1
fi
local key_path="$tmp_data$APT_BOOTSTRAP_KEY_IN_PKG"
if [[ ! -f "$key_path" ]]; then
echo "✗ 包内缺少 bootstrap 公钥: $deb_file ($APT_BOOTSTRAP_KEY_IN_PKG)" >&2
cleanup_local
return 1
fi
echo "✓ 已注入: $deb_file"
cleanup_local
return 0
}
if (($# == 0)); then
usage
exit 1
fi
if ! command -v dpkg-deb >/dev/null 2>&1; then
echo "错误: 未安装 dpkg-deb校验 deb 需要)" >&2
exit 1
fi
TARGET_DEBS=()
if [[ "$1" == "--all" ]]; then
mapfile -t TARGET_DEBS < <(collect_all_debs "$DIST_ROOT")
else
TARGET_DEBS=("$@")
fi
if ((${#TARGET_DEBS[@]} == 0)); then
echo "错误: 未找到可校验的 deb 文件" >&2
exit 1
fi
fail_count=0
for deb in "${TARGET_DEBS[@]}"; do
if ! check_one_deb "$deb"; then
fail_count=$((fail_count + 1))
fi
done
if ((fail_count > 0)); then
echo ""
echo "校验完成:失败 $fail_count" >&2
exit 1
fi
echo ""
echo "校验完成:全部通过(${#TARGET_DEBS[@]} 个)"
Loading…
Cancel
Save