docs: update online update strategy
This commit is contained in:
+45
-39
@@ -1,6 +1,6 @@
|
||||
# 在线更新设计(HTTP)
|
||||
|
||||
> 本文档原为「局域网更新」设计,现统一改为基于 **HTTP(S)** 的在线更新:更新源是一个 HTTP 文件服务(如 gohttpserver + nginx),客户端通过 HTTP 下载清单与版本包。文件名沿用 `10-lan-update.md` 以避免引用断裂;「版本并排 + 指针切换 + 回滚」等与传输无关的设计保持不变,仅「更新源 / 下载 / 安全」改为 HTTP。
|
||||
> 本文档原为「局域网更新」设计,现统一改为基于 **HTTP(S)** 的在线更新:更新源是一个 HTTP 文件服务(如 gohttpserver + nginx),客户端通过 HTTP 下载清单与版本包。文件名沿用 `10-lan-update.md` 以避免引用断裂;更新安装策略采用「`app` 目录级切换 + `app.old` 回滚」,仅在主程序启动前由启动器替换程序目录。
|
||||
|
||||
## 1. 文档定位
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- 多台 Windows 电脑使用同一工具(见 `docs/01-product-vision.md`、`docs/02-prd.md`)。
|
||||
- 更新源为一个 **HTTP(S) 文件服务**(当前部署:gohttpserver,前置 nginx,HTTP Basic Auth),托管 `manifest.json` 与各版本 zip 包。可部署在内网或公网。
|
||||
- 主程序仍本地可运行:更新源不可达时降级启动本地版本,不阻断使用。
|
||||
- 打包采用 `onedir` 目录模式(见 `docs/09` 第 3 节)。目录模式是版本并排的前提;不使用 `onefile`。
|
||||
- 打包采用 `onedir` 目录模式(见 `docs/09` 第 3 节)。目录模式是 `app\` 整体切换的前提;不使用 `onefile`。
|
||||
|
||||
本文档定义设计,不约束最终实现语言;启动器可用 Python、PowerShell 或编译型语言实现。
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
### 2.2 非目标
|
||||
|
||||
- 不做静默后台下载(更新只在启动时进行,理由见第 6 节)。
|
||||
- 不做增量二进制差分(patch)。本阶段以「整版 zip 下载 + 版本并排」为准。
|
||||
- 不做增量二进制差分(patch)。本阶段以「整版 zip 下载 + `app` 目录切换」为准。
|
||||
- 不做安装程序(installer)、注册表写入、开机自启动。
|
||||
- 不做按电脑名/用户的差异化版本策略(可作为后续扩展,见第 13 节)。
|
||||
|
||||
@@ -37,14 +37,15 @@
|
||||
|
||||
核心约束:**运行中的 .exe 无法被覆盖。** 因此「替换」必须发生在主程序未运行时,由一个独立的**启动器(Launcher)**在主程序启动前完成。
|
||||
|
||||
采用「**版本并排目录 + 启动器 + 当前版本指针**」方案:新版本整份安装到旧版本旁边,校验完整后再切换指针,最后启动当前版本。**绝不覆盖正在使用的版本目录。**
|
||||
采用「**固定 app 目录 + 启动器 + app.old 回滚目录**」方案:新版本先下载、校验并解压到 `staging\app.new\`,确认完整后再把当前 `app\` 改名为 `app.old\`,最后把 `app.new\` 改名为 `app\` 并启动。**不做边下载边覆盖,也不直接覆盖安装根目录。**
|
||||
|
||||
优点:
|
||||
|
||||
- 永不与文件锁冲突——从不写入正在运行的目录。
|
||||
- 切换原子——只改写一个指针文件,状态非新即旧,不存在「装一半」。
|
||||
- 回滚廉价——把指针改回旧版本目录即可。
|
||||
- 与 `onedir` 天然契合——每个版本是一份完整 `onedir` 产物(打成 zip 下载、解压并排)。
|
||||
- 避免文件锁冲突——启动器先更新,确认主程序未运行后才替换 `app\`。
|
||||
- 避免半覆盖——新版必须先完整落到 `staging\app.new\`,校验成功后才做目录切换。
|
||||
- 回滚清晰——`app.old\` 保留上一个可用版本,切换失败时可改回 `app\`。
|
||||
- 目录更简洁——用户常见入口固定为 `Launcher.exe`,主程序固定在 `app\CMBot.exe`。
|
||||
- 与 `onedir` 天然契合——`app\` 是一份完整 `onedir` 产物(打成 zip 下载、解压后整体切换)。
|
||||
|
||||
## 4. 安装目录结构
|
||||
|
||||
@@ -55,14 +56,14 @@
|
||||
```text
|
||||
%LOCALAPPDATA%\CMBot\ # C:\Users\<用户>\AppData\Local\CMBot
|
||||
Launcher.exe # 用户双击入口,逻辑稳定、极少变更
|
||||
current.txt # 当前版本指针,内容为单行版本号,如 1.1.0
|
||||
versions\
|
||||
1.0.0\ # 历史版本,保留以便回滚
|
||||
app\ # 当前主程序目录,一份完整 onedir 产物
|
||||
CMBot.exe
|
||||
version.txt # 当前 app 版本号,如 1.1.0
|
||||
_internal\
|
||||
resources\
|
||||
1.1.0\ # 当前版本
|
||||
app.old\ # 上一个可用版本,仅用于回滚,可不存在
|
||||
CMBot.exe
|
||||
version.txt
|
||||
_internal\
|
||||
resources\
|
||||
data\ # 用户数据,独立于版本,更新时不动
|
||||
@@ -77,28 +78,29 @@
|
||||
说明:
|
||||
|
||||
- `Launcher.exe`:版本检查、下载、校验、切换、启动主程序。逻辑稳定,本身不参与自更新(见第 11 节)。
|
||||
- `current.txt`:纯文本,仅存当前应启动的版本号。
|
||||
- `versions\<x.y.z>\`:每个版本一份完整 `onedir` 产物。
|
||||
- `app\`:当前要启动的程序目录。更新成功后,新版目录整体替换为新的 `app\`。
|
||||
- `app\version.txt`:纯文本,仅存当前 `app\` 的版本号。由发布脚本从 `src/version.py` 写入,用于启动器比较本地版本。
|
||||
- `app.old\`:上一个可用程序目录。更新失败或新版启动异常时,可把它改回 `app\` 完成回滚。
|
||||
- `data\`:所有用户可写数据集中存放,**不随版本切换变动**。
|
||||
- `staging\`:下载的 zip 与解压临时目录,校验通过后再并入 `versions\`。
|
||||
- 整个安装根(含 `versions\`、`data\`、`Launcher.exe`)都必须免提权可写:自动更新需要写入 `versions\` 与 `current.txt`,故安装根不能落在 `C:\` 盘根或 `C:\Program Files`。
|
||||
- `staging\`:下载的 zip 与解压临时目录。校验通过后,`staging\app.new\` 才会切换为 `app\`。
|
||||
- 整个安装根(含 `app\`、`app.old\`、`data\`、`staging\`、`Launcher.exe`)都必须免提权可写:自动更新需要重命名程序目录,故安装根不能落在 `C:\` 盘根或 `C:\Program Files`。
|
||||
|
||||
## 5. 数据目录分离(实现前置改造)
|
||||
|
||||
当前实现中,配置、模板、日志、输出都位于程序目录下(`get_app_dir()/config`、`/logs`、`/output`)。版本并排方案要求**程序目录与数据目录分离**,否则每次切换版本都会丢失用户自定义模板与偏好。
|
||||
当前实现中,配置、模板、日志、输出都位于程序目录下(`get_app_dir()/config`、`/logs`、`/output`)。`app` 目录切换方案要求**程序目录与数据目录分离**,否则每次替换 `app\` 都可能丢失用户自定义模板与偏好。
|
||||
|
||||
这是落地任何自动更新方案的**共同地基**,已实现:
|
||||
|
||||
- 引入「程序根目录」与「数据根目录」两个概念:
|
||||
- 程序根(`get_app_dir()`):当前运行版本目录 `versions\<current>\`(只读,更新时整体替换)。
|
||||
- 程序根(`get_app_dir()`):当前运行目录 `app\`(只读,更新时整体替换)。
|
||||
- 数据根(`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\` 文件夹。
|
||||
1. 环境变量 `CMBOT_DATA_DIR` 非空时使用它。启动器在更新布局中将其指向安装根的 `data\` 文件夹。
|
||||
2. 否则回退到 `get_app_dir()`——即开发环境(项目根)与当前扁平 `onedir` 发布(数据与程序同级)的现状布局。
|
||||
- 该设计使开发、当前发布、未来版本并排三种布局共用同一套路径规则(呼应 `docs/09` 第 7 节),且对现状**零行为变更**:未设环境变量时,config/logs/output 仍在程序目录旁。
|
||||
- 该设计使开发、当前扁平发布、未来 `app + data` 更新布局共用同一套路径规则(呼应 `docs/09` 第 7 节),且对现状**零行为变更**:未设环境变量时,config/logs/output 仍在程序目录旁。
|
||||
- 兼容旧布局:写入配置/模板/日志/输出前按需创建多级目录(`parents=True`);数据根不存在时按 `docs/09` 第 6 节规则首次创建并写入默认配置/模板。
|
||||
- 用户数据文件均位于数据根 `config/` 下:`config/app_config.json`、`config/templates.json`。
|
||||
|
||||
@@ -160,7 +162,7 @@ http://cm.xiapi.com/
|
||||
|
||||
启动器每次启动执行:
|
||||
|
||||
1. **读取本地版本**:读 `current.txt`;不存在则视为无本地版本。
|
||||
1. **读取本地版本**:读 `app\version.txt`;不存在则视为无本地版本或旧版扁平结构。
|
||||
2. **读取远端清单**:HTTP GET `manifest.json`(带 Basic Auth)。
|
||||
- 失败(网络不可达、4xx/5xx、JSON 格式错误)→ 记录日志,**跳过更新**,直接进入第 7 步启动本地当前版本。
|
||||
3. **版本比较**:远端 `version` ≤ 本地版本 → 无需更新,进入第 7 步。
|
||||
@@ -169,19 +171,23 @@ http://cm.xiapi.com/
|
||||
- 下载期间展示简易进度/启动画面(见第 12 节)。
|
||||
5. **校验并解压**:
|
||||
- 校验下载文件的 SHA-256 与 `manifest.sha256` 一致(不一致 = 下载损坏或被篡改)。
|
||||
- 解压到 `staging\<version>.tmp\`。
|
||||
- 解压到 `staging\app.new\`。
|
||||
- 校验 `staging\app.new\CMBot.exe` 存在,且 `staging\app.new\version.txt` 与 `manifest.version` 一致。
|
||||
- 任一步失败 → 删除临时文件/目录,记录日志,降级启动本地当前版本(若有)。
|
||||
6. **原子切换**:
|
||||
1. 将 `staging\<version>.tmp\` 重命名为 `versions\<version>\`。
|
||||
2. 将 `current.txt` 写为该版本号(先写临时文件再替换,保证写入原子)。
|
||||
7. **启动主程序**:启动 `versions\<current.txt>\CMBot.exe`。
|
||||
8. **清理**:成功切换后清理 `staging\`(zip 与临时目录);按保留策略清理过旧版本(见第 13 节)。
|
||||
6. **目录级切换**:
|
||||
1. 确认主程序未运行;如检测到 `CMBot.exe` 已运行,跳过更新并启动/提示现有版本。
|
||||
2. 若 `app.old\` 存在,先删除;删除失败则跳过更新,避免无回滚目录。
|
||||
3. 将当前 `app\` 重命名为 `app.old\`。
|
||||
4. 将 `staging\app.new\` 重命名为 `app\`。
|
||||
5. 若第 4 步失败,立即将 `app.old\` 改回 `app\`,记录日志并降级。
|
||||
7. **启动主程序**:启动 `app\CMBot.exe`,并设置 `CMBOT_DATA_DIR=<InstallRoot>\data`。
|
||||
8. **清理**:成功切换后清理 `staging\`(zip 与临时目录);`app.old\` 默认保留一个旧版本用于回滚(见第 13 节)。
|
||||
|
||||
任一步失败都不得使系统进入「无可启动版本」状态:只要本地存在一个可用版本,就以它启动。
|
||||
|
||||
## 9. 安装位置与权限
|
||||
|
||||
自动更新要求**整个安装根免提权可写**:不仅写用户数据,还要写 `versions\`(新版本)与 `current.txt`(切换指针)。据此选择安装位置。
|
||||
自动更新要求**整个安装根免提权可写**:不仅写用户数据,还要写 `staging\` 并重命名 `app\` / `app.old\`。据此选择安装位置。
|
||||
|
||||
- **默认安装到 `%LOCALAPPDATA%\CMBot`**(`C:\Users\<用户>\AppData\Local\CMBot`)。
|
||||
- 当前用户对该目录始终可写,**无需管理员权限、不弹 UAC**,是自更新桌面应用(如 Chrome、VS Code、Slack)的标准做法。
|
||||
@@ -201,7 +207,7 @@ http://cm.xiapi.com/
|
||||
|
||||
## 11. 启动器自身的更新
|
||||
|
||||
启动器逻辑稳定、极少变更,**不参与版本并排自更新**,以避免「更新器更新自己」的文件锁问题。
|
||||
启动器逻辑稳定、极少变更,**不参与自动自更新**,以避免「更新器更新自己」的文件锁问题。
|
||||
|
||||
- 启动器版本与主程序版本解耦,单独维护。
|
||||
- 确需升级启动器时,作为一次性手动分发处理(替换 `Launcher.exe`),并在发布说明中标注。
|
||||
@@ -215,8 +221,8 @@ http://cm.xiapi.com/
|
||||
|
||||
## 13. 失败处理与回滚
|
||||
|
||||
- **回滚**:将 `current.txt` 改回上一个 `versions\<x.y.z>\` 即可,无需重新下载。
|
||||
- **版本保留策略**:默认保留最近 N 个版本(建议 N=2~3),其余在成功切换后清理,兼顾回滚能力与磁盘占用。
|
||||
- **回滚**:将当前 `app\` 改名为 `app.bad\` 或删除,再把 `app.old\` 改回 `app\` 即可,无需重新下载。
|
||||
- **版本保留策略**:默认只保留一个旧版本,即 `app.old\`。如需保留多个历史版本,应另行扩展多版本保留策略。
|
||||
- **下载中断**:残留的 zip / 临时目录不影响现有版本;下次启动续传、重新下载或清理。
|
||||
- **校验失败**:丢弃 zip 与临时目录,使用本地当前版本。
|
||||
|
||||
@@ -234,8 +240,8 @@ HTTP 在线更新比内网共享面临更高风险,按以下层次防护:
|
||||
|
||||
沿用 `docs/09` 第 9 节:
|
||||
|
||||
- 版本号从 `src/version.py` 统一读取,标题栏、打包产物、发布目录、`manifest.json` 四处一致。
|
||||
- 发布目录与 `versions\` 子目录均以版本号命名(如 `CMBot-1.1.0` / `versions\1.1.0`)。
|
||||
- 版本号从 `src/version.py` 统一读取,标题栏、打包产物、`app\version.txt`、`manifest.json` 四处一致。
|
||||
- 发布 zip 文件名带版本号(如 `CMBot-1.1.0.zip`);客户端本地当前程序目录固定为 `app\`,不再用版本号目录名表达当前版本。
|
||||
|
||||
## 16. 实现阶段建议
|
||||
|
||||
@@ -243,17 +249,17 @@ HTTP 在线更新比内网共享面临更高风险,按以下层次防护:
|
||||
|
||||
1. **地基**:数据目录分离(第 5 节)。任何更新方案的前提。✅ 已实现。
|
||||
2. **只读通知**:启动时读取 `manifest.json` 比对版本,有新版仅提示。🚧 已实现本地文件版——`services/update_service.py`(`check_for_update` / 版本比较,纯逻辑可测)+ 主窗口顶部通知横幅,后台线程检查(不可达不阻塞启动),更新源由 `update_source` 配置。**待补:`check_for_update` 支持 `http(s)://` 源 + Basic Auth**(当前只会 `open()` 本地路径,填 URL 会静默返回 None)。
|
||||
3. **自动安装**:启动器完整流程(下载 → 校验 → 原子切换 → 启动 → 回滚)。🚧 `scripts/update.ps1` 已实现并以假版本目录验证全流程(含降级路径);**当前为 UNC/robocopy 版,待改为 HTTP**:用带凭据的 HTTP 下载 zip + SHA-256 校验 + 解压(版本切换/回滚逻辑不变)。尚未接入真实安装结构(`build.ps1` 仍产出扁平 onedir,无生成版本并排布局的安装步骤)。
|
||||
4. **强制更新与保留策略**:补全 `mandatory` / `min_supported` 与版本清理(保留最近 N 个)。⛔ 未做。
|
||||
3. **自动安装**:启动器完整流程(下载 → 校验 → `app` 目录切换 → 启动 → `app.old` 回滚)。🚧 `scripts/update.ps1` 已实现并以假版本目录验证旧版全流程(含降级路径);**当前为 UNC/robocopy + `versions/current.txt` 版,待改为 HTTP + `app/app.old` 目录切换**:用带凭据的 HTTP 下载 zip + SHA-256 校验 + 解压到 `staging\app.new\`,再重命名切换。尚未接入真实安装结构(`build.ps1` 仍产出扁平 onedir,无生成 `app\` 安装布局的步骤)。
|
||||
4. **强制更新与保留策略**:补全 `mandatory` / `min_supported` 与 `app.old` 回滚策略。⛔ 未做。
|
||||
|
||||
启动器(`scripts/update.ps1`,HTTP 版目标)要点:
|
||||
|
||||
- 入口参数 `-InstallRoot`(默认脚本所在目录)、`-NoLaunch`(测试用,只更新不启动)。
|
||||
- 从 `<InstallRoot>\data\config\app_config.json` 读 `update_source` / `update_user` / `update_pass`,与 stage ② 同源。
|
||||
- HTTP GET `manifest.json` → 比较版本 → 下载 `manifest.url` 到 `staging\<ver>.zip`(带 Basic Auth,支持续传)→ 校验 SHA-256 → 解压到 `staging\<ver>.tmp` → 重命名进 `versions\<ver>` → 原子写 `current.txt`(ascii 无 BOM)。
|
||||
- 启动 `versions\<current>\CMBot.exe` 并设 `CMBOT_DATA_DIR=<InstallRoot>\data`(接 stage ① 数据目录分离)。
|
||||
- 读取本地 `app\version.txt` → HTTP GET `manifest.json` → 比较版本 → 下载 `manifest.url` 到 `staging\<ver>.zip`(带 Basic Auth,支持续传)→ 校验 SHA-256 → 解压到 `staging\app.new\` → 校验 `CMBot.exe` 与 `version.txt` → `app` 改名为 `app.old` → `app.new` 改名为 `app`。
|
||||
- 启动 `app\CMBot.exe` 并设 `CMBOT_DATA_DIR=<InstallRoot>\data`(接 stage ① 数据目录分离)。
|
||||
- 任何失败(网络不可达 / 4xx-5xx / SHA-256 不符 / 解压失败 / 坏 manifest)都降级启动本地现版本,绝不进入「无可启动版本」。
|
||||
- 旧版本目录保留,回滚 = 手改 `current.txt` 回旧版本号。
|
||||
- 旧版本目录保留为 `app.old\`,回滚 = 将 `app.old\` 改回 `app\`。
|
||||
|
||||
## 17. 暂不做
|
||||
|
||||
@@ -270,5 +276,5 @@ HTTP 在线更新比内网共享面临更高风险,按以下层次防护:
|
||||
- 安装后用户自定义模板与偏好(`data\`)完整保留。
|
||||
- 更新源不可达时仍能启动本地当前版本。
|
||||
- 强制更新失败时不启动旧版本并给出提示。
|
||||
- 可通过改写 `current.txt` 回滚到上一版本。
|
||||
- 可通过把 `app.old\` 改回 `app\` 回滚到上一版本。
|
||||
- 安装目录在非管理员权限下可正常更新。
|
||||
|
||||
Reference in New Issue
Block a user