- Launcher is a compiled Python/PyInstaller Launcher.exe (retires update.ps1 and install_local.ps1) - portable: extract to any writable dir; InstallRoot = Launcher.exe's folder, no longer tied to %LOCALAPPDATA% - user data moves to ~/.cmbot (%USERPROFILE%\.cmbot): always writable, per-user, survives program updates; get_data_dir() three-tier (env -> ~/.cmbot -> dev root) - writability check now applies to the program dir only; first-run seeds default templates from app\config into ~/.cmbot - docs/10 §3/§4/§5/§8/§9/§11/§16 updated; tasks 17.19 added (docs done, code TODO) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
19 KiB
在线更新设计(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 无法被覆盖。 因此「替换」必须发生在主程序未运行时,由一个独立的**启动器(Launcher)**在主程序启动前完成。
采用「固定 app 目录 + 启动器 + app.old 回滚目录」方案:新版本先下载、校验并解压到 staging\app.new\,确认完整后再把当前 app\ 改名为 app.old\,最后把 app.new\ 改名为 app\ 并启动。不做边下载边覆盖,也不直接覆盖安装根目录。
优点:
- 避免文件锁冲突——启动器先更新,确认主程序未运行后才替换
app\。 - 避免半覆盖——新版必须先完整落到
staging\app.new\,校验成功后才做目录切换。 - 回滚清晰——
app.old\保留上一个可用版本,切换失败时可改回app\。 - 目录更简洁——用户常见入口固定为
Launcher.exe,主程序固定在app\CMBot.exe。 - 与
onedir天然契合——app\是一份完整onedir产物(打成 zip 下载、解压后整体切换)。
4. 安装目录结构
便携布局:把发布 zip 解压到任意可写目录(如 D:\CMBot、桌面)即可运行,不限定 %LOCALAPPDATA%。安装根 = Launcher.exe 所在目录,程序自更新就地进行。不要放在 C:\ 盘根或 C:\Program Files——标准用户默认不可写,会导致自更新失败(见第 9 节)。
用户数据不在安装根,统一放用户主目录的 ~/.cmbot(%USERPROFILE%\.cmbot),始终可写、按用户隔离、不随程序更新/重装丢失。
<任意可写目录>\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:版本检查、下载、校验、切换、启动主程序。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(),三级回退):- 环境变量
CMBOT_DATA_DIR非空时使用它(覆盖口,供测试或特殊部署)。 - 打包态(
sys.frozen)→~/.cmbot(即%USERPROFILE%\.cmbot)。不依赖启动器注入环境变量:即使用户绕过Launcher.exe直接双击app\CMBot.exe,数据也落在~/.cmbot。 - 开发态 → 项目根(不污染开发者主目录,保持现状)。
- 环境变量
- 首次运行播种:
~/.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. 更新时机
仅在启动时由启动器检查并安装,不做运行期后台更新。
理由:
- 桌面工具天然会重启,启动时更新覆盖绝大多数场景。
- 避免运行期替换带来的文件锁与状态一致性问题。
- 主程序无需内置更新逻辑,职责更清晰(呼应
docs/04单一职责)。
更新检查必须非阻塞要害路径:更新源不可达时跳过更新,直接启动本地当前版本。
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):
http://cm.xiapi.com/
manifest.json # 最新版本清单
CMBot-1.1.0.zip # 整版 onedir 产物打包
CMBot-1.0.0.zip
manifest.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. 更新流程
启动器每次启动执行:
- 读取本地版本:读
app\version.txt;不存在则视为无本地版本或旧版扁平结构。 - 读取远端清单:HTTP GET
manifest.json(带 Basic Auth)。- 失败(网络不可达、4xx/5xx、JSON 格式错误)→ 记录日志,跳过更新,直接进入第 7 步启动本地当前版本。
- 版本比较:远端
version≤ 本地版本 → 无需更新,进入第 7 步。 - 下载 zip 到 staging:HTTP GET
manifest.url下载到staging\<version>.zip(带 Basic Auth)。- 支持断点续传(更新源返回
Accept-Ranges: bytes)、失败重试。 - 下载期间展示简易进度/启动画面(见第 12 节)。
- 支持断点续传(更新源返回
- 校验并解压:
- 校验下载文件的 SHA-256 与
manifest.sha256一致(不一致 = 下载损坏或被篡改)。 - 解压到
staging\app.new\。 - 校验
staging\app.new\CMBot.exe存在,且staging\app.new\version.txt与manifest.version一致。 - 任一步失败 → 删除临时文件/目录,记录日志,降级启动本地当前版本(若有)。
- 校验下载文件的 SHA-256 与
- 目录级切换:
- 确认主程序未运行;如检测到
CMBot.exe已运行,跳过更新并启动/提示现有版本。 - 若
app.old\存在,先删除;删除失败则跳过更新,避免无回滚目录。 - 将当前
app\重命名为app.old\。 - 将
staging\app.new\重命名为app\。 - 若第 4 步失败,立即将
app.old\改回app\,记录日志并降级。
- 确认主程序未运行;如检测到
- 启动主程序:启动
app\CMBot.exe。数据根由主程序自身解析为~/.cmbot(见第 5 节),启动器无需注入CMBOT_DATA_DIR(如需指定可经该环境变量覆盖)。 - 清理:成功切换后清理
staging\(zip 与临时目录);app.old\默认保留一个旧版本用于回滚(见第 13 节)。
任一步失败都不得使系统进入「无可启动版本」状态:只要本地存在一个可用版本,就以它启动。
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. 用户体验
- 启动器在下载/安装期间显示轻量启动画面与进度,避免「双击无反应」。
- 更新完成后正常进入主程序;可选地在主程序内展示一次本次更新说明(
manifest.notes)。 - 更新源不可达时静默降级启动,不打断用户;仅记录日志。
- 文案遵循界面语气:说明发生了什么、下一步做什么,不做无谓道歉(呼应
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. 实现阶段建议
分阶段落地,每阶段可独立验证:
- 地基:数据目录分离(第 5 节)。任何更新方案的前提。✅ 已实现。
- 只读通知:启动时读取
manifest.json比对版本,有新版仅提示。✅ 已实现——services/update_service.py(check_for_update/ 版本比较,纯逻辑可测,支持http(s)://源 + Basic Auth 及本地路径;manifest 用utf-8-sig解码以容忍 BOM)+ 主窗口顶部通知横幅,后台线程检查(不可达不阻塞启动),更新源 / 凭据由update_source/update_user/update_pass配置。 - 自动安装(PowerShell 原型):启动器完整流程(下载 → 校验 →
app目录切换 → 启动 →app.old回滚)。✅ 以scripts/update.ps1实现并端到端验证(HTTP 下载 zip + SHA-256 + 解压 +app/app.old切换);scripts/build.ps1产出version.txt+ zip + 写无 BOMmanifest.json。此为流程验证原型。 - 改用
Launcher.exe+~/.cmbot(当前方向):🚧 将启动器改为 Python + PyInstaller onefile 编译的Launcher.exe(复用services/update_service.py),采用便携布局(解压任意可写目录即用,安装根 =Launcher.exe所在目录),用户数据移到~/.cmbot(get_data_dir()三级回退,见第 5 节)。退休scripts/update.ps1与scripts/install_local.ps1(%LOCALAPPDATA%安装器)。待做:src/launcher.py、build.ps1增产Launcher.exe、安装根可写性检测、首次播种默认模板、真实环境端到端实测。 - 强制更新与保留策略:补全
mandatory/min_supported与app.old回滚策略。⛔ 未做。
启动器(Launcher.exe,目标)要点:
- 安装根 =
Launcher.exe所在目录(便携,不限定%LOCALAPPDATA%);-NoLaunch等价开关用于测试。 - 从
~/.cmbot/config/app_config.json读update_source/update_user/update_pass,与 stage ② 同源(复用update_service)。 - 读取本地
app\version.txt→ GETmanifest.json→ 比较版本 → 下载manifest.url到staging\<ver>.zip(带 Basic Auth)→ 校验 SHA-256 → 解压到staging\app.new\→ 校验CMBot.exe与version.txt→app改名app.old→app.new改名app。 - 启动前检测安装根可写性;首次运行把
app\config\默认配置/模板播种到~/.cmbot。 - 启动
app\CMBot.exe(主程序自行解析数据根为~/.cmbot,无需注入CMBOT_DATA_DIR)。 - 任何失败(网络不可达 / 4xx-5xx / SHA-256 不符 / 解压失败 / 坏 manifest / 安装根不可写)都降级启动本地现版本,绝不进入「无可启动版本」。
- 回滚 = 将
app.old\改回app\。
17. 暂不做
- 增量二进制差分更新。
- 运行期后台静默更新。
- 按电脑名/用户的差异化版本下发。
- 更新源防篡改签名(Ed25519 签名 manifest,见第 14 节,作为后续加固项)。
如需上述能力,再行扩展本文档,不直接混入本阶段设计。
18. 验收要点
- 启动器能在有新版本时自动完成安装并启动新版本。
- 安装后用户自定义模板与偏好(
data\)完整保留。 - 更新源不可达时仍能启动本地当前版本。
- 强制更新失败时不启动旧版本并给出提示。
- 可通过把
app.old\改回app\回滚到上一版本。 - 安装目录在非管理员权限下可正常更新。