新增: 应用内版本更新与自动升级全链路

后端 update.rs: app_check_update(拉 latest.json+ed25519 签名校验) /
app_download_install_update / prefs 读写 / mark_checked / mark_notified 五命令,
lib.rs 注册;tauri.conf updater 段(endpoints=app.1216.top/download/devflow/latest.json
+ pubkey + createUpdaterArtifacts) + tauri-plugin-updater 依赖 + capabilities 权限。

前端 UpdateBadge.vue(顶栏徽章+下拉) / useUpdateCheck.ts(检查/下载/安装状态机) /
AboutPanel.vue(设置页关于面板) + AppLayout 接线 + SettingsNav/searchIndex 索引 +
i18n zh/en settings 键。

运维 scripts/publish-release.ps1(发版) + swap-install.ps1(换装校验) +
设计文档 版本更新与自动升级-设计-2026-10-09.md。

验收: cargo check -p devflow EXIT 0 + vue-tsc EXIT 0 + build EXIT 0。
This commit is contained in:
lxy
2026-10-09 22:53:14 +08:00
parent c70faa7bb8
commit a8c92e9619
27 changed files with 2021 additions and 24 deletions
+1
View File
@@ -13,6 +13,7 @@ pub mod project;
pub mod services;
pub mod settings;
pub mod task;
pub mod update;
pub mod workflow;
/// 当前时间戳(毫秒字符串)— 与 df-storage 内部的时间格式保持一致
+244
View File
@@ -0,0 +1,244 @@
//! 应用内升级 IPC — B2/L1(检查更新 + 自动检查节流)
//!
//! 设计:docs/02-架构设计/专项设计/版本更新与自动升级-设计-2026-10-09.md §四 L1
//!
//! 职责边界:
//! - `app_check_update`:拉远端 latest.json(Tauri updater 内建签名校验) → 返回是否有新版。
//! 只读 + 外发网络,不改本地状态(节流时间戳由调用方按需写)。
//! - `app_update_get_prefs` / `app_update_set_prefs`:自动检查偏好 KV 读写。
//! - 自动检查节流判定:`should_auto_check` 依据 `df-update-last-check` 与间隔配置。
//!
//! 安装(下载+装+重启)在 B3,本模块不含。
//!
//! KV keys(复用既有 app_settings 体系,零新表):
//! - `df-update-auto-check`:bool,默认 true
//! - `df-update-check-interval-minutes`:int,默认 15
//! - `df-update-last-check`:ISO 时间戳(节流用)
//! - `df-update-notified-version`:string(同版本只提示一次)
use tauri::{AppHandle, State};
use tauri_plugin_updater::UpdaterExt;
use crate::state::AppState;
use super::err_str;
/// 检查更新结果(IPC 传输用)
#[derive(Debug, Clone, serde::Serialize)]
pub struct UpdateCheckResult {
/// 是否有可用更新
pub has_update: bool,
/// 新版本号(无更新时 None)
pub version: Option<String>,
/// 更新说明(无更新时 None)
pub notes: Option<String>,
/// 发布日期(无更新时 None)
pub date: Option<String>,
/// 当前运行版本(便于前端展示对比)
pub current_version: String,
}
/// 自动检查偏好(IPC 传输用)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct UpdatePrefs {
/// 启动时自动检查更新
pub auto_check: bool,
/// 检查间隔(分钟)
pub interval_minutes: u32,
/// 上次检查时间(ISO 时间戳,空=从未)
pub last_check: String,
/// 已提示过的版本号(同版本只提示一次)
pub notified_version: String,
}
/// KV keys — 集中定义防拼写漂移
const KEY_AUTO_CHECK: &str = "df-update-auto-check";
const KEY_INTERVAL_MINUTES: &str = "df-update-check-interval-minutes";
const KEY_LAST_CHECK: &str = "df-update-last-check";
const KEY_NOTIFIED_VERSION: &str = "df-update-notified-version";
const DEFAULT_INTERVAL_MINUTES: u32 = 15;
/// 检查是否有新版本(只读 + 外发网络)。
///
/// 走 Tauri updater 的 `check()`:拉取 `tauri.conf.json` plugins.updater.endpoints
/// 配置的 latest.json,内建 ed25519 签名校验(公钥在 pubkey)。无更新返回 `has_update=false`。
/// 网络失败/解析失败返回 Err(前端据此提示,不谎报「已是最新」)。
#[tauri::command]
pub async fn app_check_update(app: AppHandle) -> Result<UpdateCheckResult, String> {
let current_version = env!("CARGO_PKG_VERSION").to_string();
let updater = app.updater().map_err(err_str)?;
match updater.check().await {
Ok(Some(update)) => Ok(UpdateCheckResult {
has_update: true,
version: Some(update.version.clone()),
notes: update.body.clone(),
date: update.date.map(|d| d.to_string()),
current_version,
}),
Ok(None) => Ok(UpdateCheckResult {
has_update: false,
version: None,
notes: None,
date: None,
current_version,
}),
Err(e) => Err(format!("检查更新失败: {e}")),
}
}
/// 读取自动检查偏好(缺省:auto_check=true / interval=15 / last_check 空 / notified 空)
#[tauri::command]
pub async fn app_update_get_prefs(state: State<'_, AppState>) -> Result<UpdatePrefs, String> {
let auto_check = state
.settings
.get(KEY_AUTO_CHECK)
.await
.map_err(err_str)?
.map(|v| v.trim_matches('"') != "false")
.unwrap_or(true);
let interval_minutes = state
.settings
.get(KEY_INTERVAL_MINUTES)
.await
.map_err(err_str)?
.and_then(|v| v.trim_matches('"').parse::<u32>().ok())
.unwrap_or(DEFAULT_INTERVAL_MINUTES);
let last_check = state
.settings
.get(KEY_LAST_CHECK)
.await
.map_err(err_str)?
.map(|v| v.trim_matches('"').to_string())
.unwrap_or_default();
let notified_version = state
.settings
.get(KEY_NOTIFIED_VERSION)
.await
.map_err(err_str)?
.map(|v| v.trim_matches('"').to_string())
.unwrap_or_default();
Ok(UpdatePrefs {
auto_check,
interval_minutes,
last_check,
notified_version,
})
}
/// 写自动检查偏好(仅 auto_check / interval_minutes;last_check / notified_version 由内部节流逻辑写)
#[tauri::command]
pub async fn app_update_set_prefs(
state: State<'_, AppState>,
auto_check: bool,
interval_minutes: u32,
) -> Result<(), String> {
state
.settings
.set(KEY_AUTO_CHECK, if auto_check { "true" } else { "false" })
.await
.map_err(err_str)?;
state
.settings
.set(KEY_INTERVAL_MINUTES, &interval_minutes.to_string())
.await
.map_err(err_str)?;
Ok(())
}
/// 记录一次检查(写 last_check 时间戳)。前端检查后调用,供下次节流判定。
#[tauri::command]
pub async fn app_update_mark_checked(
state: State<'_, AppState>,
timestamp: String,
) -> Result<(), String> {
state
.settings
.set(KEY_LAST_CHECK, &timestamp)
.await
.map_err(err_str)?;
Ok(())
}
/// 记录已提示版本号(同版本只提示一次)。
#[tauri::command]
pub async fn app_update_mark_notified(
state: State<'_, AppState>,
version: String,
) -> Result<(), String> {
state
.settings
.set(KEY_NOTIFIED_VERSION, &version)
.await
.map_err(err_str)?;
Ok(())
}
// ═══ B3:下载安装 + 进度事件 ═══
// 设计 §四 L3 / §八 C2:下载+安装(passive 进度条窗口),成功即自动重启。
// 前端走「立即升级」确认后调用本命令;过程经 `app-update-progress` 事件流回传。
// 高危动作:前端已二次确认,本命令不再额外审批(纯应用内自升级,非工具调用)。
/// 下载安装进度事件载荷
#[derive(Debug, Clone, serde::Serialize)]
pub struct UpdateProgress {
/// 阶段:downloading(下载中)/ installing(安装中)
pub stage: String,
/// 已下载字节数(下载阶段有意义)
pub downloaded: u64,
/// 总字节数(下载阶段有意义;unknown 时 None)
pub total: Option<u64>,
}
/// 下载并安装更新(成功即自动重启)。
///
/// 流程:check() 复验版本 → download_and_install(带 on_chunk 进度回调 →
/// emit `app-update-progress` 事件) → 安装完 Tauri 自动重启(passive 模式弹进度条)。
///
/// 失败(网络断/签名校验失败/安装失败)返回 Err,旧版不受影响(下载在临时目录,
/// 校验通过才替换)。前端据此提示重试。
#[tauri::command]
pub async fn app_download_install_update(app: AppHandle) -> Result<(), String> {
use tauri::Emitter;
let updater = app.updater().map_err(err_str)?;
let update = updater
.check()
.await
.map_err(|e| format!("检查更新失败: {e}"))?
.ok_or_else(|| "当前已是最新版本".to_string())?;
let app_handle = app.clone();
let mut downloaded: u64 = 0;
update
.download_and_install(
move |chunk_len, content_len| {
downloaded += chunk_len as u64;
let _ = app_handle.emit(
"app-update-progress",
UpdateProgress {
stage: "downloading".to_string(),
downloaded,
total: content_len,
},
);
},
move || {
// 下载完成,进入安装阶段
let _ = app.emit(
"app-update-progress",
UpdateProgress {
stage: "installing".to_string(),
downloaded,
total: None,
},
);
},
)
.await
.map_err(|e| format!("下载/安装更新失败: {e}"))?;
// 安装成功后 Tauri 自动触发重启(passive 模式),此处返回即终结
Ok(())
}
+14
View File
@@ -42,6 +42,8 @@ pub fn run() {
.plugin(tauri_plugin_window_state::Builder::default().build())
// P1 bg 化:系统通知插件(Rust 侧 notify.rs 条件发送:主窗可见聚焦不发 + 同类节流 + 失败静默)
.plugin(tauri_plugin_notification::init())
// B2 应用内升级:tauri-plugin-updater(内建 ed25519 签名校验;端点/pubkey 见 tauri.conf.json plugins.updater)
.plugin(tauri_plugin_updater::Builder::new().build())
// Phase3 单实例:二次启动不新开进程,唤起已有实例主窗口(防多实例抢 MCP 18765 端口)
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
tray::show_main(app);
@@ -545,6 +547,8 @@ pub fn run() {
commands::ai::audit::record::list_task_conversations,
// AC-5 按工具失败率统计(诊断 IPC,聚合 ai_tool_executions 覆盖全量/时间范围)
commands::ai::audit::record::tool_failure_stats,
// D2a 外置工具结果读回(审计面板遇句柄按 path 读原文全文)
commands::ai::audit::record::ai_read_offloaded_result,
// 知识库
commands::knowledge::knowledge_list,
commands::knowledge::knowledge_get,
@@ -567,6 +571,16 @@ pub fn run() {
commands::settings::settings_get_all,
commands::settings::settings_delete,
commands::settings::get_data_dir,
// 应用版本号(编译期注入,关于页/更新检查消费)
commands::settings::app_get_version,
// B2 应用内升级:检查更新 + 自动检查偏好 KV
commands::update::app_check_update,
commands::update::app_update_get_prefs,
commands::update::app_update_set_prefs,
commands::update::app_update_mark_checked,
commands::update::app_update_mark_notified,
// B3 应用内升级:下载安装(进度事件 + 成功重启)
commands::update::app_download_install_update,
// 审批超时配置(默认 15 分钟,0=禁用;Settings 页可改)
commands::settings::ai_get_approval_timeout,
commands::settings::ai_set_approval_timeout,