Files
cmbot/docs/10-lan-update.md
T
adminandClaude Opus 4.8 3c4617a27b docs: default export dir to <install root>\合并后的图片
Default output moves from ~/.cmbot/output (buried in the user profile) to a
合并后的图片\ folder next to Launcher.exe — easy to find and not replaced on
update (NOT under app\, which is swapped each update). Falls back to
~/.cmbot/output if the install root is read-only; user can still pick any dir.
docs/10 §4/§5, docs/02 config, docs/07 §7.5; tasks 17.22.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:26:04 +08:00

296 lines
20 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.
# 在线更新设计(HTTP)
> 本文档原为「局域网更新」设计,现统一改为基于 **HTTP(S)** 的在线更新:更新源是一个 HTTP 文件服务(如 gohttpserver + nginx),客户端通过 HTTP 下载清单与版本包。文件名沿用 `10-lan-update.md` 以避免引用断裂;更新安装策略采用「`app` 目录级切换 + `app.old` 回滚」,仅在主程序启动前由启动器替换程序目录。
## 1. 文档定位
本文档定义工具的**在线自动更新(HTTP)**设计。它是 `docs/09-packaging-release.md` 第 13 节列出的「暂不做」能力的后续扩展,按该文档第 262 行要求单独成文,不混入第一阶段打包规则。
适用前提:
- 多台 Windows 电脑使用同一工具(见 `docs/01-product-vision.md`、`docs/02-prd.md`)。
- 更新源为一个 **HTTP(S) 文件服务**(当前部署:gohttpserver,前置 nginx,HTTP Basic Auth),托管 `manifest.json` 与各版本 zip 包。可部署在内网或公网。
- 主程序仍本地可运行:更新源不可达时降级启动本地版本,不阻断使用。
- 打包采用 `onedir` 目录模式(见 `docs/09` 第 3 节)。目录模式是 `app\` 整体切换的前提;不使用 `onefile`。
本文档定义设计,不约束最终实现语言;启动器可用 Python、PowerShell 或编译型语言实现。
## 2. 目标与非目标
### 2.1 目标
- 客户端启动时自动检查 HTTP 更新源是否有新版本。
- 有新版本时自动下载并安装,用户无需手动拷贝。
- 安装过程不破坏正在运行的程序,不丢失用户数据。
- 安装失败或更新源不可达时,能降级到本地当前版本继续使用。
- 支持回滚到上一可用版本。
- 支持「强制更新」:关键修复发布后阻止旧版本继续启动。
### 2.2 非目标
- 不做静默后台下载(更新只在启动时进行,理由见第 6 节)。
- 不做增量二进制差分(patch)。本阶段以「整版 zip 下载 + `app` 目录切换」为准。
- 不做安装程序(installer)、注册表写入、开机自启动。
- 不做按电脑名/用户的差异化版本策略(可作为后续扩展,见第 13 节)。
## 3. 总体架构
核心约束:**运行中的 .exe 无法被覆盖**,且运行时也无法重命名它所在的 `app\` 文件夹。因此把更新拆成**两个阶段**,分别在「app 运行时」和「app 未运行时」执行:
- **下载暂存(app 运行时)**:主程序内(手动「更新」按钮,未来可后台)下载新版 zip、校验 SHA-256、解压到 `staging\app.new\`。**只暂存,不切换**——此刻 `app\` 正被占用。
- **应用切换(启动时)**:下次启动,由独立的**启动器 `Launcher.exe`** 在拉起主程序之前,把 `staging\app.new\` 切换为 `app\`(旧的改名 `app.old\`)。此刻主程序尚未运行,可安全重命名目录。
所以分工是:**启动器只负责「应用已暂存好的更新」+ 启动,启动时不联网、不下载;下载发生在 app 内。**
优点:
- **启动永不阻塞**——启动器不下载,只做一次本地秒级目录切换(或直接启动)。
- 避免文件锁冲突——切换发生在主程序未运行时,不就地覆盖运行中的文件。
- 避免半覆盖——新版必须先完整落到 `staging\app.new\` 并校验通过,才在启动时切换。
- 回滚清晰——`app.old\` 保留上一个可用版本,切换失败时可改回 `app\`。
- 与 `onedir` 天然契合——`app\` 是一份完整 `onedir` 产物(打成 zip 下载、解压后整体切换)。
## 4. 安装目录结构
**便携布局**:把发布 zip 解压到**任意可写目录**(如 `D:\CMBot`、桌面)即可运行,不限定 `%LOCALAPPDATA%`。安装根 = `Launcher.exe` 所在目录,程序自更新就地进行。**不要放在 `C:\` 盘根或 `C:\Program Files`**——标准用户默认不可写,会导致自更新失败(见第 9 节)。
**用户数据不在安装根**,统一放用户主目录的 `~/.cmbot`(`%USERPROFILE%\.cmbot`),始终可写、按用户隔离、不随程序更新/重装丢失。
```text
<任意可写目录>\CMBot\ # 例如 D:\CMBot,InstallRoot = Launcher.exe 所在目录
Launcher.exe # 用户双击入口:应用已暂存的更新(若有)→ 启动 app
app\ # 当前主程序目录,一份完整 onedir 产物
CMBot.exe
version.txt # 当前 app 版本号,如 1.1.0
_internal\
resources\
config\ # 出厂默认配置/模板,首次运行播种到 ~/.cmbot(见第 5 节)
app.old\ # 上一个可用版本,仅用于回滚,可不存在
staging\ # 下载/解压临时区,安装成功后清理
合并后的图片\ # 默认导出目录(就在程序旁、好找、不随更新替换;可在导出面板改)
%USERPROFILE%\.cmbot\ # 用户数据,独立于程序位置,始终可写、按用户隔离
config\
app_config.json # 用户偏好(输出格式、最近文件夹、最近模板、更新源等)
templates.json # 用户自定义模板
logs\
output\ # 默认导出目录的回退位置(安装根不可写时)
```
说明:
- `Launcher.exe`:**只「应用已暂存的更新 + 启动」**——若 `staging\app.new\` 有就绪的新版则秒切,否则直接启动 `app\CMBot.exe`;启动时不下载、不联网。**Python + PyInstaller onefile** 编译,逻辑稳定,本身不参与自更新(见第 11 节)。下载由主程序内完成。
- `app\`:当前要启动的程序目录。更新成功后,新版目录整体替换为新的 `app\`。
- `app\version.txt`:纯文本,仅存当前 `app\` 的版本号。由发布脚本从 `src/version.py` 写入,供启动器比较本地版本。
- `app\config\`:出厂默认配置/模板,仅作首次运行的播种来源,运行时不读写。
- `app.old\`:上一个可用程序目录。更新失败或新版启动异常时,可把它改回 `app\` 完成回滚。
- `staging\`:下载的 zip 与解压临时目录。校验通过后,`staging\app.new\` 才会切换为 `app\`。
- `合并后的图片\`:**默认导出目录**,放在安装根(`Launcher.exe` 旁),便于用户直接找到合成结果,且不随 `app\` 更新替换。用户可在导出面板改成任意目录;安装根不可写时回退 `~/.cmbot/output`。
- `~/.cmbot\`:配置/模板/日志集中存放,**与程序位置、版本切换均无关**。即便 `app\` 所在目录只读(更新失败),配置/模板仍可正常写。
- 安装根(含 `app\`、`app.old\`、`staging\`、`Launcher.exe`)需免提权可写**才能自更新**;不可写时仅「更新失败」,不影响数据读写。
## 5. 数据目录分离(实现前置改造)
当前实现中,配置、模板、日志、输出都位于程序目录下(`get_app_dir()/config`、`/logs`、`/output`)。`app` 目录切换方案要求**程序目录与数据目录分离**,否则每次替换 `app\` 都可能丢失用户自定义模板与偏好。
这是落地任何自动更新方案的**共同地基**,已实现:
- 引入「程序根目录」与「数据根目录」两个概念:
- 程序根(`get_app_dir()`):当前运行目录 `app\`(只读,更新时整体替换)。
- 数据根(`get_data_dir()`):用户数据所在,**与程序位置解耦**。
- 路径函数划分:
- `get_resource_path()` 指向**程序根**下的 `resources/`。
- `get_config_path()`、`get_log_dir()` 指向**数据根**下的对应目录。
- `get_output_dir()`(默认导出目录)特例:打包态优先返回 `<安装根>\合并后的图片`(就在程序旁、好找、不随更新替换),不可写时回退数据根 `output\`;开发态用项目目录。用户在导出面板的选择(`output_dir`)仍优先。
- 数据根解析规则(`get_data_dir()`,三级回退):
1. 环境变量 `CMBOT_DATA_DIR` 非空时使用它(覆盖口,供测试或特殊部署)。
2. 打包态(`sys.frozen`)→ `~/.cmbot`(即 `%USERPROFILE%\.cmbot`)。**不依赖启动器注入环境变量**:即使用户绕过 `Launcher.exe` 直接双击 `app\CMBot.exe`,数据也落在 `~/.cmbot`。
3. 开发态 → 项目根(不污染开发者主目录,保持现状)。
- **首次运行播种**:`~/.cmbot/config/templates.json`(或 `app_config.json`)不存在时,从程序包内 `app\config\` 拷贝出厂默认;缺省再退回内置默认(呼应 `docs/09` 第 6 节)。
- 写入配置/模板/日志/输出前按需创建多级目录(`parents=True`)。
数据目录分离与 `~/.cmbot` 约定后续需同步 `docs/05-project-architecture.md` 与 `docs/09` 第 5、6 节的目录说明。
## 6. 更新时机
- **检查**:主程序**启动时**后台线程检查一次(非阻塞);发现新版只在设置入口旁显示一行蓝色提示文字,不打断使用。
- **下载暂存**:用户在设置里点「更新」时进行(app 运行期);下载/校验不阻塞主界面,完成后暂存待应用。
- **应用切换**:仅在**下次启动**由启动器完成(主程序未运行时)。
理由:
- 启动器只做秒级切换,**启动永不阻塞**;下载与启动解耦。
- 切换发生在主程序未运行时,避免文件锁与目录占用问题。
- 检查失败/更新源不可达时静默跳过,不影响启动与使用。
## 7. 更新源与版本清单
更新源为一个 HTTP(S) 文件服务,托管清单与各版本 zip。相关配置项在 `app_config.json`(见 `docs/02-prd.md`):
- `update_source`:清单 `manifest.json` 的基础 URL 或完整 URL(为空时不做更新检查)。
- `update_user` / `update_pass`:HTTP Basic Auth 凭据(更新源开启鉴权时使用,为空表示匿名)。
当前部署示例(gohttpserver + nginx,HTTP Basic Auth):
```text
http://cm.xiapi.com/
manifest.json # 最新版本清单
CMBot-1.1.0.zip # 整版 onedir 产物打包
CMBot-1.0.0.zip
```
`manifest.json` 字段:
```json
{
"version": "1.1.0",
"url": "http://cm.xiapi.com/CMBot-1.1.0.zip",
"sha256": "<CMBot-1.1.0.zip 的 SHA-256 十六进制>",
"size": 57033820,
"mandatory": false,
"min_supported": "1.0.0",
"notes": "修复批量导出格式问题"
}
```
- `version`:最新版本号,遵循 `docs/09` 第 9 节版本规则,与 `src/version.py` 的 `APP_VERSION` 一致。
- `url`:该版本 zip 包的下载地址(可为绝对 URL;若为相对路径,相对 `update_source` 解析)。
- `sha256`:zip 包的 SHA-256,下载后校验,防止下载损坏或被篡改(见第 8、14 节)。
- `size`:zip 字节数,用于显示进度与粗校验(可选)。
- `mandatory`:是否强制更新(见第 10 节)。
- `min_supported`:低于此版本必须更新后才能启动。
- `notes`:更新说明,可在提示中展示。
版本比较按语义化版本(major.minor.patch)数值比较,不做字符串比较。
## 8. 更新流程
分两阶段,对应两个执行体(`services/installer.py` 提供两个函数)。
### 8.1 阶段一:下载暂存(主程序内,`installer.download_and_stage`)
主程序内点「更新」时执行:
1. **取清单**:HTTP GET `manifest.json`(带 Basic Auth),与 `APP_VERSION` 比较;非更新/不可达 → 提示「已是最新或连接失败」,不动 `app\`。
2. **可写性检测**:安装根不可写 → 报「安装目录不可写」并终止(不影响 app 继续使用)。
3. **下载**:GET `manifest.url` 到 `staging\<version>.zip`(带 Basic Auth)。
4. **校验**:`size`(若有)与 SHA-256 必须与清单一致,否则丢弃报错。
5. **解压 + 校验包**:解压,定位含 `CMBot.exe` 的包根,校验其 `version.txt == manifest.version`,移动为 `staging\app.new\`。
6. 成功 → 提示「已下载 vX,下次启动生效」。**全程不触碰 `app\`**,主程序照常使用。
### 8.2 阶段二:应用切换(启动器,`installer.apply_staged`)
下次启动 `Launcher.exe` 时执行(主程序未运行):
1. **首次播种**:`~/.cmbot/config` 缺文件时,从 `app\config\` 拷出厂默认。
2. **检查暂存**:`staging\app.new\` 无就绪包 → 跳过,直接启动当前 `app\`。
3. **新旧校验**:暂存版本 ≤ 当前 `app\version.txt` → 丢弃暂存(防降级/重复),直接启动。
4. **目录切换**:删除旧 `app.old\` → `app\` 改名为 `app.old\` → `staging\app.new\` 改名为 `app\`;第 3 步式的改名失败(如 exe 仍被占用)→ 立即把 `app.old\` 改回 `app\` 回滚。
5. **启动**:启动 `app\CMBot.exe`。数据根由主程序自身解析为 `~/.cmbot`(见第 5 节)。
任一步失败都不得使系统进入「无可启动版本」状态:只要本地存在一个可用 `app\`,就以它启动。
## 9. 安装位置与权限
便携模型把「程序位置」与「数据位置」拆开,权限要求也随之分两层:
**用户数据(`~/.cmbot`)—— 始终可写。**
- 用户主目录对当前用户永远可写、不需提权,故配置/模板/日志/导出无论程序装在哪都能正常读写。
- 按用户隔离:同机多用户即使共用同一份程序,各自数据互不干扰。
**程序与更新(安装根)—— 需可写才能自更新。**
- 安装根 = `Launcher.exe` 所在目录。自更新要写 `staging\` 并重命名 `app\` / `app.old\`,故该目录需**免提权可写**。
- **不要放在**:
- `C:\Program Files` / `C:\Program Files (x86)`:写入需管理员权限。
- `C:\` 盘根(如 `C:\CMBot`):盘根新建/改名默认需提权;继承 ACL 常只给标准用户读取权限;域环境组策略常锁死盘根。
- 推荐放在 `D:\CMBot`、`%USERPROFILE%\CMBot`、桌面等用户可写位置。
- 启动器与更新过程**不应要求管理员权限**。
- 启动器应**检测安装根是否可写**;不可写时提示「请将程序移到可写目录后再运行」并降级(仍能启动现有 `app\`、仍能读写 `~/.cmbot` 数据),不静默失败。
- 数据根默认 `~/.cmbot`,与安装根无关(见第 5 节);`CMBOT_DATA_DIR` 仅作覆盖口。
## 10. 强制更新
- `manifest.mandatory = true` 或本地版本低于 `min_supported` 时,启动器**必须更新成功后才启动主程序**;更新失败则提示用户并终止,不启动旧版本。
- 普通更新(`mandatory = false`)失败时降级启动旧版本,不阻断使用。
## 11. 启动器自身的更新
启动器(`Launcher.exe`,Python + PyInstaller onefile)逻辑稳定、极少变更,**不参与自动自更新**,以避免「更新器更新自己」的文件锁问题(运行中的 `Launcher.exe` 无法被覆盖)。
- 启动器版本与主程序版本解耦,单独维护。
- 确需升级启动器时,作为一次性手动分发处理(替换 `Launcher.exe`),并在发布说明中标注。
## 12. 用户体验
- **发现新版**:主程序启动后台检查,有新版则在 `⚙ 配置` 按钮左侧显示一行低调蓝色提示 `发现新版本 vX.Y.Z`(可点击进设置;不用红色、不弹阻塞横幅、不打断工作)。
- **手动更新**:用户打开设置 → 点「检查并更新」→ 下载在后台进行,完成后提示「已下载 vX,下次启动生效」。下载期间主界面照常可用。
- **应用**:下次双击 `Launcher.exe` 时秒切到新版,几乎无感;启动**永不因下载而卡住**。
- 更新源不可达 / 安装目录不可写时,给出明确文案并降级,不静默失败也不阻断使用。
- 文案遵循界面语气:说明发生了什么、下一步做什么,不做无谓道歉(呼应 `docs/07` 文案规范)。
## 13. 失败处理与回滚
- **回滚**:将当前 `app\` 改名为 `app.bad\` 或删除,再把 `app.old\` 改回 `app\` 即可,无需重新下载。
- **版本保留策略**:默认只保留一个旧版本,即 `app.old\`。如需保留多个历史版本,应另行扩展多版本保留策略。
- **下载中断**:残留的 zip / 临时目录不影响现有版本;下次启动续传、重新下载或清理。
- **校验失败**:丢弃 zip 与临时目录,使用本地当前版本。
## 14. 安全考量
HTTP 在线更新比内网共享面临更高风险,按以下层次防护:
- **传输用 HTTPS**:Basic Auth 凭据为 base64≈明文,纯 HTTP 下凭据与下载内容均可被监听/篡改。生产环境必须让更新源走 HTTPS(gohttpserver 配证书,或前置 nginx/Caddy 终止 TLS)。
- **访问控制**:更新源开启 Basic Auth;客户端使用**只读账号**(仅能下载,不能上传/删除),上传发布用单独的管理账号。
- **SHA-256 完整性校验**(第 8 节):下载后比对 `manifest.sha256`,既防「下载损坏」也防「文件被替换」(前提是 manifest 本身可信)。
- **(可选)防篡改签名**:若担心更新源被攻破后投毒,可扩展为 **Ed25519 签名 manifest**(公钥内嵌启动器,验签通过才采信 manifest 与其中的 sha256)。本阶段先不做,列入第 17 节。
- 凭据 `update_user` / `update_pass` 存于 `app_config.json`(明文)。因其为只读账号、风险可控;如需更强保护可后续改为系统凭据库或环境变量。
## 15. 版本号规则
沿用 `docs/09` 第 9 节:
- 版本号从 `src/version.py` 统一读取,标题栏、打包产物、`app\version.txt`、`manifest.json` 四处一致。
- 发布 zip 文件名带版本号(如 `CMBot-1.1.0.zip`);客户端本地当前程序目录固定为 `app\`,不再用版本号目录名表达当前版本。
## 16. 实现阶段建议
分阶段落地,每阶段可独立验证:
1. **地基**:数据目录分离(第 5 节)。任何更新方案的前提。✅ 已实现。
2. **检测通知**:启动时比对版本,有新版提示。✅ 已实现——`services/update_service.py`(`check_for_update` / 版本比较,支持 **`http(s)://` 源 + Basic Auth** 及本地路径,manifest 用 `utf-8-sig` 容忍 BOM)+ 主窗口后台线程检查 → `⚙ 配置` 按钮左侧显示蓝色提示文字(非阻塞)。
3. **非阻塞更新(当前方向)**:✅ 已实现——下载与应用拆开,**启动不阻塞**。
- `services/installer.py`:`download_and_stage()`(app 内下载→SHA-256→解压→`staging\app.new`)、`apply_staged()`(启动器秒切 `app/app.old`,含「不比当前新则丢弃」「移动失败回滚」)、`staged_version()`。
- `src/launcher.py`:瘦身为 seed + `apply_staged` + 启动,**不联网**。
- `src/app/widgets/settings_dialog.py`:「检查并更新」按钮 → `download_and_stage` → 「下次启动生效」。
- `get_data_dir()` 三级回退(见第 5 节);`scripts/build.ps1` 产出 `Launcher.exe` + 便携布局 + 两个 zip + 无 BOM `manifest.json`。
- 已用本地 HTTP server 真实端到端验证(下载暂存 → app 不动 → 启动器秒切)。**待做**:Windows 真实 PyInstaller 构建 + 解压 D 盘真机实测。
4. **强制更新与保留策略**:补全 `mandatory` / `min_supported` 与 `app.old` 保留策略。⛔ 未做。
关键约定:
- 安装根 = `Launcher.exe` 所在目录(便携,不限定 `%LOCALAPPDATA%`)。
- 配置 `update_source` / `update_user` / `update_pass` 从 `~/.cmbot/config/app_config.json` 读(app 与启动器同源)。
- **启动器不下载**:只在 `staging\app.new\` 就绪时秒切,否则直接启动;首次播种 `app\config\` 默认模板到 `~/.cmbot`。
- 任何失败都降级启动本地现版本,绝不进入「无可启动版本」;回滚 = 将 `app.old\` 改回 `app\`。
## 17. 暂不做
- 增量二进制差分更新。
- 运行期后台**静默下载**(当前为手动「更新」按钮触发;后台自动下载可后续加)。
- 按电脑名/用户的差异化版本下发。
- 更新源防篡改签名(Ed25519 签名 manifest,见第 14 节,作为后续加固项)。
如需上述能力,再行扩展本文档,不直接混入本阶段设计。
## 18. 验收要点
- 有新版时 `⚙ 配置` 按钮左侧出现蓝色提示文字,启动不被打断。
- 设置里「检查并更新」能下载暂存,下载期间主界面照常可用,完成提示「下次启动生效」。
- 下载暂存期间 `app\` 不被改动;下次启动 `Launcher.exe` 秒切到新版,`app.old\` 保留旧版。
- 安装(切换)后用户自定义模板与偏好(`~/.cmbot`)完整保留。
- 更新源不可达 / 安装目录不可写时给出明确提示并降级,仍能启动本地当前版本。
- 可通过把 `app.old\` 改回 `app\` 回滚到上一版本。
- 安装目录在非管理员权限下可正常更新。