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

后端 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
Generated
+233 -6
View File
@@ -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"
+6 -3
View File
@@ -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
+7 -3
View File
@@ -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!("人工审批被取消"));
}
+18
View File
@@ -30,6 +30,10 @@ use df_types::types::{NodeId, NodeStatus};
pub struct StateMachine {
/// 节点状态映射(Arc 共享,clone 浅拷贝同一 HashMap)
states: Arc<Mutex<HashMap<NodeId, NodeStatus>>>,
/// 取消信号唤醒器(FR-P4):set_cancelled 时 notify_waiters,
/// 阻塞节点(HumanNode)用 notified() 被动等待,替代 500ms 轮询。
/// 与 states 同 Arc 共享,clone 浅拷贝同一 Notify。
cancel_notify: Arc<tokio::sync::Notify>,
}
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 返回,调用方持独立副本)
@@ -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": "<tauri signer generate 产出的公钥>",
"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=<timestamp>` 兜底 |
| 更新包下载中断 | 中 | 插件内建重试;失败保留旧版不破坏现状 |
| 装完起不来(坏包) | 中 | `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_<ver>_x64-setup.exe` | 手动下载的用户 | ✅ 站点「裸 exe」规范 |
| `devflow_<ver>_x64-setup.nsis.zip` | updater 拉取 | ✅ Tauri 格式强制 |
| `devflow_<ver>_x64-setup.nsis.zip.sig` | updater 验签 | ✅ |
| `latest.json` | updater 清单 | ✅ |
### C4 补充决策:体验设计「环境式更新」
**核心原则**:检查频率 ≠ 体验。体验由「提醒方式 / 去重 / 过程可见 / 时机可控」四件事决定。
| 触点 | ✅ 定稿 |
|---|---|
| 平时 | 完全安静,零存在感 |
| 发现新版 | **顶栏非模态徽章**(不弹窗打断) |
| 查看详情 | 点徽章展开:版本号 + 更新说明 + 包大小 |
| 决定 | 「立即升级」/「稍后」双选(**不自动下载安装**) |
| 下载 | 进度条 + 可取消 |
| 安装 | `passive` 进度窗口,无需点击 |
| 完成 | 重启后 toast「已升级到 vX.Y.Z」 |
| 反复 | **同版本只提示一次**(KV `df-update-notified-version`) |
**为什么必须是「环境式」而非「隐形型(全自动)」**:DevFlow 是**有状态工作工具**(可能正跑长任务/开着会话),任何自动重启都会打断工作。时机控制权必须交给用户。
@@ -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<Mutex<Connection>>`,全仓数十处调用点按此签名取锁,改连接池需重构全部调用点 + 迁移逻辑(`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<tokio::sync::Notify>` 字段(与 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(重构项)
+121
View File
@@ -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)"
+43
View File
@@ -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 "=== 换装结束 ==="
+3 -1
View File
@@ -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
+2 -1
View File
@@ -24,6 +24,7 @@
"core:webview:allow-create-webview-window",
"dialog:default",
"window-state:default",
"notification:default"
"notification:default",
"updater:default"
]
}
+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,
+12
View File
@@ -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"
}
}
}
}
+6
View File
@@ -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),
+46
View File
@@ -21,6 +21,52 @@ export const settingsApi = {
return invoke<string>('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<void> {
return invoke('app_update_set_prefs', { autoCheck, intervalMinutes })
},
/** B2 记录一次检查时间戳(节流用) */
markUpdateChecked(timestamp: string): Promise<void> {
return invoke('app_update_mark_checked', { timestamp })
},
/** B2 记录已提示版本号(同版本只提示一次) */
markUpdateNotified(version: string): Promise<void> {
return invoke('app_update_mark_notified', { version })
},
/** B3 下载并安装更新(进度经 app-update-progress 事件流;成功即自动重启) */
downloadAndInstallUpdate(): Promise<void> {
return invoke('app_download_install_update')
},
/** 取全部 key/value(启动时一次性拉回恢复偏好) */
getAll(): Promise<Record<string, string>> {
return invoke<Record<string, string>>('settings_get_all')
+3
View File
@@ -6,6 +6,8 @@
<!-- 主窗口:完整布局 -->
<div v-else class="df-shell">
<AppSidebar />
<!-- 全局更新徽章(环境式更新,任意页面可见) -->
<UpdateBadge />
<!-- ═══ Main (页面内容) ═══ -->
<!-- 仅在 AI 面板可见且最大化时隐藏主内容;面板关闭时无论 maximized 状态如何都显示 -->
@@ -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
+255
View File
@@ -0,0 +1,255 @@
<template>
<!-- ═══ 全局更新徽章(环境式更新,设计 §八 C4)═══
发现新版 → 右上角非模态徽章(不弹窗打断);点击展开版本详情 + 升级/稍后。
全局挂载(AppLayout),任意页面可见。 -->
<Transition name="update-badge">
<div v-if="visible" class="update-badge-root">
<button
type="button"
class="update-badge-btn"
:class="{ 'is-active': expanded }"
:title="$t('settings.updateBadgeTitle')"
@click="expanded = !expanded"
>
<span class="update-badge-dot"></span>
<span class="update-badge-text">{{ $t('settings.updateBadgeText', { version: info?.version }) }}</span>
</button>
<Transition name="update-pop">
<div v-if="expanded" class="update-pop">
<div class="update-pop-head">{{ $t('settings.updateAvailableTitle', { version: info?.version }) }}</div>
<div v-if="info?.notes" class="update-pop-notes">{{ info.notes }}</div>
<!-- 下载进度 -->
<div v-if="progress" class="update-pop-progress">
<div class="update-pop-bar">
<div class="update-pop-fill" :style="{ width: progressPercent + '%' }"></div>
</div>
<div class="update-pop-progress-text">{{ progressText }}</div>
</div>
<div v-if="installError" class="update-pop-error">{{ installError }}</div>
<div class="update-pop-actions">
<button class="btn btn-ghost btn-sm" :disabled="installing" @click="onLater">
{{ $t('settings.updateLater') }}
</button>
<button class="btn btn-primary btn-sm" :disabled="installing" @click="onInstall">
{{ installing ? $t('settings.updateInstalling') : $t('settings.updateNow') }}
</button>
</div>
</div>
</Transition>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { settingsApi } from '@/api'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import type { UpdateAvailablePayload } from '@/composables/useUpdateCheck'
// ============================================================
// 全局更新徽章 — 消费 useUpdateCheck 广播的 app-update-available 事件。
// 环境式:平时零存在感;有新版才显徽章;点击展开详情;不自动安装。
// ============================================================
const { t } = useI18n()
const visible = ref(false)
const expanded = ref(false)
const info = ref<UpdateAvailablePayload | null>(null)
const installing = ref(false)
const progress = ref<{ stage: string; downloaded: number; total: number | null } | null>(null)
const installError = ref('')
let unlistenAvailable: UnlistenFn | null = null
let unlistenProgress: UnlistenFn | null = null
onMounted(async () => {
try {
unlistenAvailable = await listen<UpdateAvailablePayload>('app-update-available', (e) => {
info.value = e.payload
visible.value = true
})
} catch {
/* 静默 */
}
try {
unlistenProgress = await listen<{ stage: string; downloaded: number; total: number | null }>(
'app-update-progress',
(e) => {
progress.value = e.payload
},
)
} catch {
/* 静默 */
}
})
onUnmounted(() => {
unlistenAvailable?.()
unlistenProgress?.()
unlistenAvailable = null
unlistenProgress = null
})
const progressPercent = computed(() => {
const p = progress.value
if (!p || p.stage !== 'downloading' || !p.total || p.total === 0) return 0
return Math.min(100, Math.round((p.downloaded / p.total) * 100))
})
const progressText = computed(() => {
const p = progress.value
if (!p) return ''
if (p.stage === 'installing') return t('settings.updateInstallingStage')
if (!p.total) return t('settings.updateDownloadingUnknown')
const mb = (n: number) => (n / 1048576).toFixed(1)
return t('settings.updateDownloading', {
percent: progressPercent.value,
done: mb(p.downloaded),
total: mb(p.total),
})
})
function onLater() {
visible.value = false
expanded.value = false
}
async function onInstall() {
if (installing.value) return
installing.value = true
installError.value = ''
progress.value = null
try {
await settingsApi.downloadAndInstallUpdate()
} catch (e) {
installError.value = t('settings.updateInstallFailed', { error: String(e) })
installing.value = false
progress.value = null
}
}
</script>
<style scoped>
.update-badge-root {
position: fixed;
top: 12px;
right: 16px;
z-index: 2000;
}
.update-badge-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: 0.5px solid var(--df-accent);
border-radius: 999px;
background: var(--df-bg-card);
color: var(--df-accent);
font-size: 12px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: filter 0.15s;
}
.update-badge-btn:hover {
filter: brightness(1.1);
}
.update-badge-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--df-accent);
animation: update-pulse 1.6s ease-in-out infinite;
}
@keyframes update-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.update-pop {
position: absolute;
top: 36px;
right: 0;
width: 320px;
padding: 14px 16px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
background: var(--df-bg-card);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.update-pop-head {
font-size: 13px;
font-weight: 600;
color: var(--df-accent);
margin-bottom: 8px;
}
.update-pop-notes {
font-size: 12px;
color: var(--df-text-dim);
line-height: 1.5;
white-space: pre-wrap;
max-height: 160px;
overflow-y: auto;
margin-bottom: 12px;
}
.update-pop-progress {
margin-bottom: 12px;
}
.update-pop-bar {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--df-bg-hover, rgba(128, 128, 128, 0.2));
overflow: hidden;
}
.update-pop-fill {
height: 100%;
background: var(--df-accent);
transition: width 0.2s ease;
}
.update-pop-progress-text {
margin-top: 6px;
font-size: 12px;
font-family: var(--df-font-mono, monospace);
color: var(--df-text-dim);
}
.update-pop-error {
margin-bottom: 12px;
padding: 8px 10px;
border-radius: var(--df-radius-md, 6px);
background: rgba(220, 53, 69, 0.12);
font-size: 12px;
color: var(--df-danger, #dc3545);
line-height: 1.5;
word-break: break-all;
}
.update-pop-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.update-pop-actions .btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.update-badge-enter-active,
.update-badge-leave-active {
transition: opacity 0.25s, transform 0.25s;
}
.update-badge-enter-from,
.update-badge-leave-to {
opacity: 0;
transform: translateY(-8px);
}
.update-pop-enter-active,
.update-pop-leave-active {
transition: opacity 0.15s;
}
.update-pop-enter-from,
.update-pop-leave-to {
opacity: 0;
}
</style>
+413
View File
@@ -0,0 +1,413 @@
<template>
<!-- ═══ 关于 DevFlow(版本可见 + 更新入口)═══
B1(L0):版本号/构建时间/git hash 展示。
B2(L1):检查更新接入 + 自动检查开关/间隔 + 更新可用提示。
下载安装链路在 B3(此处「立即升级」按钮先禁用,提示即将上线)。 -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('settings.sectionAbout') }}</h2>
</div>
<div class="general-settings">
<!-- ═══ 当前版本(编译期注入,单一源头) ═══ -->
<div class="setting-row inline-control-row" data-setting-key="appVersion">
<div class="inline-head">
<span class="setting-label">{{ $t('settings.labelAppVersion') }}</span>
<span class="about-version" :title="versionTitle">{{ versionText }}</span>
</div>
<span class="setting-desc">{{ $t('settings.descAppVersion') }}</span>
</div>
<!-- ═══ 检查更新 ═══ -->
<div class="setting-row inline-control-row" data-setting-key="checkUpdate">
<div class="inline-head">
<span class="setting-label">{{ $t('settings.labelCheckUpdate') }}</span>
<button
class="btn btn-ghost btn-sm about-check-btn"
:disabled="checking || !versionLoaded"
@click="onCheckUpdate"
>
{{ checking ? $t('settings.checkUpdateChecking') : $t('settings.checkUpdateAction') }}
</button>
</div>
<span class="setting-desc">{{ checkResultText }}</span>
</div>
<!-- ═══ 自动检查开关 ═══ -->
<div class="setting-row inline-control-row">
<div class="inline-head">
<span class="setting-label">{{ $t('settings.labelAutoCheckUpdate') }}</span>
<input
v-model="autoCheck"
type="checkbox"
class="setting-checkbox"
@change="onAutoCheckChange"
/>
</div>
<span class="setting-desc">{{ $t('settings.descAutoCheckUpdate') }}</span>
</div>
<!-- ═══ 检查间隔(自动检查开启时可用) ═══ -->
<div class="setting-row inline-control-row">
<div class="inline-head">
<span class="setting-label">{{ $t('settings.labelCheckInterval') }}</span>
<select
v-model.number="intervalMinutes"
class="setting-select"
:disabled="!autoCheck"
@change="onIntervalChange"
>
<option :value="5">{{ $t('settings.interval5m') }}</option>
<option :value="15">{{ $t('settings.interval15m') }}</option>
<option :value="30">{{ $t('settings.interval30m') }}</option>
<option :value="60">{{ $t('settings.interval60m') }}</option>
</select>
</div>
<span class="setting-desc">{{ $t('settings.descCheckInterval') }}</span>
</div>
<!-- ═══ 上次检查时间 ═══ -->
<div class="setting-row inline-control-row">
<div class="inline-head">
<span class="setting-label">{{ $t('settings.labelLastCheck') }}</span>
<span class="about-last-check">{{ lastCheckText }}</span>
</div>
</div>
</div>
<!-- ═══ 更新可用提示卡(检测到新版时显示) ═══ -->
<Transition name="update-card">
<div v-if="updateInfo" class="update-card">
<div class="update-card-head">
<span class="update-card-title">{{ $t('settings.updateAvailableTitle', { version: updateInfo.version }) }}</span>
</div>
<div v-if="updateInfo.notes" class="update-card-notes">{{ updateInfo.notes }}</div>
<!-- 下载进度(下载中/安装中显示) -->
<div v-if="progress" class="update-progress">
<div class="update-progress-bar">
<div class="update-progress-fill" :style="{ width: progressPercent + '%' }"></div>
</div>
<div class="update-progress-text">{{ progressText }}</div>
</div>
<!-- 错误提示 -->
<div v-if="installError" class="update-error">{{ installError }}</div>
<div class="update-card-actions">
<button
class="btn btn-ghost btn-sm"
:disabled="installing"
@click="onLater"
>
{{ $t('settings.updateLater') }}
</button>
<button
class="btn btn-primary btn-sm"
:disabled="installing"
@click="onInstall"
>
{{ installing ? $t('settings.updateInstalling') : $t('settings.updateNow') }}
</button>
</div>
</div>
</Transition>
</section>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { settingsApi } from '@/api'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
// ============================================================
// 关于域 — 版本可见 + 检查更新 + 下载安装(B1/L0 + B2/L1 + B3/L2)
// 版本信息经 app_get_version IPC 取回(编译期注入)。
// 检查更新经 app_check_update IPC(拉远端 latest.json,内建签名校验)。
// 自动检查偏好经 app_update_get_prefs / set_prefs 读写(复用 app_settings KV)。
// 下载安装经 app_download_install_update IPC;进度经 app-update-progress 事件流。
// ============================================================
const { t } = useI18n()
interface AppVersionInfo {
version: string
build_time: string
git_hash: string
}
interface UpdateInfo {
version: string
notes: string | null
date: string | null
}
interface UpdateProgressPayload {
stage: 'downloading' | 'installing'
downloaded: number
total: number | null
}
const versionInfo = ref<AppVersionInfo | null>(null)
const versionLoaded = ref(false)
const checking = ref(false)
// 检查结果描述(成功/失败/无更新)
const checkResultText = ref('')
// 更新可用信息(检测到新版时非空 → 显示提示卡)
const updateInfo = ref<UpdateInfo | null>(null)
// 自动检查偏好
const autoCheck = ref(true)
const intervalMinutes = ref(15)
const lastCheck = ref('')
// B3 下载安装状态
const installing = ref(false)
const progress = ref<UpdateProgressPayload | null>(null)
const installError = ref('')
let unlistenProgress: UnlistenFn | null = null
onMounted(async () => {
// 版本信息
try {
versionInfo.value = await settingsApi.getVersion()
} catch {
/* 静默:取版本失败不阻断关于页渲染 */
} finally {
versionLoaded.value = true
}
// 自动检查偏好
try {
const prefs = await settingsApi.getUpdatePrefs()
autoCheck.value = prefs.auto_check
intervalMinutes.value = prefs.interval_minutes
lastCheck.value = prefs.last_check
} catch {
/* 静默:偏好读取失败用默认值 */
}
// 下载进度事件订阅
try {
unlistenProgress = await listen<UpdateProgressPayload>('app-update-progress', (e) => {
progress.value = e.payload
})
} catch {
/* 静默:订阅失败不影响手动检查 */
}
})
onUnmounted(() => {
unlistenProgress?.()
unlistenProgress = null
})
// 主展示:vX.Y.Z;git_hash 非 dev 时附短 hash
const versionText = computed(() => {
const v = versionInfo.value
if (!v) return t('settings.loading')
const hash = v.git_hash && v.git_hash !== 'dev' ? ` (${v.git_hash.slice(0, 7)})` : ''
return `v${v.version}${hash}`
})
const versionTitle = computed(() => {
const v = versionInfo.value
if (!v) return ''
return `${t('settings.labelBuildTime')}: ${v.build_time}\n${t('settings.labelGitHash')}: ${v.git_hash}`
})
const lastCheckText = computed(() => lastCheck.value || t('settings.lastCheckNever'))
// 检查更新:调 app_check_update,成功则更新结果文案 + 命中新版显示提示卡并记录已提示版本
async function onCheckUpdate() {
if (checking.value) return
checking.value = true
checkResultText.value = t('settings.checkUpdateChecking')
try {
const r = await settingsApi.checkUpdate()
const now = new Date().toISOString()
lastCheck.value = now
void settingsApi.markUpdateChecked(now)
if (r.has_update && r.version) {
checkResultText.value = t('settings.checkUpdateFound', { version: r.version })
updateInfo.value = { version: r.version, notes: r.notes, date: r.date }
void settingsApi.markUpdateNotified(r.version)
} else {
checkResultText.value = t('settings.checkUpdateLatest', { version: r.current_version })
}
} catch (e) {
// 网络/解析失败:明确报错,不谎报「已是最新」
checkResultText.value = t('settings.checkUpdateFailed', { error: String(e) })
} finally {
checking.value = false
}
}
// 「稍后」:关闭提示卡(同版本已记 notified,不再自动弹)
function onLater() {
updateInfo.value = null
}
// 进度百分比(下载阶段;总大小未知时按 0 处理)
const progressPercent = computed(() => {
const p = progress.value
if (!p || p.stage !== 'downloading' || !p.total || p.total === 0) return 0
return Math.min(100, Math.round((p.downloaded / p.total) * 100))
})
// 进度文案:下载中(x% / MB) / 安装中
const progressText = computed(() => {
const p = progress.value
if (!p) return ''
if (p.stage === 'installing') return t('settings.updateInstallingStage')
if (!p.total) return t('settings.updateDownloadingUnknown')
const mb = (n: number) => (n / 1048576).toFixed(1)
return t('settings.updateDownloading', {
percent: progressPercent.value,
done: mb(p.downloaded),
total: mb(p.total),
})
})
// 「立即升级」:调 app_download_install_update,进度经事件流更新;
// 成功即自动重启(本函数不再返回后处理);失败展示错误 + 可重试。
async function onInstall() {
if (installing.value) return
installing.value = true
installError.value = ''
progress.value = null
try {
await settingsApi.downloadAndInstallUpdate()
// 成功路径:Tauri 自动重启,通常不会执行到这里
} catch (e) {
installError.value = t('settings.updateInstallFailed', { error: String(e) })
installing.value = false
progress.value = null
}
}
async function onAutoCheckChange() {
try {
await settingsApi.setUpdatePrefs(autoCheck.value, intervalMinutes.value)
} catch {
/* 静默:写失败保留内存态 */
}
}
async function onIntervalChange() {
try {
await settingsApi.setUpdatePrefs(autoCheck.value, intervalMinutes.value)
} catch {
/* 静默:写失败保留内存态 */
}
}
</script>
<style scoped>
/* 复用 settings.css 全局 .panel/.panel-header/.general-settings/.setting-row 等。
本组件仅保留版本号显示 + 检查更新按钮 + 更新提示卡的特有样式。 */
.about-version {
flex: 0 0 auto;
margin-left: auto;
font-size: 13px;
font-family: var(--df-font-mono, monospace);
color: var(--df-text);
white-space: nowrap;
}
.about-last-check {
flex: 0 0 auto;
margin-left: auto;
font-size: 12px;
font-family: var(--df-font-mono, monospace);
color: var(--df-text-dim);
white-space: nowrap;
}
.about-check-btn {
flex: 0 0 auto;
margin-left: auto;
}
.about-check-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.setting-checkbox {
flex: 0 0 auto;
margin-left: auto;
width: 16px;
height: 16px;
cursor: pointer;
}
/* 更新可用提示卡 */
.update-card {
margin: 12px 0 0;
padding: 14px 16px;
border: 0.5px solid var(--df-accent);
border-radius: var(--df-radius-lg);
background: var(--df-bg-card);
}
.update-card-head {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.update-card-title {
font-size: 13px;
font-weight: 600;
color: var(--df-accent);
}
.update-card-notes {
font-size: 12px;
color: var(--df-text-dim);
line-height: 1.5;
white-space: pre-wrap;
max-height: 160px;
overflow-y: auto;
margin-bottom: 12px;
}
.update-card-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.update-card-actions .btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 下载进度条 */
.update-progress {
margin-bottom: 12px;
}
.update-progress-bar {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--df-bg-hover, rgba(128, 128, 128, 0.2));
overflow: hidden;
}
.update-progress-fill {
height: 100%;
background: var(--df-accent);
transition: width 0.2s ease;
}
.update-progress-text {
margin-top: 6px;
font-size: 12px;
font-family: var(--df-font-mono, monospace);
color: var(--df-text-dim);
}
/* 安装错误提示 */
.update-error {
margin-bottom: 12px;
padding: 8px 10px;
border-radius: var(--df-radius-md, 6px);
background: rgba(220, 53, 69, 0.12);
font-size: 12px;
color: var(--df-danger, #dc3545);
line-height: 1.5;
word-break: break-all;
}
.update-card-enter-active,
.update-card-leave-active {
transition: opacity 0.2s;
}
.update-card-enter-from,
.update-card-leave-to {
opacity: 0;
}
</style>
+1
View File
@@ -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<{
+9 -1
View File
@@ -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' },
]
+119
View File
@@ -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<typeof setTimeout> | 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<void> {
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<void> {
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<void> {
stopUpdateAutoCheck()
try {
const prefs = await settingsApi.getUpdatePrefs()
if (prefs.auto_check) startUpdateAutoCheck()
} catch {
/* 静默 */
}
}
+38
View File
@@ -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
+38
View File
@@ -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 任一为空都拦在写库前
-4
View File
@@ -1,8 +1,4 @@
export { useProjectStore } from './project'
export { useKnowledgeStore } from './knowledge'
export { useAppSettingsStore } from './appSettings'
export { useAiStore } from './ai'
+4
View File
@@ -87,6 +87,9 @@
@toast="showToast"
/>
</template>
<!-- 关于:版本号展示 + 更新检查入口(B1/L0 版本治理) -->
<AboutPanel v-else-if="activeCategory === 'about'" />
</div>
</div>
</div>
@@ -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'
+12 -1
View File
@@ -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'