Files
cmbot/docs/10-lan-update.md
T

23 KiB
Raw Blame History

在线更新设计(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),始终可写、按用户隔离、不随程序更新/重装丢失。

<任意可写目录>\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\                  # 下载/解压临时区,安装成功后清理
  合并后的图片\              # 添加印花默认导出目录(就在程序旁、好找、不随更新替换;可在导出面板改)
  穿搭图片\                  # AI 穿搭默认输出目录(就在程序旁、好找、不随更新替换;可在 AI 穿搭页改)

%USERPROFILE%\.cmbot\        # 用户数据,独立于程序位置,始终可写、按用户隔离
  config\
    app_config.json         # 用户偏好(输出格式、最近文件夹、最近模板、更新源等)
    templates.json          # 用户自定义模板
    ai_models.json          # AI 穿搭模型配置(管理员填写 key,首次可由出厂模板播种)
    outfit_prompt.txt       # AI 穿搭默认话术(用户可编辑,缺失时由出厂模板补种)
    title_prompt.txt        # 标题生成默认提示词(用户可编辑,缺失时由出厂模板补种)
  logs\
  output\                   # 默认导出目录的回退位置(安装根不可写时,AI 穿搭回退到 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。
  • 穿搭图片\:AI 穿搭默认输出目录,同样放在安装根(Launcher.exe 旁),用于保存 AI 生成的人物穿搭图;与 合并后的图片\ 分开,避免两类产物混在一起。用户可在 AI 穿搭页改成任意目录;安装根不可写时回退 ~/.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)仍优先。
    • AI 穿搭默认输出目录独立于 get_output_dir():打包态优先返回 <安装根>\穿搭图片,不可写时回退 ~/.cmbot/output/穿搭图片;开发态用项目目录下的 穿搭图片。用户在 AI 穿搭页选择的 outfit_output_dir 仍优先。
  • 数据根解析规则(get_data_dir(),三级回退):
    1. 环境变量 CMBOT_DATA_DIR 非空时使用它(覆盖口,供测试或特殊部署)。
    2. 打包态(sys.frozen)→ ~/.cmbot(即 %USERPROFILE%\.cmbot)。不依赖启动器注入环境变量:即使用户绕过 Launcher.exe 直接双击 app\CMBot.exe,数据也落在 ~/.cmbot。
    3. 开发态 → 项目根(不污染开发者主目录,保持现状)。
  • 首次运行播种:~/.cmbot/config/app_config.json、templates.json、ai_models.json、outfit_prompt.txt、title_prompt.txt 不存在时,从程序包内 app\config\ 拷贝对应出厂默认;缺省再退回内置默认(呼应 docs/09 第 6 节)。ai_models.json 出厂模板不得包含真实 API key,管理员在用户数据目录中填写。
  • 新增配置的更新兼容:启动器当前先执行播种,再应用 staging\app.new。因此用户通过自更新换到新版时,播种阶段读取的仍可能是旧版 app\config\,新增的出厂配置文件(例如 ai_models.json)不会在这次启动被复制。且 Launcher.exe 本身不参与自更新,旧 Launcher 也可能不知道新增文件名。新增配置文件必须由主程序运行时兜底补种:新版 app 启动或加载配置时,如果 ~/.cmbot/config/<name> 不存在,应从当前新版 app\config\<name> 复制一次,仍不得覆盖用户已有文件。
  • 已有配置的非覆盖式补全:对于 ai_models.json 这类列表配置,用户文件已存在时不得整文件覆盖。若新版新增了默认标题模型(app_config.title_model 指定的 name,默认 GPT-5.5 文本),主程序应从当前 app\config\ai_models.json 查找同名条目并追加到用户 ai_models.json;用户已有同名条目时不改,出厂条目 api_key 仍为空,管理员后续在用户目录填写真实 key。
  • 写入配置/模板/日志/输出前按需创建多级目录(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):

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. 更新流程

分两阶段,对应两个执行体(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\ 回滚到上一版本。
  • 安装目录在非管理员权限下可正常更新。