From a8c92e9619a29742fb718ed60f93922ce55bcbdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 9 Oct 2026 22:53:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=86=85=E7=89=88=E6=9C=AC=E6=9B=B4=E6=96=B0=E4=B8=8E=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8D=87=E7=BA=A7=E5=85=A8=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 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。 --- Cargo.lock | 239 +++++++++- Cargo.toml | 9 +- crates/df-nodes/src/human_node.rs | 10 +- crates/df-workflow/src/state.rs | 18 + .../版本更新与自动升级-设计-2026-10-09.md | 361 +++++++++++++++ .../全栈代码审查报告-2026-06-14.md | 16 +- scripts/publish-release.ps1 | 121 +++++ scripts/swap-install.ps1 | 43 ++ src-tauri/Cargo.toml | 4 +- src-tauri/capabilities/default.json | 3 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/update.rs | 244 +++++++++++ src-tauri/src/lib.rs | 14 + src-tauri/tauri.conf.json | 12 + src/App.vue | 6 + src/api/settings.ts | 46 ++ src/components/layout/AppLayout.vue | 3 + src/components/layout/UpdateBadge.vue | 255 +++++++++++ src/components/settings/AboutPanel.vue | 413 ++++++++++++++++++ src/components/settings/SettingsNav.vue | 1 + src/components/settings/searchIndex.ts | 10 +- src/composables/useUpdateCheck.ts | 119 +++++ src/i18n/en/settings.ts | 38 ++ src/i18n/zh-CN/settings.ts | 38 ++ src/stores/index.ts | 4 - src/views/Settings.vue | 4 + swap-install.ps1 | 13 +- 27 files changed, 2021 insertions(+), 24 deletions(-) create mode 100644 docs/02-架构设计/专项设计/版本更新与自动升级-设计-2026-10-09.md create mode 100644 scripts/publish-release.ps1 create mode 100644 scripts/swap-install.ps1 create mode 100644 src-tauri/src/commands/update.rs create mode 100644 src/components/layout/UpdateBadge.vue create mode 100644 src/components/settings/AboutPanel.vue create mode 100644 src/composables/useUpdateCheck.ts diff --git a/Cargo.lock b/Cargo.lock index 4ee9ae5..4bb4dca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,6 +104,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "asn1-rs" version = "0.7.2" @@ -996,6 +1005,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1019,7 +1039,7 @@ dependencies = [ [[package]] name = "devflow" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "async-trait", @@ -1054,6 +1074,7 @@ dependencies = [ "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-single-instance", + "tauri-plugin-updater", "tauri-plugin-window-state", "tokio", "tokio-tungstenite 0.23.1", @@ -1571,6 +1592,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2572,6 +2603,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2896,6 +2957,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3277,6 +3344,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -3420,6 +3499,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -4059,15 +4152,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -4213,6 +4311,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -4661,6 +4786,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -4830,6 +4971,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4911,7 +5063,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -4944,6 +5096,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4968,7 +5131,7 @@ dependencies = [ "heck 0.5.0", "http", "http-range", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -5179,6 +5342,39 @@ dependencies = [ "zbus 5.16.0", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.4", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-plugin-window-state" version = "2.4.1" @@ -5204,7 +5400,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -5227,7 +5423,7 @@ checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -6376,6 +6572,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.8" @@ -7069,7 +7274,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -7133,6 +7338,16 @@ dependencies = [ "time", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xdg-home" version = "1.3.0" @@ -7379,6 +7594,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 6fb35a1..90cfa1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,9 @@ keyring = { version = "3", features = ["windows-native", "apple-native", "linux- dashmap = "6" # release 增量编译:小改动重打包显著提速(2026-10-04 打包提速项); -# 代价为产物略大,正式发版如在意体积可临时注释此段 -[profile.release] -incremental = true +# ⚠️ 2026-10-09 回退:release + incremental 触发链接期匿名符号丢失 +# (anon.*.llvm.* undefined symbol,rust-lld 与 MSVC link.exe 均复现), +# 根因是增量编译 CGU 划分与跨 crate 内联冲突。cargo check 不链接故不暴露, +# cargo build/tauri build 链接期必挂。release 禁用 incremental 为 Rust 官方推荐。 +# [profile.release] +# incremental = true diff --git a/crates/df-nodes/src/human_node.rs b/crates/df-nodes/src/human_node.rs index 0102904..01ad99a 100644 --- a/crates/df-nodes/src/human_node.rs +++ b/crates/df-nodes/src/human_node.rs @@ -70,10 +70,12 @@ impl Node for HumanNode { // 3. select! 循环:Response / 超时 / 取消 let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs); - let mut cancel_tick = tokio::time::interval(Duration::from_millis(500)); - cancel_tick.tick().await; // 丢弃首个立即触发的 tick + // FR-P4:取消检测由 500ms interval 轮询改为 Notify 被动唤醒(set_cancelled 触发)。 + // 先建 notified future 再进 loop,避免 notify 早于 await 的竞态窗口; + // 每轮 select! 后重建(Notified 单次消费),配合 is_cancelled 复查兜底。 loop { + let cancel_wait = ctx.node_status.cancel_notified(); tokio::select! { recv = rx.recv() => match recv { Ok(WorkflowEvent::HumanApprovalResponse { @@ -154,7 +156,9 @@ impl Node for HumanNode { _ = tokio::time::sleep_until(deadline) => { return Err(anyhow::anyhow!("人工审批超时({}s)", timeout_secs)); } - _ = cancel_tick.tick() => { + _ = cancel_wait => { + // FR-P4:Notify 唤醒后复查 is_cancelled(Notify 无状态, + // 可能因其他节点取消而误唤醒,须确认本节点确被取消)。 if ctx.node_status.is_cancelled(&ctx.node_id) { return Err(anyhow::anyhow!("人工审批被取消")); } diff --git a/crates/df-workflow/src/state.rs b/crates/df-workflow/src/state.rs index c9000b9..9458cf9 100644 --- a/crates/df-workflow/src/state.rs +++ b/crates/df-workflow/src/state.rs @@ -30,6 +30,10 @@ use df_types::types::{NodeId, NodeStatus}; pub struct StateMachine { /// 节点状态映射(Arc 共享,clone 浅拷贝同一 HashMap) states: Arc>>, + /// 取消信号唤醒器(FR-P4):set_cancelled 时 notify_waiters, + /// 阻塞节点(HumanNode)用 notified() 被动等待,替代 500ms 轮询。 + /// 与 states 同 Arc 共享,clone 浅拷贝同一 Notify。 + cancel_notify: Arc, } impl StateMachine { @@ -37,9 +41,19 @@ impl StateMachine { pub fn new() -> Self { Self { states: Arc::new(Mutex::new(HashMap::new())), + cancel_notify: Arc::new(tokio::sync::Notify::new()), } } + /// 获取取消信号等待器(FR-P4) + /// + /// 返回的 `Notified` future 在 `set_cancelled` 被调用时 resolve。 + /// 调用方应配合 `is_cancelled` 做条件判断(Notify 无状态,可能错过 + /// 早于 await 的 notify,故 select! 分支 resolve 后仍须复查 is_cancelled)。 + pub fn cancel_notified(&self) -> tokio::sync::futures::Notified<'_> { + self.cancel_notify.notified() + } + /// 获取节点状态 /// /// 锁中毒时降级返回 `NodeStatus::Pending`(保守默认:视为未启动, @@ -139,6 +153,10 @@ impl StateMachine { ); } } + // FR-P4:唤醒所有等待取消信号的阻塞节点(HumanNode notified 分支)。 + // notify_waiters 不持锁调用,避免与 states 锁耦合;Notify 无状态, + // 等待方 resolve 后仍复查 is_cancelled 兜底(防 notify 早于 await 丢失)。 + self.cancel_notify.notify_waiters(); } /// 获取所有状态快照(clone 返回,调用方持独立副本) diff --git a/docs/02-架构设计/专项设计/版本更新与自动升级-设计-2026-10-09.md b/docs/02-架构设计/专项设计/版本更新与自动升级-设计-2026-10-09.md new file mode 100644 index 0000000..8e1076c --- /dev/null +++ b/docs/02-架构设计/专项设计/版本更新与自动升级-设计-2026-10-09.md @@ -0,0 +1,361 @@ +# DevFlow 版本更新与自动升级 — 专项设计(2026-10-09) + +> 对象:应用内「版本可见 → 检查更新 → 下载 → 安装重启」全链路。 +> 标准:合理 · 兼容 · 优雅 · 可扩张。 +> 现状勘察:`src-tauri/tauri.conf.json` / `src-tauri/Cargo.toml` / `src-tauri/capabilities/default.json` / `src-tauri/src/lib.rs` / `src/views/Settings.vue` / `src/components/settings/SettingsNav.vue` / `scripts/package-devflow.ps1` / `swap-install.ps1` / u-desk 先例。 + +--- + +## 一、本质定性 + +### 1.1 两条"升级"通道必须分开命名 + +现状只有一个动作叫「换装」,但它其实是两个语义完全不同的事情,混称导致讨论失真: + +| 通道 | 触发源 | 使用者 | 目标 | 现载体 | +|---|---|---|---|---| +| **本地换装** | 本机源码改动 | 开发者(你) | 把刚编译的产物装到自己机器 | `scripts/package-devflow.ps1 -Mode full` / `swap-install.ps1` | +| **应用内升级** | 远端新版本 | 任何使用者 | 把手上的旧版升到新版 | **不存在** | + +**本设计只做第二条**。第一条保持不动(且是本设计的发布端依赖)。 + +### 1.2 为什么这件事值得做(而非"自用工具不需要") + +现状的隐性成本: + +1. **用户无法得知自己用的是哪版** —— 设置页无版本号展示,issue 反馈时只能靠猜("你那个缓存修复装上了吗?"——本次对话里就真实发生过:凌晨 02:50 装了包,13:02 又改了码,靠猜时间戳判断哪个生效)。 +2. **升级不可达** —— 未来若分发给他人(u-desk 已有对外分发先例),对方拿到 v0.1.1 后永远停在 v0.1.1,只能等新包重装。 +3. **升版本号是手工雷区** —— `swap-install.ps1` 硬编码 `devflow_0.1.1_x64-setup.exe`,改版本号忘改脚本 = 静默装回旧包(**静默伪成功**,最危险的一类故障)。 + +结论:**先做 L0(版本单一源 + 可见),再做 L1(检查→提示),最后 L2(下载安装)**。L0 交付即消除 1、3 两项,成本极低。 + +--- + +## 二、现状勘察(全部实证) + +### 2.1 三元缺口 + +| 层 | 现状 | 证据 | +|---|---|---| +| 插件层 | 无 `tauri-plugin-updater` | `src-tauri/Cargo.toml:18-25` 仅 dialog/opener/window-state/notification/single-instance | +| 配置层 | 无 `plugins.updater` 段 | `tauri.conf.json` 仅 `bundle.targets: ["nsis"]` | +| 逻辑层 | 无版本比对 IPC | `grep check_update\|latest_version\|auto_update` 于 `src-tauri/src` → 0 命中 | +| UI 层 | 无版本号/检查更新入口 | `grep 检查更新\|getVersion\|appVersion` 于 `src/` → 0 命中;SettingsNav 仅 7 分类 | + +### 2.2 现存缺陷(本设计顺带治理) + +**D1 — 版本双源不一致** + +``` +src-tauri/tauri.conf.json:3 "version": "0.1.1" ← 打包器实际用这个 +src-tauri/Cargo.toml:3 version = "0.1.0" ← Rust crate 用这个 +``` + +Tauri 2 中 `bundle` 版本取 `tauri.conf.json`,`CARGO_PKG_VERSION` 取 Cargo.toml。当前两者已经分叉(0.1.1 vs 0.1.0),未来必然出「设置页显示 0.1.0,实际装的是 0.1.1」这类灵异现象。 + +**D2 — 换装脚本硬编码版本号** + +```powershell +# swap-install.ps1:8 +$installer = 'E:\wk-lab\devflow\target\release\bundle\nsis\devflow_0.1.1_x64-setup.exe' +``` + +版本号进入文件名,升版忘改 → 找不到文件 → `Start-Process` 抛错(尚好);若旧版包残留同目录,则**装回旧版**(伪成功)。必须改为 glob 探测(`package-devflow.ps1:44` 已用 `Sort-Object LastWriteTime -Descending | Select-Object -First 1` 的正确姿势,swap-install 未对齐)。 + +### 2.3 可复用的成熟资产 + +**u-desk(同作者的 Wails 应用)已完成全套更新链路**,虽技术栈不同(Go+Wails vs Rust+Tauri),但**方案骨架可直接借鉴**: + +- 清单托管:`https://img.1216.top/u-desk/last-version.json`(七牛 CDN) +- 后端:`internal/api/update_api.go` + `internal/service/update_service.go` + - `CheckUpdate()` / `GetCurrentVersion()` / `GetUpdateConfig()` / `SetUpdateConfig()` + - `DownloadUpdate()` 异步 + `download-progress` 事件流 + - `InstallUpdateWithHash()` 哈希校验后安装 + - 自动检查定时器(`startAutoUpdateCheck` 可重入,配置变更 `resetAutoUpdateCheck` 免重启生效) +- 前端:`update-available` 事件驱动弹窗 + +**关键差异**:u-desk 是自研(无签名体系);Tauri 2 官方 updater **内建 ed25519 签名校验**,安全性更好,应优先采用官方插件而非照搬自研。 + +**另有可复用的健壮性经验**:`swap-install.ps1` 里已验证有效的三件套—— +1. `Wait-HandleReleased()`:独占打开目标 exe 探测句柄释放(治 Windows 文件锁延迟释放致 NSIS 静默伪成功) +2. SHA256 三级判据(等磁盘快照 / 两次确定性输出 / 实变) +3. SYSTEM 上下文转投交互用户会话(GUI 才可见) + +这三条应在应用内升级的「安装前等待」阶段复刻(Rust 侧实现等价逻辑)。 + +--- + +## 三、方案选型 + +### 方案 A:Tauri 官方 `tauri-plugin-updater`(推荐) + +**能力**:`check()` → `Update` 对象(version/notes/date)→ `download_and_install(on_chunk)` → 自动重启。 + +**优点** +- 内建 **ed25519 签名校验**(防篡改,安全基线) +- 跨平台统一(NSIS / MSI / AppImage / dmg) +- 与 Tauri 窗口生命周期原生协同(安装前自动关窗) +- 官方维护,随 Tauri 2 演进 + +**代价** +- 需引入签名密钥(`tauri signer generate`,公钥入配置,私钥入打包环境) +- 打包需开 `bundle.createUpdaterArtifacts: true`(产出 `.nsis.zip` + `.nsis.zip.sig`) +- 端点必须 HTTPS + 清单格式固定(`latest.json`) + +### 方案 B:自研(照搬 u-desk 模式) + +**优点**:零新依赖,可完全复用 `swap-install` 的静默安装动作。 + +**缺点**:签名/校验/回滚/进度全自研;`latest.json` 契约要自己定;安全基线靠自己守。 + +### 选型结论:**A 为基座,B 的本地动作经验作为"安装前等待"的健壮性补丁喂给 A** + +理由: +- 官方插件把「签名校验」这件最容易做错的事做对了,没有理由重造。 +- devflow 是**联网分发**场景(未来对外),签名是刚需;u-desk 的裸下载在自用场景够,对外不够。 +- 本地换装通道(脚本)**不废弃**,继续服务开发期高频迭代(它比走远端快得多)。 + +--- + +## 四、分层设计 + +``` +┌─ L3 发布侧(脚本增强)────────────────────────────┐ +│ tauri build → 产出 .nsis.zip + .sig │ +│ → 生成 latest.json(version/notes/pub_date/urls/sig)│ +│ → 上传 app.1216.top/download/devflow/ │ +└────────────────────────────────────────────────────┘ + ↑ 拉取 +┌─ L2 前端 ──────────────────────────────────────────┐ +│ SettingsNav 新增「关于」分类(第 8 类) │ +│ AboutPanel.vue:版本号 / 检查更新 / 更新弹窗 / 进度 │ +└────────────────────────────────────────────────────┘ + ↑ IPC +┌─ L1 后端(Rust)───────────────────────────────────┐ +│ tauri-plugin-updater 注册 + capabilities 授权 │ +│ IPC: app_get_version / app_check_update / │ +│ app_download_install_update │ +│ 本地 KV: df-update-auto-check / -interval-minutes / │ +│ -last-check / -notified-version │ +└────────────────────────────────────────────────────┘ + ↑ 单一源 +┌─ L0 版本治理 ──────────────────────────────────────┐ +│ 单一版本源 + 设置页展示版本号(零风险,先落) │ +└────────────────────────────────────────────────────┘ +``` + +### L0:版本单一源治理 + +**决策**:以 `tauri.conf.json` 的 `version` 为**唯一权威源**(打包器实际使用它)。 + +**动作** +1. `src-tauri/Cargo.toml` 的 `version` 对齐同名值(人工同步,或加校验脚本);**§2.2-D1 双源问题在此关闭**。 +2. 新增 IPC `app_get_version`:返回 `env!("CARGO_PKG_VERSION")` + 构建时间 + git short hash(option_env! 注入,未注入显示 `dev`)。 +3. `swap-install.ps1:8` 改为 glob 探测(**§2.2-D2 在此关闭**): + ```powershell + $installer = (Get-ChildItem 'E:\wk-lab\devflow\target\release\bundle\nsis\devflow_*_x64-setup.exe' | + Sort-Object LastWriteTime -Descending | Select-Object -First 1).FullName + ``` +4. 设置页「关于」展示版本号(L0 即可交付的可见价值)。 + +**扩张性**:未来 `df-im-gateway` 等独立服务走各自版本线(在其自身发布 skill 内治理),本设计不扩范围,但 `app_get_version` 的返回结构预留 `components: []` 数组位。 + +### L1:后端接入(插件注册 + 检查) + +**Cargo.toml** +```toml +tauri-plugin-updater = "2" +``` + +**lib.rs**(`Builder` 链,插在 notification 之后) +```rust +.plugin(tauri_plugin_updater::Builder::new().build()) +``` + +**capabilities/default.json** 补 +```json +"updater:default" +``` + +**tauri.conf.json** +```json +"bundle": { + "active": true, + "targets": ["nsis"], + "createUpdaterArtifacts": true +}, +"plugins": { + "updater": { + "endpoints": ["https://app.1216.top/download/devflow/latest.json"], + "pubkey": "", + "windows": { "installMode": "passive" } + } + } + ``` +> `installMode: passive` —— **不是静默**:弹进度条窗口,用户看得见进度,但无需点击「下一步」,装完自动往下走。 +> 三档对比:`basic`(完整向导,需点下一步)/ `passive`(进度条,无需点击)/ `quiet`(完全无 UI,**失败不可见**,不推荐)。 +> 注:`swap-install.ps1` 用的 NSIS `/S` 是真静默(等价 `quiet`),应用内升级刻意不采用——面向用户自己机器的升级需要失败可见性。 + +**IPC 契约**(新增 3 个,命名沿用 `app_*` 前缀) + +| 命令 | 入参 | 返回 | 风险档 | +|---|---|---|---| +| `app_get_version` | — | `{version, build_time, git_hash}` | 只读 | +| `app_check_update` | — | `{has_update, version?, notes?, date?}` | 只读(外发网络) | +| `app_download_install_update` | — | 流式事件 + 成功即重启 | **高风险(需审批)** | + +**配置 KV**(复用既有 appSettings 体系,零新表) +- `df-update-auto-check`:bool,默认 `true` +- `df-update-check-interval-minutes`:int,默认 `15` +- `df-update-last-check`:ISO 时间戳(节流用,防每次启动都拉) +- `df-update-notified-version`:string(**同版本只提示一次**,防骚扰) + +**自动检查策略**:启动后延迟 **3s** → 拉清单 → 有更新则发事件 `app-update-available` 到主窗;此后**每 15 分钟**检查一次,但**同一版本号只提示一次**(用户点「稍后」后不再重复弹)。**只提示不自动装**(安装是高风险动作,必须人点头)。 + +### L2:前端(设置页「关于」分类) + +**SettingsNav** 加第 8 类: +```ts +{ value: 'about', icon: 'ℹ️', labelKey: 'settings.navAbout' }, +``` +`searchIndex.ts` 补对应条目(`SettingsCategory` 联合类型同步扩 `'about'`)。 + +**新增 `src/components/settings/AboutPanel.vue`**(对齐既有 panel 视觉语言) + +``` +┌─ 关于 DevFlow ─────────────────────────┐ +│ 当前版本 v0.1.2 (a1b2c3d) [检查更新] │ +│ 构建时间 2026-10-09 14:30 │ +│ ─────────────────────────────────────── │ +│ ☑ 启动时自动检查更新(启动后 3 秒) │ +│ 检查间隔 [15 分钟 ▾] │ +│ ─────────────────────────────────────── │ +│ 上次检查 2026-10-09 14:35 │ +└─────────────────────────────────────────┘ +``` + +**更新弹窗**(复用既有 ConfirmDialog 视觉,含:新版本号 / 更新说明 markdown / 下载进度条 / 「立即更新并重启」「稍后」) + +**事件订阅**(对齐既有 `useAiEvents` 模式) +- `app-update-available` → 顶栏小红点 + 弹窗 +- `app-update-progress` → 进度条(`downloaded` / `total` / 百分比) + +**i18n**:zh-CN / en 双语同步补 `settings.navAbout` / `settings.about*` / `update.*` 系列键。 + +### L3:发布侧脚本增强 + +新增 `scripts/publish-release.ps1`(或扩展 `package-devflow.ps1` 加 `-Mode release` 通道): + +1. 前置:校验 `tauri.conf.json.version` 与 `Cargo.toml.version` 一致(防 D1 复发) +2. tauri build(含 createUpdaterArtifacts → .nsis.zip + .sig) +3. 计算 sha256 + 读取 .sig 内容 +4. 生成 latest.json: + { + "version": "0.1.2", + "notes": "<从 docs/CHANGELOG.md 提取本节>", + "pub_date": "2026-10-09T14:30:00Z", + "platforms": { + "windows-x86_64": { + "signature": "<.sig 内容>", + "url": "https://app.1216.top/download/devflow/devflow_0.1.2_x64-setup.nsis.zip" + } + } + } +5. 上传 latest.json + .nsis.zip + .sig + 裸 exe 到 **app.1216.top** `download/devflow/`(走 publish-app-site 通道:suke-dev / `/var/www/app-site/`) +6. `app.1216.top` 的 JSON 已配 `Cache-Control: no-cache` → **无需刷 CDN**,发布即生效 +``` + +**密钥管理** +- 一次性:`tauri signer generate -w ~/.tauri/devflow.key` → 公钥入 `tauri.conf.json` 的 `pubkey` +- 打包时设环境变量 `TAURI_SIGNING_PRIVATE_KEY`(路径)+ `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` +- **私钥绝不入仓**(`.gitignore` 补 `*.key`) + +--- + +## 五、兼容性核验 + +| 既有资产 | 影响 | 结论 | +|---|---|---| +| `scripts/package-devflow.ps1` | 不动 verify/full 两通道,L3 新增独立通道 | ✔ 零回归 | +| `swap-install.ps1` | 仅改第 8 行硬编码 → glob(D2 修复) | ✔ 行为等价,更健壮 | +| `lib.rs` Builder 链 | 追加一个 `.plugin()`,不触碰既有 5 个插件与 68 IPC | ✔ 零侵入 | +| `capabilities/default.json` | 追加一行 `updater:default` | ✔ 纯增量 | +| SettingsNav 7 类 | 追加第 8 类,搜索/防抖/持久化逻辑自动覆盖(`SETTINGS_INDEX` 驱动) | ✔ 架构自洽 | +| appSettings KV 体系 | 新增 4 个 key,复用既有读写 | ✔ 零新表零迁移 | +| `tauri.conf.json` targets | 保持 `["nsis"]`,只加 `createUpdaterArtifacts` | ✔ 不扩大构建目标(对齐知识库「targets 收窄需还原」的教训:本项是新增字段非收窄,安全) | +| devflow.db / devflow-dev.db | L1/L2 只读 KV,不涉 schema | ✔ 零库变更 | + +**dex 依赖**:无。**P0 依赖**:无(可立即开工)。 + +--- + +## 六、实施批次(全部可独立交付) + +| 批次 | 规模 | 内容 | 验收 | +|---|---|---|---| +| **B1** | S | L0 版本治理:Cargo.toml 对齐 0.1.1 / `app_get_version` IPC / swap-install glob 修复 / 设置页版本号展示 | `cargo check -p devflow` 0 error;设置页可见 `v0.1.1`;换装脚本升版后仍能正确取包 | +| **B2** | M | L1 插件接入 + `app_check_update` IPC + 自动检查节流 + `app-update-available` 事件 | 手动改远端 `latest.json` 版本号 → 应用内提示可用 | +| **B3** | M | L2 下载安装:进度事件 + 更新弹窗 + `app_download_install_update` + 重启 | 端到端:旧版 → 提示 → 下载 → 装 → 重启后版本号变新 | +| **B4** | S | L3 发布脚本 + 密钥生成 + latest.json 生成上传 | 一条命令产出并上传可分发的更新清单(app.1216.top 无需刷缓存) | + +**建议序**:B1 立即做(零风险、当天可交付、消除 D1/D2 两个真实缺陷);B2→B3 一起做(拆开无独立价值);B4 在首次真实发版前完成即可。 + +**总估算**:B1 0.5 天 / B2+B3 1.5-2 天 / B4 0.5 天 ≈ **2.5-3 天**。 + +--- + +## 七、风险与边界 + +| 风险 | 等级 | 对策 | +|---|---|---| +| 私钥泄露 | **高** | `.gitignore` 拦 `*.key`;仅打包机存私钥;公钥入仓无妨 | +| ~~CDN 缓存陈旧致拉了旧清单~~ | ~~中~~ **已消解** | C1 改走 `app.1216.top`(已配 `Cache-Control: no-cache`),无缓存陈旧问题;可选 `?t=` 兜底 | +| 更新包下载中断 | 中 | 插件内建重试;失败保留旧版不破坏现状 | +| 装完起不来(坏包) | 中 | `installMode: passive` 失败可见;保留上一版安装包以备手回滚 | +| 自动检查打扰 | 低 | 15 分钟周期 + **同版本只提示一次** + 只提示不自动装 + 可关 | +| 版本双源复发 | 低 | B1 加校验(发布脚本第 1 步)+ 可选 `build.rs` 断言 | + +**明确不做(本设计范围外)** +- 增量更新(delta update)——体量收益不值复杂度 +- 灰度/分渠道发布 +- 非主应用组件(`df-im-gateway` 等)的版本线 +- 回滚 UI(保留手动重装路径即可) + +--- + +## 八、决策点(✅ 2026-10-09 拍板定稿) + +| # | 议题 | ✅ 定稿 | 依据 | +|---|---|---|---| +| **C1** | 清单托管位 | **`app.1216.top/download/devflow/`** | 产品门户语义正确;该站已配 `Cache-Control: no-cache`,发布即生效,无需刷 CDN;通道(suke-dev / `/var/www/app-site/`)现成 | +| **C2** | 安装模式 | **`passive`**(进度条窗口,无需点击) | 非静默;`quiet` 完全无感但失败不可见(静默伪成功风险) | +| **C3** | B1 是否先行 | **立即开工** | 零风险、当天交付,消 D1/D2 两个真实缺陷 | +| **C4** | 自动检查节奏 | **启动后 3s → 周期 15 分钟** + 同版本单次提示 + 关于页手动按钮 | 用户定:3s / 15min | + +### C1 补充决策:双产物并存(站点规范 vs Tauri 强制格式) + +app-site 站规为「不打 zip,直接裸 exe」,而 Tauri updater **强制** `.nsis.zip` + `.sig`。故 `download/devflow/` 并存四项: + +| 文件 | 给谁 | 合规 | +|---|---|---| +| `devflow__x64-setup.exe` | 手动下载的用户 | ✅ 站点「裸 exe」规范 | +| `devflow__x64-setup.nsis.zip` | updater 拉取 | ✅ Tauri 格式强制 | +| `devflow__x64-setup.nsis.zip.sig` | updater 验签 | ✅ | +| `latest.json` | updater 清单 | ✅ | + +### C4 补充决策:体验设计「环境式更新」 + +**核心原则**:检查频率 ≠ 体验。体验由「提醒方式 / 去重 / 过程可见 / 时机可控」四件事决定。 + +| 触点 | ✅ 定稿 | +|---|---| +| 平时 | 完全安静,零存在感 | +| 发现新版 | **顶栏非模态徽章**(不弹窗打断) | +| 查看详情 | 点徽章展开:版本号 + 更新说明 + 包大小 | +| 决定 | 「立即升级」/「稍后」双选(**不自动下载安装**) | +| 下载 | 进度条 + 可取消 | +| 安装 | `passive` 进度窗口,无需点击 | +| 完成 | 重启后 toast「已升级到 vX.Y.Z」 | +| 反复 | **同版本只提示一次**(KV `df-update-notified-version`) | + +**为什么必须是「环境式」而非「隐形型(全自动)」**:DevFlow 是**有状态工作工具**(可能正跑长任务/开着会话),任何自动重启都会打断工作。时机控制权必须交给用户。 diff --git a/docs/05-代码审查/全栈代码审查报告-2026-06-14.md b/docs/05-代码审查/全栈代码审查报告-2026-06-14.md index ff87b10..dfcc005 100644 --- a/docs/05-代码审查/全栈代码审查报告-2026-06-14.md +++ b/docs/05-代码审查/全栈代码审查报告-2026-06-14.md @@ -259,7 +259,7 @@ > **已完成**:`SELECT *` 改为显式 14 列(id, kind, title, content, tags, status, confidence, reuse_count, verified, source_project, source_ref, reasoning, created_at, updated_at),`embedding` 单独另取。消除隐式依赖,字段级精简待单独立项。commit 4a95f6a(该 commit 内编号为 FR-D2)。 -### 🟡 FR-P3 — 全局单 SQLite 连接串行瓶颈 [仅代理报告待复核] +### 🟡 FR-P3 — 全局单 SQLite 连接串行瓶颈 [仅代理报告待复核] ⏸️ 评估后不改(2026-10-08) **位置**:`crates/df-storage/src/db.rs:13-60` + `crud.rs` 宏内 `spawn_blocking`+`blocking_lock` @@ -269,7 +269,9 @@ **建议**:r2d2/deadpool-sqlite 连接池(写单连接+Mutex,读多连接并发);最低限度换 `std::sync::Mutex`(spawn_blocking 同步上下文内开销更低,不跨 await)。 -### 🟡 FR-P4 — `human_node` 500ms 忙轮询取消状态 [已核实] +> **⏸️ 2026-10-08 评估后判定不改**:① 架构约束——`conn()` 返 `Arc>`,全仓数十处调用点按此签名取锁,改连接池需重构全部调用点 + 迁移逻辑(`run_migrations` 亦持单锁),改动面大风险高;② 收益极低——SQLite 写操作本就串行(WAL 下写写互斥),连接池对写无收益,读并发在桌面单用户场景无实际压力;③ 现状已缓解——`busy_timeout(5000)` 已解 GUI+MCP server 多进程写冲突(真实痛点)且已落地;④ 收益/风险比不划算,动 DB 核心层高危。保留 `db.rs:60` TODO 注释备忘。 + +### 🟡 FR-P4 — `human_node` 500ms 忙轮询取消状态 [已核实] ✅ 已完成(2026-10-08) **位置**:`crates/df-nodes/src/human_node.rs:54` @@ -279,7 +281,9 @@ **建议**:取消走独立 `tokio::sync::Notify`/oneshot,HumanNode `notify.notified().await` 零轮询。注:todo.md:142 已记"停止生成 idle 用 Notify 替代轮询",此为同类不同位置(审批节点 vs stopChat)。 -### 🟡 FR-P5 — `build_system_prompt` 每次发消息全表扫项目 [仅代理报告待复核] +> **✅ 2026-10-08 已完成**:`StateMachine` 加 `cancel_notify: Arc` 字段(与 states 同 Arc 共享,clone 浅拷贝同一 Notify);`set_cancelled` 置位后 `notify_waiters()` 唤醒;新增 `cancel_notified()` 暴露 `Notified` future。`human_node.rs` select! 循环内 `let cancel_wait = ctx.node_status.cancel_notified()` 替代 `cancel_tick.tick()`,唤醒后仍复查 `is_cancelled` 兜底(Notify 无状态,可能因他节点取消误唤醒)。`cargo check -p df-workflow -p df-nodes` EXIT 0;`cargo test -p df-nodes -p df-workflow` 62+3 passed(含 `human_workflow_cancel_via_shared_state_machine` 取消路径测试)。 + +### 🟡 FR-P5 — `build_system_prompt` 每次发消息全表扫项目 [仅代理报告待复核] ✅ 已完成(指纹缓存) **位置**:`src-tauri/src/commands/ai/prompt.rs:70-86` @@ -289,7 +293,9 @@ **建议**:项目列表变动时刷新缓存(已有 path 绑定事件可触发),或注入前比对 updated_at 决定是否重算。 -### 🟡 FR-P6 — `AiChat.vue` watch messages 用 JSON.stringify 全量快照 [仅代理报告待复核] +> **✅ 已完成(指纹缓存)**:`prompt.rs:388,447` 已落地指纹比对——`SELECT id, updated_at FROM projects WHERE deleted_at IS NULL`(仅取轻量两列,省 `list_active` 全行物化),指纹未变则复用缓存字符串,变更才重建;查询失败回退「强制重建」保守安全。原「全表扫 + 每次重拼」病灶已消除。 + +### 🟡 FR-P6 — `AiChat.vue` watch messages 用 JSON.stringify 全量快照 [仅代理报告待复核] ✅ 已完成(已消除) **位置**:`src/components/AiChat.vue:670-678` @@ -299,6 +305,8 @@ **建议**:watch 具体派生信号(如 toolCalls 的 status 集合)而非整个 messages deep 快照。 +> **✅ 已完成(已消除)**:`AiChat.vue` 全仓 grep `JSON.stringify` 零命中——原 deep watch 全量快照已随消息渲染重构移除(AiChat 组件已按职责抽离,消息 watch 逻辑迁移至 MessageList 等子组件,改用具体派生信号)。病灶不复存在。 + --- ## 6. 可读性 / DRY(重构项) diff --git a/scripts/publish-release.ps1 b/scripts/publish-release.ps1 new file mode 100644 index 0000000..72ec688 --- /dev/null +++ b/scripts/publish-release.ps1 @@ -0,0 +1,121 @@ +# 用途: DevFlow 应用内升级发布通道(B4/L3) +# 构建 updater 产物(.nsis.zip + .sig) -> 生成 latest.json -> 上传 app.1216.top +# 设计: docs/02-架构设计/专项设计/版本更新与自动升级-设计-2026-10-09.md §四 L3 +# +# 用法: +# .\scripts\publish-release.ps1 # 用 git 最近提交作 notes +# .\scripts\publish-release.ps1 -Notes "修复xx" # 手动指定更新说明 +# .\scripts\publish-release.ps1 -SkipBuild # 复用已有产物(仅生成清单+上传) +# +# 前置: +# 1. tauri.conf.json 与 Cargo.toml 版本号一致(脚本第 1 步校验,防 D1 复发) +# 2. 签名私钥就位: $env:TAURI_SIGNING_PRIVATE_KEY = "$env:USERPROFILE\.tauri\devflow.key" +# (无密码密钥无需 TAURI_SIGNING_PRIVATE_KEY_PASSWORD) +# 3. ssh-proxy 已配 suke-dev(39.99.243.191),web 根 /var/www/app-site/ +param( + [string]$Notes = "", + [switch]$SkipBuild +) + +$ErrorActionPreference = "Stop" +$repo = "E:\wk-lab\devflow" +$site = "suke-dev" +$remoteDir = "/var/www/app-site/download/devflow" +$baseUrl = "https://app.1216.top/download/devflow" + +Set-Location $repo + +# ── 1. 版本双源校验(防 D1 复发)── +Write-Host "[1/6] 校验版本双源一致..." +$confVer = (Get-Content "$repo\src-tauri\tauri.conf.json" -Raw | ConvertFrom-Json).version +$cargoVer = (Select-String -Path "$repo\src-tauri\Cargo.toml" -Pattern '^version\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value +if ($confVer -ne $cargoVer) { + Write-Host "[失败] 版本不一致: tauri.conf.json=$confVer, Cargo.toml=$cargoVer" + Write-Host " 请先对齐两处版本号再发布(设计 D1:双源必须同值)" + exit 1 +} +$version = $confVer +Write-Host " 版本 v$version 双源一致,通过" + +# ── 2. 签名私钥就位检查 ── +if (-not $env:TAURI_SIGNING_PRIVATE_KEY) { + $keyPath = "$env:USERPROFILE\.tauri\devflow.key" + if (Test-Path $keyPath) { + $env:TAURI_SIGNING_PRIVATE_KEY = $keyPath + Write-Host "[2/6] 签名私钥: $keyPath" + } else { + Write-Host "[失败] 未找到签名私钥($keyPath),且未设 TAURI_SIGNING_PRIVATE_KEY" + Write-Host " 先执行: cargo tauri signer generate -w `$env:USERPROFILE\.tauri\devflow.key" + exit 1 + } +} else { + Write-Host "[2/6] 签名私钥: 取自环境变量" +} + +# ── 3. 构建 updater 产物 ── +$nsisDir = "$repo\target\release\bundle\nsis" +if (-not $SkipBuild) { + Write-Host "[3/6] tauri build(含 createUpdaterArtifacts,可能 10 分钟级)..." + cmd /c "cargo tauri build 2>&1" + if ($LASTEXITCODE -ne 0) { Write-Host "[失败] tauri build"; exit 1 } +} else { + Write-Host "[3/6] 跳过构建(-SkipBuild),复用已有产物" +} + +# 定位产物:裸 exe / .nsis.zip / .sig +$setupExe = Get-ChildItem "$nsisDir\devflow_${version}_x64-setup.exe" -ErrorAction SilentlyContinue | Select-Object -First 1 +$zipFile = Get-ChildItem "$nsisDir\devflow_${version}_x64-setup.nsis.zip" -ErrorAction SilentlyContinue | Select-Object -First 1 +$sigFile = Get-ChildItem "$nsisDir\devflow_${version}_x64-setup.nsis.zip.sig" -ErrorAction SilentlyContinue | Select-Object -First 1 +if (-not $zipFile) { Write-Host "[失败] 未找到 .nsis.zip 产物(确认 createUpdaterArtifacts=true)"; exit 1 } +if (-not $sigFile) { Write-Host "[失败] 未找到 .sig 签名文件"; exit 1 } +Write-Host " 产物: $($zipFile.Name) ($([math]::Round($zipFile.Length/1MB,1))MB)" + +# ── 4. 生成 latest.json ── +Write-Host "[4/6] 生成 latest.json..." +$sig = (Get-Content $sigFile.FullName -Raw).Trim() +if (-not $Notes) { + # 用 git 最近一条提交作 notes(无 CHANGELOG 时的兜底) + $Notes = (git log -1 --pretty=format:"%s" 2>&1) -join "`n" + if (-not $Notes) { $Notes = "DevFlow v$version" } +} +$pubDate = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") +$sha256 = (Get-FileHash $zipFile.FullName -Algorithm SHA256).Hash.ToLower() +$latestJson = [ordered]@{ + version = $version + notes = $Notes + pub_date = $pubDate + platforms = [ordered]@{ + "windows-x86_64" = [ordered]@{ + signature = $sig + url = "$baseUrl/devflow_${version}_x64-setup.nsis.zip" + } + } +} | ConvertTo-Json -Depth 5 +$latestPath = "$repo\target\release\bundle\nsis\latest.json" +$latestJson | Out-File $latestPath -Encoding utf8 -NoNewline +Write-Host " latest.json: version=$version, sha256=$($sha256.Substring(0,16))..." + +# ── 5. 上传到 app.1216.top ── +Write-Host "[5/6] 上传到 $site`:$remoteDir ..." +$env:MSYS_NO_PATHCONV = "1" +# 确保远程目录存在 +ssh-proxy exec -n $site -c "mkdir -p $remoteDir" +if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 创建远程目录"; exit 1 } + +$uploads = @($latestPath, $zipFile.FullName, $sigFile.FullName) +if ($setupExe) { $uploads += $setupExe.FullName } +foreach ($f in $uploads) { + $name = Split-Path $f -Leaf + Write-Host " 上传 $name ..." + ssh-proxy upload --name $site --src $f --dst "$remoteDir/$name" + if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 上传 $name"; exit 1 } +} + +# ── 6. 验证 ── +Write-Host "[6/6] 验证远端清单..." +$remoteCheck = ssh-proxy exec -n $site -c "ls -la $remoteDir && echo '---' && cat $remoteDir/latest.json | head -5" +Write-Host $remoteCheck +Write-Host "" +Write-Host "[发布完成] v$version" +Write-Host " 清单地址: $baseUrl/latest.json" +Write-Host " 应用内检查更新即可发现新版本(app.1216.top 已配 no-cache,无需刷 CDN)" diff --git a/scripts/swap-install.ps1 b/scripts/swap-install.ps1 new file mode 100644 index 0000000..5a05097 --- /dev/null +++ b/scripts/swap-install.ps1 @@ -0,0 +1,43 @@ +# DevFlow 换装脚本:kill 旧进程 -> 静默安装 -> 启动验证 +# 独立后台执行,避免中断当前会话导致换装半途而废 +$ErrorActionPreference = 'Continue' +$log = 'E:/wk-lab/devflow/swap.log' +function Log($m) { "$(Get-Date -Format 'HH:mm:ss') $m" | Tee-Object -FilePath $log -Append } + +Log "=== 换装开始 ===" + +# 1. kill 旧进程 +$procs = Get-Process devflow -ErrorAction SilentlyContinue +if ($procs) { + Log "发现运行中 devflow 进程: $($procs.Id -join ','),正在结束..." + $procs | Stop-Process -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 3 + $still = Get-Process devflow -ErrorAction SilentlyContinue + if ($still) { Log "警告: 仍有残留进程 $($still.Id -join ',')"; $still | Stop-Process -Force -ErrorAction SilentlyContinue; Start-Sleep -Seconds 2 } + Log "旧进程已结束" +} else { + Log "无运行中 devflow 进程" +} + +# 2. 静默安装新包 +$setup = 'E:/wk-lab/devflow/target/release/bundle/nsis/devflow_0.1.1_x64-setup.exe' +if (-not (Test-Path $setup)) { Log "错误: 安装包不存在 $setup"; exit 1 } +Log "静默安装: $setup" +$p = Start-Process -FilePath $setup -ArgumentList '/S' -PassThru -Wait +Log "安装器退出码: $($p.ExitCode)" +Start-Sleep -Seconds 3 + +# 3. 启动验证 +$installed = 'C:\Users\23780\AppData\Local\devflow\devflow.exe' +if (Test-Path $installed) { + $ver = (Get-Item $installed).VersionInfo.FileVersion + Log "已安装二进制: $installed (FileVersion=$ver)" + Start-Process -FilePath $installed + Start-Sleep -Seconds 5 + $new = Get-Process devflow -ErrorAction SilentlyContinue + if ($new) { Log "启动成功, PID=$($new.Id -join ',')" } else { Log "警告: 启动后未检测到进程" } +} else { + Log "警告: 未找到安装后二进制 $installed" +} + +Log "=== 换装结束 ===" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 355cb97..1406ac6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devflow" -version = "0.1.0" +version = "0.1.1" description = "DevFlow Desktop App" authors = [""] edition = "2021" @@ -23,6 +23,8 @@ tauri-plugin-window-state = "2" tauri-plugin-notification = "2" # Phase3 单实例:二次启动唤起已有进程主窗口,防多实例抢 18765 端口 tauri-plugin-single-instance = "2" +# B2 应用内升级:L1 接入 tauri-plugin-updater(内建 ed25519 签名校验) +tauri-plugin-updater = "2" serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index d351c18..4c4c8ef 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -24,6 +24,7 @@ "core:webview:allow-create-webview-window", "dialog:default", "window-state:default", - "notification:default" + "notification:default", + "updater:default" ] } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f1a2493..b4d31be 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -13,6 +13,7 @@ pub mod project; pub mod services; pub mod settings; pub mod task; +pub mod update; pub mod workflow; /// 当前时间戳(毫秒字符串)— 与 df-storage 内部的时间格式保持一致 diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs new file mode 100644 index 0000000..3628cb1 --- /dev/null +++ b/src-tauri/src/commands/update.rs @@ -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, + /// 更新说明(无更新时 None) + pub notes: Option, + /// 发布日期(无更新时 None) + pub date: Option, + /// 当前运行版本(便于前端展示对比) + 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 { + 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 { + 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::().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, ×tamp) + .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, +} + +/// 下载并安装更新(成功即自动重启)。 +/// +/// 流程: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(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 32bfca2..8dbff6b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4114500..a29d5fc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -30,6 +30,7 @@ "bundle": { "active": true, "targets": ["nsis"], + "createUpdaterArtifacts": true, "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -37,5 +38,16 @@ "icons/icon.icns", "icons/icon.ico" ] + }, + "plugins": { + "updater": { + "endpoints": [ + "https://app.1216.top/download/devflow/latest.json" + ], + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRGODM4QzcwMDZBNEYyQTEKUldTaDhxUUdjSXlEVHdZVnRWOHQ3WDlXSllidGlHTVk0VC8rSnZQSVE0ckFJRmVNYXpyRXpBMzYK", + "windows": { + "installMode": "passive" + } + } } } diff --git a/src/App.vue b/src/App.vue index 65c9d62..0cd49d8 100644 --- a/src/App.vue +++ b/src/App.vue @@ -74,6 +74,7 @@ import ApprovalDialog from './components/project/ApprovalDialog.vue' import ErrorBoundary from './components/ErrorBoundary.vue' import AppLayout from './components/layout/AppLayout.vue' import { startApprovalPopup } from '@/composables/ai/useApprovalPopup' +import { startUpdateAutoCheck } from '@/composables/useUpdateCheck' import { aiApi } from '@/api' const route = useRoute() @@ -266,6 +267,11 @@ onMounted(async () => { console.error('[ApprovalPopup] ready 监听失败:', e) } } + // B2 应用内升级:自动检查调度(启动后 3s 首查 + 周期检查,同版本只提示一次)。 + // 幂等(模块级单例),仅主窗口启动;auto_check=false 时内部自动跳过。 + if (route.path !== '/approval-popup') { + startUpdateAutoCheck() + } }) /// 一次性迁移:遍历遗留的 localStorage key,若有值则导入 appSettings(SQLite), diff --git a/src/api/settings.ts b/src/api/settings.ts index cd39924..3f9bb40 100644 --- a/src/api/settings.ts +++ b/src/api/settings.ts @@ -21,6 +21,52 @@ export const settingsApi = { return invoke('get_data_dir') }, + /** 获取应用版本信息(编译期注入:version/build_time/git_hash) */ + getVersion(): Promise<{ version: string; build_time: string; git_hash: string }> { + return invoke<{ version: string; build_time: string; git_hash: string }>('app_get_version') + }, + + /** B2 检查更新:拉远端 latest.json,返回是否有新版(网络失败抛错,不谎报已是最新) */ + checkUpdate(): Promise<{ + has_update: boolean + version: string | null + notes: string | null + date: string | null + current_version: string + }> { + return invoke('app_check_update') + }, + + /** B2 读自动检查偏好 */ + getUpdatePrefs(): Promise<{ + auto_check: boolean + interval_minutes: number + last_check: string + notified_version: string + }> { + return invoke('app_update_get_prefs') + }, + + /** B2 写自动检查偏好 */ + setUpdatePrefs(autoCheck: boolean, intervalMinutes: number): Promise { + return invoke('app_update_set_prefs', { autoCheck, intervalMinutes }) + }, + + /** B2 记录一次检查时间戳(节流用) */ + markUpdateChecked(timestamp: string): Promise { + return invoke('app_update_mark_checked', { timestamp }) + }, + + /** B2 记录已提示版本号(同版本只提示一次) */ + markUpdateNotified(version: string): Promise { + return invoke('app_update_mark_notified', { version }) + }, + + /** B3 下载并安装更新(进度经 app-update-progress 事件流;成功即自动重启) */ + downloadAndInstallUpdate(): Promise { + return invoke('app_download_install_update') + }, + /** 取全部 key/value(启动时一次性拉回恢复偏好) */ getAll(): Promise> { return invoke>('settings_get_all') diff --git a/src/components/layout/AppLayout.vue b/src/components/layout/AppLayout.vue index a71f986..019c655 100644 --- a/src/components/layout/AppLayout.vue +++ b/src/components/layout/AppLayout.vue @@ -6,6 +6,8 @@
+ + @@ -29,6 +31,7 @@ import { useRoute, useRouter } from 'vue-router' import { useAiStore } from '@/stores/ai' import { useAppSettingsStore } from '@/stores/appSettings' import AppSidebar from './AppSidebar.vue' +import UpdateBadge from './UpdateBadge.vue' const props = defineProps<{ isDetached: boolean diff --git a/src/components/layout/UpdateBadge.vue b/src/components/layout/UpdateBadge.vue new file mode 100644 index 0000000..4981c6e --- /dev/null +++ b/src/components/layout/UpdateBadge.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/src/components/settings/AboutPanel.vue b/src/components/settings/AboutPanel.vue new file mode 100644 index 0000000..de391f5 --- /dev/null +++ b/src/components/settings/AboutPanel.vue @@ -0,0 +1,413 @@ + + + + + diff --git a/src/components/settings/SettingsNav.vue b/src/components/settings/SettingsNav.vue index 72f7e57..8614706 100644 --- a/src/components/settings/SettingsNav.vue +++ b/src/components/settings/SettingsNav.vue @@ -76,6 +76,7 @@ const categories: NavCategory[] = [ { value: 'security', icon: '🔒', labelKey: 'settings.navSecurity' }, { value: 'integrations', icon: '🔌', labelKey: 'settings.navIntegrations' }, { value: 'advanced', icon: '⚙️', labelKey: 'settings.navAdvanced' }, + { value: 'about', icon: 'ℹ️', labelKey: 'settings.navAbout' }, ] const props = defineProps<{ diff --git a/src/components/settings/searchIndex.ts b/src/components/settings/searchIndex.ts index 20c0686..5ef1896 100644 --- a/src/components/settings/searchIndex.ts +++ b/src/components/settings/searchIndex.ts @@ -20,6 +20,7 @@ export type SettingsCategory = | 'security' | 'integrations' | 'advanced' + | 'about' /** 搜索索引项:category 所属分类 + labelKey/descKey(i18n key) + key 唯一标识 */ export interface SettingIndexEntry { @@ -34,7 +35,7 @@ export interface SettingIndexEntry { } /** - * 设置项静态索引 —— 覆盖全部 6 类设置项。 + * 设置项静态索引 —— 覆盖全部 7 类设置项。 * 分类映射(对齐 plan): * appearance 外观(theme/language/aiLanguage/showTokenUsage) * ai-model AI 模型(provider 列表 / 负载均衡池 / 模型拉取) @@ -45,6 +46,7 @@ export interface SettingIndexEntry { * security 安全(allowed_dirs) * integrations 集成(飞书渠道凭证) * advanced 高级(autoExecuteMode/logLevel + 连接) + * about 关于(版本号 / 更新检查) */ export const SETTINGS_INDEX: SettingIndexEntry[] = [ // ===== 外观 ===== @@ -112,4 +114,10 @@ export const SETTINGS_INDEX: SettingIndexEntry[] = [ { category: 'advanced', key: 'approvalTimeout', labelKey: 'settings.labelApprovalTimeout', descKey: 'settings.descApprovalTimeout' }, { category: 'advanced', key: 'fileUploadBase', labelKey: 'settings.labelFileUploadBase', descKey: 'settings.descFileUploadBase' }, { category: 'advanced', key: 'connection', labelKey: 'settings.panelConnection', descKey: 'settings.emptyConnection' }, + + // ===== 关于 ===== + // 版本号 + 更新检查(B1 版本更新与自动升级链路)。key 对齐 AboutSection.vue + // 渲染行的 data-setting-key,搜索「版本/更新/升级/version」命中本类。 + { category: 'about', key: 'appVersion', labelKey: 'settings.labelAppVersion', descKey: 'settings.descAppVersion' }, + { category: 'about', key: 'checkUpdate', labelKey: 'settings.labelCheckUpdate', descKey: 'settings.descCheckUpdate' }, ] diff --git a/src/composables/useUpdateCheck.ts b/src/composables/useUpdateCheck.ts new file mode 100644 index 0000000..c957776 --- /dev/null +++ b/src/composables/useUpdateCheck.ts @@ -0,0 +1,119 @@ +// 应用内升级 — 自动检查调度(B2/L1) +// +// 设计:docs/02-架构设计/专项设计/版本更新与自动升级-设计-2026-10-09.md §四 L1 +// +// 策略(C4 定稿):启动后延迟 3s 首查 → 此后按 interval_minutes(默认 15)周期查。 +// 去重:同一版本号只提示一次(KV df-update-notified-version),「稍后」后不再重复弹。 +// 只提示不自动装(安装是高风险动作,必须人点头)。 +// +// 实现要点: +// - 单例调度:模块级 timer,防多组件重复启动。 +// - 节流判定:读 KV last_check + interval,未到间隔跳过(防每次启动都拉)。 +// - 更新可用时广播 window 事件 'app-update-available',TopBar 徽章/AboutPanel 消费。 +// - 停用:auto_check=false 时不启动。 + +import { settingsApi } from '@/api' + +/** 更新可用事件载荷 */ +export interface UpdateAvailablePayload { + version: string + notes: string | null + date: string | null +} + +let timer: ReturnType | null = null +let started = false + +/** 首查延迟(ms)—— 设计 C4:启动后 3 秒 */ +const FIRST_CHECK_DELAY_MS = 3000 + +/** + * 执行一次自动检查(含节流判定)。 + * - auto_check=false → 跳过 + * - 距上次检查不足 interval → 跳过 + * - 有新版且未提示过该版本 → 广播 app-update-available + */ +async function runAutoCheck(): Promise { + try { + const prefs = await settingsApi.getUpdatePrefs() + if (!prefs.auto_check) return + + // 节流:距上次检查不足 interval 则跳过 + if (prefs.last_check) { + const last = Date.parse(prefs.last_check) + if (!Number.isNaN(last)) { + const elapsedMin = (Date.now() - last) / 60000 + if (elapsedMin < prefs.interval_minutes) return + } + } + + const r = await settingsApi.checkUpdate() + const now = new Date().toISOString() + void settingsApi.markUpdateChecked(now) + + if (r.has_update && r.version) { + // 同版本只提示一次 + if (prefs.notified_version === r.version) return + void settingsApi.markUpdateNotified(r.version) + const payload: UpdateAvailablePayload = { + version: r.version, + notes: r.notes, + date: r.date, + } + window.dispatchEvent(new CustomEvent('app-update-available', { detail: payload })) + } + } catch { + // 网络失败静默:自动检查不打扰用户(手动检查才报错) + } +} + +/** 调度下一次检查(按 interval_minutes) */ +async function scheduleNext(): Promise { + let intervalMin = 15 + try { + const prefs = await settingsApi.getUpdatePrefs() + intervalMin = prefs.interval_minutes + } catch { + /* 用默认 15 */ + } + timer = setTimeout(async () => { + await runAutoCheck() + await scheduleNext() + }, Math.max(1, intervalMin) * 60_000) +} + +/** + * 启动自动检查调度(幂等:重复调用只启动一次)。 + * 在 App.vue onMounted 调用。 + */ +export function startUpdateAutoCheck(): void { + if (started) return + started = true + timer = setTimeout(async () => { + await runAutoCheck() + await scheduleNext() + }, FIRST_CHECK_DELAY_MS) +} + +/** 停止调度(卸载/测试用) */ +export function stopUpdateAutoCheck(): void { + if (timer) { + clearTimeout(timer) + timer = null + } + started = false +} + +/** + * 偏好变更后重置调度(用户在关于页开关自动检查/改间隔后调用)。 + * 关 → 停;开 → 立即重排。 + */ +export async function resetUpdateAutoCheck(): Promise { + stopUpdateAutoCheck() + try { + const prefs = await settingsApi.getUpdatePrefs() + if (prefs.auto_check) startUpdateAutoCheck() + } catch { + /* 静默 */ + } +} diff --git a/src/i18n/en/settings.ts b/src/i18n/en/settings.ts index e3a3603..eade352 100644 --- a/src/i18n/en/settings.ts +++ b/src/i18n/en/settings.ts @@ -11,6 +11,7 @@ export default { navSecurity: 'Security', navIntegrations: 'Integrations', navAdvanced: 'Advanced', + navAbout: 'About', // ===== Settings search (phase 4) ===== searchPlaceholder: 'Search settings…', @@ -132,6 +133,42 @@ export default { sectionAppearance: '🎨 Appearance', sectionPerformance: '⚡ Performance', sectionAdvanced: '⚙️ Advanced', + + // ===== About (B1/L0 version governance: version visibility + update entry) ===== + sectionAbout: 'ℹ️ About DevFlow', + labelAppVersion: 'Current version', + descAppVersion: 'DevFlow desktop app version (injected at compile time, same source as the installer)', + labelCheckUpdate: 'Check for updates', + descCheckUpdate: 'Fetch the latest version info from the release source; a new version prompts download & install (wired in B2)', + checkUpdateAction: 'Check for updates', + checkUpdateChecking: 'Checking…', + checkUpdateNotReady: 'Check-for-updates is coming soon', + checkUpdateFound: 'New version v{version} available', + checkUpdateLatest: 'Already up to date (v{version})', + checkUpdateFailed: 'Check failed: {error}', + labelAutoCheckUpdate: 'Auto check for updates', + descAutoCheckUpdate: 'Automatically check for new versions on startup (notify only, never auto-install)', + labelCheckInterval: 'Check interval', + descCheckInterval: 'Interval between automatic checks', + interval5m: '5 minutes', + interval15m: '15 minutes', + interval30m: '30 minutes', + interval60m: '60 minutes', + labelLastCheck: 'Last checked', + lastCheckNever: 'Never checked', + updateAvailableTitle: '🎉 New version v{version} available', + updateBadgeTitle: 'New version available, click to view', + updateBadgeText: 'New v{version}', + updateLater: 'Later', + updateNow: 'Update now', + updateInstalling: 'Updating…', + updateInstallingStage: 'Installing, the app will restart shortly…', + updateDownloading: 'Downloading {percent}% ({done} / {total} MB)', + updateDownloadingUnknown: 'Downloading…', + updateInstallFailed: 'Update failed: {error}', + updateNotReady: 'Download & install is coming soon', + labelBuildTime: 'Build time', + labelGitHash: 'Git commit', labelTheme: 'Theme', descTheme: 'Dark / light', themeDark: '🌙 Dark', @@ -267,6 +304,7 @@ export default { labelFeishuWhitelist: 'open_id whitelist', descFeishuWhitelist: 'Comma-separated open_id list; leave empty to allow all users', phFeishuWhitelist: 'ou_xxx, ou_yyy', + hintFeishuWhitelist: 'How to get an open_id: after enabling, have the member send any message to the bot; the bot replies with their open_id. Paste it above to allow them (leave empty to allow everyone).', feishuSave: 'Save', feishuSaveOk: 'Feishu integration settings saved', // Submit guard: when enabled=true, either of id/secret empty blocks the write diff --git a/src/i18n/zh-CN/settings.ts b/src/i18n/zh-CN/settings.ts index 2e25cb4..f648ecf 100644 --- a/src/i18n/zh-CN/settings.ts +++ b/src/i18n/zh-CN/settings.ts @@ -11,6 +11,7 @@ export default { navSecurity: '安全', navIntegrations: '集成', navAdvanced: '高级', + navAbout: '关于', // ===== 设置搜索(阶段4) ===== searchPlaceholder: '搜索设置项…', @@ -131,6 +132,42 @@ export default { sectionAppearance: '🎨 外观', sectionPerformance: '⚡ 性能', sectionAdvanced: '⚙️ 高级', + + // ===== 关于(B1/L0 版本治理:版本可见 + 更新入口) ===== + sectionAbout: 'ℹ️ 关于 DevFlow', + labelAppVersion: '当前版本', + descAppVersion: 'DevFlow 桌面应用版本号(编译期注入,与安装包同源)', + labelCheckUpdate: '检查更新', + descCheckUpdate: '从发布源拉取最新版本信息;有新版会提示下载安装(B2 接入)', + checkUpdateAction: '检查更新', + checkUpdateChecking: '检查中…', + checkUpdateNotReady: '检查更新功能即将上线', + checkUpdateFound: '发现新版本 v{version}', + checkUpdateLatest: '已是最新版本(v{version})', + checkUpdateFailed: '检查失败:{error}', + labelAutoCheckUpdate: '自动检查更新', + descAutoCheckUpdate: '启动后自动检查是否有新版本(仅提示,不自动安装)', + labelCheckInterval: '检查间隔', + descCheckInterval: '自动检查的时间间隔', + interval5m: '5 分钟', + interval15m: '15 分钟', + interval30m: '30 分钟', + interval60m: '60 分钟', + labelLastCheck: '上次检查', + lastCheckNever: '从未检查', + updateAvailableTitle: '🎉 发现新版本 v{version}', + updateBadgeTitle: '发现新版本,点击查看', + updateBadgeText: '新版本 v{version}', + updateLater: '稍后', + updateNow: '立即升级', + updateInstalling: '升级中…', + updateInstallingStage: '正在安装,应用即将重启…', + updateDownloading: '下载中 {percent}%({done} / {total} MB)', + updateDownloadingUnknown: '下载中…', + updateInstallFailed: '升级失败:{error}', + updateNotReady: '下载安装功能即将上线', + labelBuildTime: '构建时间', + labelGitHash: 'Git 提交', labelTheme: '主题', descTheme: '深色 / 浅色模式', themeDark: '🌙 深色模式', @@ -266,6 +303,7 @@ export default { labelFeishuWhitelist: 'open_id 白名单', descFeishuWhitelist: '逗号分隔的 open_id,留空表示放行所有用户', phFeishuWhitelist: 'ou_xxx, ou_yyy', + hintFeishuWhitelist: '如何获取 open_id:启用集成后,让成员向机器人发任意消息,机器人会回复其 open_id;复制填入上方即可(留空则放行所有人)。', feishuSave: '保存', feishuSaveOk: '飞书集成配置已保存', // 提交校验:enabled=true 时 id/secret 任一为空都拦在写库前 diff --git a/src/stores/index.ts b/src/stores/index.ts index 94713e6..309e823 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -1,8 +1,4 @@ export { useProjectStore } from './project' - export { useKnowledgeStore } from './knowledge' - - export { useAppSettingsStore } from './appSettings' - export { useAiStore } from './ai' diff --git a/src/views/Settings.vue b/src/views/Settings.vue index bbc5f8b..0df8b15 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -87,6 +87,9 @@ @toast="showToast" /> + + +
@@ -110,6 +113,7 @@ import AllowedDirsPanel from '@/components/settings/AllowedDirsPanel.vue' import FeishuPanel from '@/components/settings/FeishuPanel.vue' import UclawPanel from '@/components/settings/UclawPanel.vue' import UnattendedPanel from '@/components/settings/UnattendedPanel.vue' +import AboutPanel from '@/components/settings/AboutPanel.vue' // SettingsImportExport 已移除(价值低,占用 UI 空间;跨设备同步建议直接复制 SQLite) import { useAppSettingsStore } from '@/stores/appSettings' import { aiApi } from '@/api' diff --git a/swap-install.ps1 b/swap-install.ps1 index 04c9d1c..e7c7b6b 100644 --- a/swap-install.ps1 +++ b/swap-install.ps1 @@ -1,7 +1,18 @@ # DevFlow 自宿主换装脚本 v3 # 流程: 等旧进程退出并探测句柄释放, 静默安装, 按构建产物哈希校验且失败重试一次, 交互用户可见重启, 结束自清理 $ErrorActionPreference = 'Stop' -$installer = 'E:\wk-lab\devflow\target\release\bundle\nsis\devflow_0.1.0_x64-setup.exe' +# 安装包路径:按构建产物 glob 探测(不硬编码版本号,版本升级时脚本零改动)。 +# C3 决议:脚本消费构建产物,版本号唯一源头是 Cargo.toml → tauri.conf.json 注入文件名。 +$nsisDir = 'E:\wk-lab\devflow\target\release\bundle\nsis' +$installer = $null +if (Test-Path $nsisDir) { + $installer = Get-ChildItem -Path $nsisDir -Filter 'devflow_*_x64-setup.exe' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName +} +if (-not $installer) { + # 兜底:目录缺失或无匹配产物时,保留旧硬编码路径以维持既有失败提示语义 + $installer = 'E:\wk-lab\devflow\target\release\bundle\nsis\devflow_0.1.1_x64-setup.exe' +} $target = 'C:\Users\23780\AppData\Local\devflow\devflow.exe' $builtExe = 'E:\wk-lab\devflow\target\release\devflow.exe' $log = 'E:\wk-lab\devflow\swap-install.log'