Files
cmbot/docs/10-lan-update.md
T
adminandClaude Opus 4.8 0392e1c8a2 feat: LAN update check on startup with notify banner (stage 2)
Read manifest.json from the configured update_source and show a dismissable
banner when a newer version is available. Notify-only — no install yet.

- services/update_service.py: version compare + check_for_update (pure, tested)
- config: add update_source key (empty = no check)
- main_window: top banner, background-thread check; open folder via os.startfile
  (QDesktopServices.openUrl mishandles file:// folder URLs — ShellExecute err 2)
- tests: +15 covering version compare and check_for_update branches
- docs 02/05/10 + tasks 17.16

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:05:46 +08:00

253 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 局域网更新设计
## 1. 文档定位
本文档定义工具在**局域网内自动更新**的设计。它是 `docs/09-packaging-release.md` 第 13 节列出的「暂不做」能力的后续扩展,按该文档第 262 行要求单独成文,不混入第一阶段打包规则。
适用前提:
- 多台 Windows 电脑在同一局域网内使用同一工具(见 `docs/01-product-vision.md`、`docs/02-prd.md`)。
- 主程序保持本地可运行,**不依赖公网服务**,更新源也在内网。
- 打包采用 `onedir` 目录模式(见 `docs/09` 第 3 节)。目录模式是本方案增量替换、版本并排的前提;不使用 `onefile`。
本文档定义设计,不约束最终实现语言;启动器可用 Python、PowerShell 或编译型语言实现。
## 2. 目标与非目标
### 2.1 目标
- 客户端启动时自动检查内网更新源是否有新版本。
- 有新版本时自动下载并安装,用户无需手动拷贝。
- 安装过程不破坏正在运行的程序,不丢失用户数据。
- 安装失败或更新源不可达时,能降级到本地当前版本继续使用。
- 支持回滚到上一可用版本。
- 支持「强制更新」:关键修复发布后阻止旧版本继续启动。
### 2.2 非目标
- 不做公网更新、不依赖外部更新服务。
- 不做静默后台下载(更新只在启动时进行,理由见第 6 节)。
- 不做增量二进制差分(patch)。本阶段以「整版并排 + 文件级增量拷贝」为准。
- 不做安装程序(installer)、注册表写入、开机自启动。
- 不做按电脑名/用户的差异化版本策略(可作为后续扩展,见第 13 节)。
## 3. 总体架构
核心约束:**运行中的 .exe 无法被覆盖。** 因此「替换」必须发生在主程序未运行时,由一个独立的**启动器(Launcher)**在主程序启动前完成。
采用「**版本并排目录 + 启动器 + 当前版本指针**」方案:新版本整份安装到旧版本旁边,校验完整后再切换指针,最后启动当前版本。**绝不覆盖正在使用的版本目录。**
优点:
- 永不与文件锁冲突——从不写入正在运行的目录。
- 切换原子——只改写一个指针文件,状态非新即旧,不存在「装一半」。
- 回滚廉价——把指针改回旧版本目录即可。
- 与 `onedir` 天然契合,支持文件级增量拷贝。
## 4. 安装目录结构
安装到**当前用户始终可写的位置**(见第 9 节):`%LOCALAPPDATA%\CMBot`
(即 `C:\Users\<用户>\AppData\Local\CMBot`)。**不安装到 `C:\` 盘根或 `C:\Program Files`**——
两者标准用户默认不可写,会导致自动更新与数据写入失败(见第 9 节)。
```text
%LOCALAPPDATA%\CMBot\ # C:\Users\<用户>\AppData\Local\CMBot
Launcher.exe # 用户双击入口,逻辑稳定、极少变更
current.txt # 当前版本指针,内容为单行版本号,如 1.1.0
versions\
1.0.0\ # 历史版本,保留以便回滚
CMBot.exe
_internal\
resources\
1.1.0\ # 当前版本
CMBot.exe
_internal\
resources\
data\ # 用户数据,独立于版本,更新时不动
config\
app_config.json # 用户偏好(输出格式、最近文件夹、最近模板等)
templates.json # 用户自定义模板
logs\
output\
staging\ # 下载临时区,安装成功后清理
```
说明:
- `Launcher.exe`:版本检查、下载、校验、切换、启动主程序。逻辑稳定,本身不参与自更新(见第 11 节)。
- `current.txt`:纯文本,仅存当前应启动的版本号。
- `versions\<x.y.z>\`:每个版本一份完整 `onedir` 产物。
- `data\`:所有用户可写数据集中存放,**不随版本切换变动**。
- `staging\`:下载与校验的临时目录,校验通过后再并入 `versions\`。
- 整个安装根(含 `versions\`、`data\`、`Launcher.exe`)都必须免提权可写:自动更新需要写入 `versions\` 与 `current.txt`,故安装根不能落在 `C:\` 盘根或 `C:\Program Files`。
## 5. 数据目录分离(实现前置改造)
当前实现中,配置、模板、日志、输出都位于程序目录下(`get_app_dir()/config`、`/logs`、`/output`)。版本并排方案要求**程序目录与数据目录分离**,否则每次切换版本都会丢失用户自定义模板与偏好。
这是落地任何自动更新方案的**共同地基**,已实现:
- 引入「程序根目录」与「数据根目录」两个概念:
- 程序根(`get_app_dir()`):当前运行版本目录 `versions\<current>\`(只读,更新时整体替换)。
- 数据根(`get_data_dir()`):`data\`(可写,跨版本稳定)。
- 路径函数划分:
- `get_resource_path()` 指向**程序根**下的 `resources/`。
- `get_config_path()`、`get_log_dir()`、`get_output_dir()` 指向**数据根**下的对应目录。
- 数据根解析规则(`get_data_dir()`):
1. 环境变量 `CMBOT_DATA_DIR` 非空时使用它。启动器在版本并排布局中将其指向安装根的 `data\` 文件夹。
2. 否则回退到 `get_app_dir()`——即开发环境(项目根)与当前扁平 `onedir` 发布(数据与程序同级)的现状布局。
- 该设计使开发、当前发布、未来版本并排三种布局共用同一套路径规则(呼应 `docs/09` 第 7 节),且对现状**零行为变更**:未设环境变量时,config/logs/output 仍在程序目录旁。
- 兼容旧布局:写入配置/模板/日志/输出前按需创建多级目录(`parents=True`);数据根不存在时按 `docs/09` 第 6 节规则首次创建并写入默认配置/模板。
- 用户数据文件均位于数据根 `config/` 下:`config/app_config.json`、`config/templates.json`。
数据目录分离后续需同步更新 `docs/05-project-architecture.md` 与 `docs/09` 第 5、6 节的目录说明。
## 6. 更新时机
仅在**启动时**由启动器检查并安装,不做运行期后台更新。
理由:
- 桌面工具天然会重启,启动时更新覆盖绝大多数场景。
- 避免运行期替换带来的文件锁与状态一致性问题。
- 主程序无需内置更新逻辑,职责更清晰(呼应 `docs/04` 单一职责)。
更新检查必须**非阻塞要害路径**:更新源不可达时跳过更新,直接启动本地当前版本。
## 7. 更新源与版本清单
更新源路径由 `app_config.json` 的 `update_source` 配置(见 `docs/02-prd.md`);为空时不做更新检查。更新源为内网共享目录(UNC 路径)或内网文件服务,例如:
```text
\\nas\cmbot\releases\
manifest.json # 最新版本清单
CMBot-1.1.0\ # 与 release 目录结构一致的完整版本
CMBot-1.0.0\
```
`manifest.json` 字段:
```json
{
"version": "1.1.0",
"source": "\\\\nas\\cmbot\\releases\\CMBot-1.1.0",
"mandatory": false,
"min_supported": "1.0.0",
"notes": "修复批量导出格式问题",
"files": 142,
"marker": "CMBot.exe"
}
```
- `version`:最新版本号,遵循 `docs/09` 第 9 节版本规则,与 `src/version.py` 的 `APP_VERSION` 一致。
- `source`:该版本完整产物所在的内网路径。
- `mandatory`:是否强制更新(见第 10 节)。
- `min_supported`:低于此版本必须更新后才能启动。
- `notes`:更新说明,可在提示中展示。
- `files` / `marker`:完整性校验依据(见第 8 节)。
版本比较按语义化版本(major.minor.patch)数值比较,不做字符串比较。
## 8. 更新流程
启动器每次启动执行:
1. **读取本地版本**:读 `current.txt`;不存在则视为无本地版本。
2. **读取远端清单**:读更新源 `manifest.json`。
- 读取失败(路径不可达、文件缺失、格式错误)→ 记录日志,**跳过更新**,直接进入第 7 步启动本地当前版本。
3. **版本比较**:远端 `version` ≤ 本地版本 → 无需更新,进入第 7 步。
4. **下载到 staging**:将远端 `source` 目录完整拷贝到本地 `staging\<version>.tmp\`。
- 优先文件级增量(仅拷变动文件),减少内网带宽与耗时。
- 拷贝期间展示简易进度/启动画面(见第 12 节)。
5. **完整性校验**:校验 `staging\<version>.tmp\` 是否完整——至少校验 `marker` 文件存在、文件数与 `files` 一致(后续可升级为清单哈希校验)。
- 校验失败 → 删除该临时目录,记录日志,降级启动本地当前版本(若有)。
6. **原子切换**:
1. 将 `staging\<version>.tmp\` 重命名为 `versions\<version>\`。
2. 将 `current.txt` 写为该版本号(先写临时文件再替换,保证写入原子)。
7. **启动主程序**:启动 `versions\<current.txt>\CMBot.exe`。
8. **清理**:成功切换后清理 `staging\`;按保留策略清理过旧版本(见第 13 节)。
任一步失败都不得使系统进入「无可启动版本」状态:只要本地存在一个可用版本,就以它启动。
## 9. 安装位置与权限
自动更新要求**整个安装根免提权可写**:不仅写用户数据,还要写 `versions\`(新版本)与 `current.txt`(切换指针)。据此选择安装位置。
- **默认安装到 `%LOCALAPPDATA%\CMBot`**(`C:\Users\<用户>\AppData\Local\CMBot`)。
- 当前用户对该目录始终可写,**无需管理员权限、不弹 UAC**,是自更新桌面应用(如 Chrome、VS Code、Slack)的标准做法。
- 这是**每用户安装**:每个登录用户各一份程序与数据,互不影响。
- **禁止安装到以下位置:**
- `C:\Program Files` / `C:\Program Files (x86)`:写入需管理员权限。
- `C:\` 盘根(如 `C:\CMBot`):在盘根新建目录默认需提权;即便由管理员预建,其继承 ACL 通常只给标准用户读取权限,自动更新与数据写入会失败;域环境组策略常直接锁死盘根。
- 不采用机器级共享安装(如 `C:\ProgramData\CMBot`):默认 ACL 下多用户互写、改他人文件易失败,且共享安装目录的更新通常又需管理员权限,与「标准用户自动更新」相悖。如确有全机共享需求,作为后续扩展单独设计。
- 启动器与更新过程**不应要求管理员权限**。
- 若检测到安装根不可写,提示用户改装到可写目录,不静默失败(呼应 `docs/09` 第 6 节)。
- `CMBOT_DATA_DIR`(见第 5 节)由启动器指向安装根下的 `data\`;未设置时主程序回退到程序目录,保持开发与当前扁平发布的现状行为。
## 10. 强制更新
- `manifest.mandatory = true` 或本地版本低于 `min_supported` 时,启动器**必须更新成功后才启动主程序**;更新失败则提示用户并终止,不启动旧版本。
- 普通更新(`mandatory = false`)失败时降级启动旧版本,不阻断使用。
## 11. 启动器自身的更新
启动器逻辑稳定、极少变更,**不参与版本并排自更新**,以避免「更新器更新自己」的文件锁问题。
- 启动器版本与主程序版本解耦,单独维护。
- 确需升级启动器时,作为一次性手动分发处理(替换 `Launcher.exe`),并在发布说明中标注。
## 12. 用户体验
- 启动器在下载/安装期间显示轻量启动画面与进度,避免「双击无反应」。
- 更新完成后正常进入主程序;可选地在主程序内展示一次本次更新说明(`manifest.notes`)。
- 更新源不可达时静默降级启动,不打断用户;仅记录日志。
- 文案遵循界面语气:说明发生了什么、下一步做什么,不做无谓道歉(呼应 `docs/07` 文案规范)。
## 13. 失败处理与回滚
- **回滚**:将 `current.txt` 改回上一个 `versions\<x.y.z>\` 即可,无需重新下载。
- **版本保留策略**:默认保留最近 N 个版本(建议 N=2~3),其余在成功切换后清理,兼顾回滚能力与磁盘占用。
- **下载中断**:临时目录残留不影响现有版本;下次启动重新下载或清理。
- **校验失败**:丢弃临时目录,使用本地当前版本。
## 14. 安全考量
- 更新源处于受信任内网;本方案不引入额外签名机制,但**应对更新源做访问控制**(共享目录权限、只读发布账号)。
- 完整性校验(第 8 节)用于防止「装一半」的损坏,不用于防篡改;若后续需要防篡改,可扩展为对清单签名 + 文件哈希校验。
- 不从公网拉取任何内容。
## 15. 版本号规则
沿用 `docs/09` 第 9 节:
- 版本号从 `src/version.py` 统一读取,标题栏、打包产物、发布目录、`manifest.json` 四处一致。
- 发布目录与 `versions\` 子目录均以版本号命名(如 `CMBot-1.1.0` / `versions\1.1.0`)。
## 16. 实现阶段建议
分阶段落地,每阶段可独立验证:
1. **地基**:数据目录分离(第 5 节)。任何更新方案的前提。✅ 已实现。
2. **只读通知**:启动时读取 `manifest.json` 比对版本,有新版仅提示并打开更新源目录(不自动安装)。✅ 已实现——`services/update_service.py`(`check_for_update` / 版本比较,纯逻辑可测)+ 主窗口顶部通知横幅,检查在后台线程进行(更新源不可达不阻塞启动),更新源路径由 `update_source` 配置。
3. **自动安装**:实现启动器完整流程(下载 → 校验 → 原子切换 → 启动 → 回滚)。⛔ 未做。
4. **强制更新与保留策略**:补全 `mandatory` / `min_supported` 与版本清理。⛔ 未做。
## 17. 暂不做
- 增量二进制差分更新。
- 运行期后台静默更新。
- 公网更新通道。
- 按电脑名/用户的差异化版本下发。
- 更新源防篡改签名。
如需上述能力,再行扩展本文档,不直接混入本阶段设计。
## 18. 验收要点
- 启动器能在有新版本时自动完成安装并启动新版本。
- 安装后用户自定义模板与偏好(`data\`)完整保留。
- 更新源不可达时仍能启动本地当前版本。
- 强制更新失败时不启动旧版本并给出提示。
- 可通过改写 `current.txt` 回滚到上一版本。
- 安装目录在非管理员权限下可正常更新。