# 在线更新设计(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\`。 - `~/.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()` 指向**数据根**下的对应目录。 - 数据根解析规则(`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": "", "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\.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,下次启动生效」。下载期间主界面照常可用。 - **应用**:下次双击 `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\` 回滚到上一版本。 - 安装目录在非管理员权限下可正常更新。