diff --git a/docs/10-lan-update.md b/docs/10-lan-update.md new file mode 100644 index 0000000..b6233dd --- /dev/null +++ b/docs/10-lan-update.md @@ -0,0 +1,252 @@ +# 局域网更新设计 + +## 1. 文档定位 + +本文档定义工具在**局域网内自动更新**的设计。它是 `docs/09-packaging-release.md` 第 13 节列出的「暂不做」能力的后续扩展,按该文档第 262 行要求单独成文,不混入第一阶段打包规则。 + +适用前提: + +- 多台 Windows 电脑在同一局域网内使用同一工具(见 `docs/01-product-vision.md`、`docs/02-prd.md`)。 +- 主程序保持本地可运行,**不依赖公网服务**,更新源也在内网。 +- 打包采用 `onedir` 目录模式(见 `docs/09` 第 3 节)。目录模式是本方案增量替换、版本并排的前提;不使用 `onefile`。 + +本文档定义设计,不约束最终实现语言;启动器可用 Python、PowerShell 或编译型语言实现。 + +## 2. 目标与非目标 + +### 2.1 目标 + +- 客户端启动时自动检查内网更新源是否有新版本。 +- 有新版本时自动下载并安装,用户无需手动拷贝。 +- 安装过程不破坏正在运行的程序,不丢失用户数据。 +- 安装失败或更新源不可达时,能降级到本地当前版本继续使用。 +- 支持回滚到上一可用版本。 +- 支持「强制更新」:关键修复发布后阻止旧版本继续启动。 + +### 2.2 非目标 + +- 不做公网更新、不依赖外部更新服务。 +- 不做静默后台下载(更新只在启动时进行,理由见第 6 节)。 +- 不做增量二进制差分(patch)。本阶段以「整版并排 + 文件级增量拷贝」为准。 +- 不做安装程序(installer)、注册表写入、开机自启动。 +- 不做按电脑名/用户的差异化版本策略(可作为后续扩展,见第 13 节)。 + +## 3. 总体架构 + +核心约束:**运行中的 .exe 无法被覆盖。** 因此「替换」必须发生在主程序未运行时,由一个独立的**启动器(Launcher)**在主程序启动前完成。 + +采用「**版本并排目录 + 启动器 + 当前版本指针**」方案:新版本整份安装到旧版本旁边,校验完整后再切换指针,最后启动当前版本。**绝不覆盖正在使用的版本目录。** + +优点: + +- 永不与文件锁冲突——从不写入正在运行的目录。 +- 切换原子——只改写一个指针文件,状态非新即旧,不存在「装一半」。 +- 回滚廉价——把指针改回旧版本目录即可。 +- 与 `onedir` 天然契合,支持文件级增量拷贝。 + +## 4. 安装目录结构 + +安装到**当前用户始终可写的位置**(见第 9 节):`%LOCALAPPDATA%\CMBot` +(即 `C:\Users\<用户>\AppData\Local\CMBot`)。**不安装到 `C:\` 盘根或 `C:\Program Files`**—— +两者标准用户默认不可写,会导致自动更新与数据写入失败(见第 9 节)。 + +```text +%LOCALAPPDATA%\CMBot\ # C:\Users\<用户>\AppData\Local\CMBot + Launcher.exe # 用户双击入口,逻辑稳定、极少变更 + current.txt # 当前版本指针,内容为单行版本号,如 1.1.0 + versions\ + 1.0.0\ # 历史版本,保留以便回滚 + CMBot.exe + _internal\ + resources\ + 1.1.0\ # 当前版本 + CMBot.exe + _internal\ + resources\ + data\ # 用户数据,独立于版本,更新时不动 + config\ + app_config.json # 用户偏好(输出格式、最近文件夹、最近模板等) + templates.json # 用户自定义模板 + logs\ + output\ + staging\ # 下载临时区,安装成功后清理 +``` + +说明: + +- `Launcher.exe`:版本检查、下载、校验、切换、启动主程序。逻辑稳定,本身不参与自更新(见第 11 节)。 +- `current.txt`:纯文本,仅存当前应启动的版本号。 +- `versions\\`:每个版本一份完整 `onedir` 产物。 +- `data\`:所有用户可写数据集中存放,**不随版本切换变动**。 +- `staging\`:下载与校验的临时目录,校验通过后再并入 `versions\`。 +- 整个安装根(含 `versions\`、`data\`、`Launcher.exe`)都必须免提权可写:自动更新需要写入 `versions\` 与 `current.txt`,故安装根不能落在 `C:\` 盘根或 `C:\Program Files`。 + +## 5. 数据目录分离(实现前置改造) + +当前实现中,配置、模板、日志、输出都位于程序目录下(`get_app_dir()/config`、`/logs`、`/output`)。版本并排方案要求**程序目录与数据目录分离**,否则每次切换版本都会丢失用户自定义模板与偏好。 + +这是落地任何自动更新方案的**共同地基**,已实现: + +- 引入「程序根目录」与「数据根目录」两个概念: + - 程序根(`get_app_dir()`):当前运行版本目录 `versions\\`(只读,更新时整体替换)。 + - 数据根(`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\` 文件夹。 + 2. 否则回退到 `get_app_dir()`——即开发环境(项目根)与当前扁平 `onedir` 发布(数据与程序同级)的现状布局。 + - 该设计使开发、当前发布、未来版本并排三种布局共用同一套路径规则(呼应 `docs/09` 第 7 节),且对现状**零行为变更**:未设环境变量时,config/logs/output 仍在程序目录旁。 +- 兼容旧布局:写入配置/模板/日志/输出前按需创建多级目录(`parents=True`);数据根不存在时按 `docs/09` 第 6 节规则首次创建并写入默认配置/模板。 +- 用户数据文件均位于数据根 `config/` 下:`config/app_config.json`、`config/templates.json`。 + +数据目录分离后续需同步更新 `docs/05-project-architecture.md` 与 `docs/09` 第 5、6 节的目录说明。 + +## 6. 更新时机 + +仅在**启动时**由启动器检查并安装,不做运行期后台更新。 + +理由: + +- 桌面工具天然会重启,启动时更新覆盖绝大多数场景。 +- 避免运行期替换带来的文件锁与状态一致性问题。 +- 主程序无需内置更新逻辑,职责更清晰(呼应 `docs/04` 单一职责)。 + +更新检查必须**非阻塞要害路径**:更新源不可达时跳过更新,直接启动本地当前版本。 + +## 7. 更新源与版本清单 + +更新源为内网共享目录(UNC 路径)或内网文件服务,例如: + +```text +\\nas\cmbot\releases\ + manifest.json # 最新版本清单 + CMBot-1.1.0\ # 与 release 目录结构一致的完整版本 + CMBot-1.0.0\ +``` + +`manifest.json` 字段: + +```json +{ + "version": "1.1.0", + "source": "\\\\nas\\cmbot\\releases\\CMBot-1.1.0", + "mandatory": false, + "min_supported": "1.0.0", + "notes": "修复批量导出格式问题", + "files": 142, + "marker": "CMBot.exe" +} +``` + +- `version`:最新版本号,遵循 `docs/09` 第 9 节版本规则,与 `src/version.py` 的 `APP_VERSION` 一致。 +- `source`:该版本完整产物所在的内网路径。 +- `mandatory`:是否强制更新(见第 10 节)。 +- `min_supported`:低于此版本必须更新后才能启动。 +- `notes`:更新说明,可在提示中展示。 +- `files` / `marker`:完整性校验依据(见第 8 节)。 + +版本比较按语义化版本(major.minor.patch)数值比较,不做字符串比较。 + +## 8. 更新流程 + +启动器每次启动执行: + +1. **读取本地版本**:读 `current.txt`;不存在则视为无本地版本。 +2. **读取远端清单**:读更新源 `manifest.json`。 + - 读取失败(路径不可达、文件缺失、格式错误)→ 记录日志,**跳过更新**,直接进入第 7 步启动本地当前版本。 +3. **版本比较**:远端 `version` ≤ 本地版本 → 无需更新,进入第 7 步。 +4. **下载到 staging**:将远端 `source` 目录完整拷贝到本地 `staging\.tmp\`。 + - 优先文件级增量(仅拷变动文件),减少内网带宽与耗时。 + - 拷贝期间展示简易进度/启动画面(见第 12 节)。 +5. **完整性校验**:校验 `staging\.tmp\` 是否完整——至少校验 `marker` 文件存在、文件数与 `files` 一致(后续可升级为清单哈希校验)。 + - 校验失败 → 删除该临时目录,记录日志,降级启动本地当前版本(若有)。 +6. **原子切换**: + 1. 将 `staging\.tmp\` 重命名为 `versions\\`。 + 2. 将 `current.txt` 写为该版本号(先写临时文件再替换,保证写入原子)。 +7. **启动主程序**:启动 `versions\\CMBot.exe`。 +8. **清理**:成功切换后清理 `staging\`;按保留策略清理过旧版本(见第 13 节)。 + +任一步失败都不得使系统进入「无可启动版本」状态:只要本地存在一个可用版本,就以它启动。 + +## 9. 安装位置与权限 + +自动更新要求**整个安装根免提权可写**:不仅写用户数据,还要写 `versions\`(新版本)与 `current.txt`(切换指针)。据此选择安装位置。 + +- **默认安装到 `%LOCALAPPDATA%\CMBot`**(`C:\Users\<用户>\AppData\Local\CMBot`)。 + - 当前用户对该目录始终可写,**无需管理员权限、不弹 UAC**,是自更新桌面应用(如 Chrome、VS Code、Slack)的标准做法。 + - 这是**每用户安装**:每个登录用户各一份程序与数据,互不影响。 +- **禁止安装到以下位置:** + - `C:\Program Files` / `C:\Program Files (x86)`:写入需管理员权限。 + - `C:\` 盘根(如 `C:\CMBot`):在盘根新建目录默认需提权;即便由管理员预建,其继承 ACL 通常只给标准用户读取权限,自动更新与数据写入会失败;域环境组策略常直接锁死盘根。 +- 不采用机器级共享安装(如 `C:\ProgramData\CMBot`):默认 ACL 下多用户互写、改他人文件易失败,且共享安装目录的更新通常又需管理员权限,与「标准用户自动更新」相悖。如确有全机共享需求,作为后续扩展单独设计。 +- 启动器与更新过程**不应要求管理员权限**。 +- 若检测到安装根不可写,提示用户改装到可写目录,不静默失败(呼应 `docs/09` 第 6 节)。 +- `CMBOT_DATA_DIR`(见第 5 节)由启动器指向安装根下的 `data\`;未设置时主程序回退到程序目录,保持开发与当前扁平发布的现状行为。 + +## 10. 强制更新 + +- `manifest.mandatory = true` 或本地版本低于 `min_supported` 时,启动器**必须更新成功后才启动主程序**;更新失败则提示用户并终止,不启动旧版本。 +- 普通更新(`mandatory = false`)失败时降级启动旧版本,不阻断使用。 + +## 11. 启动器自身的更新 + +启动器逻辑稳定、极少变更,**不参与版本并排自更新**,以避免「更新器更新自己」的文件锁问题。 + +- 启动器版本与主程序版本解耦,单独维护。 +- 确需升级启动器时,作为一次性手动分发处理(替换 `Launcher.exe`),并在发布说明中标注。 + +## 12. 用户体验 + +- 启动器在下载/安装期间显示轻量启动画面与进度,避免「双击无反应」。 +- 更新完成后正常进入主程序;可选地在主程序内展示一次本次更新说明(`manifest.notes`)。 +- 更新源不可达时静默降级启动,不打断用户;仅记录日志。 +- 文案遵循界面语气:说明发生了什么、下一步做什么,不做无谓道歉(呼应 `docs/07` 文案规范)。 + +## 13. 失败处理与回滚 + +- **回滚**:将 `current.txt` 改回上一个 `versions\\` 即可,无需重新下载。 +- **版本保留策略**:默认保留最近 N 个版本(建议 N=2~3),其余在成功切换后清理,兼顾回滚能力与磁盘占用。 +- **下载中断**:临时目录残留不影响现有版本;下次启动重新下载或清理。 +- **校验失败**:丢弃临时目录,使用本地当前版本。 + +## 14. 安全考量 + +- 更新源处于受信任内网;本方案不引入额外签名机制,但**应对更新源做访问控制**(共享目录权限、只读发布账号)。 +- 完整性校验(第 8 节)用于防止「装一半」的损坏,不用于防篡改;若后续需要防篡改,可扩展为对清单签名 + 文件哈希校验。 +- 不从公网拉取任何内容。 + +## 15. 版本号规则 + +沿用 `docs/09` 第 9 节: + +- 版本号从 `src/version.py` 统一读取,标题栏、打包产物、发布目录、`manifest.json` 四处一致。 +- 发布目录与 `versions\` 子目录均以版本号命名(如 `CMBot-1.1.0` / `versions\1.1.0`)。 + +## 16. 实现阶段建议 + +建议分阶段落地,每阶段可独立验证: + +1. **地基**:数据目录分离(第 5 节)。任何更新方案的前提,先行完成并通过现有测试。 +2. **只读通知**:启动时读取 `manifest.json` 比对版本,有新版仅提示并打开更新源目录(不自动安装)。验证版本检查与降级逻辑。 +3. **自动安装**:实现启动器完整流程(下载 → 校验 → 原子切换 → 启动 → 回滚)。 +4. **强制更新与保留策略**:补全 `mandatory` / `min_supported` 与版本清理。 + +## 17. 暂不做 + +- 增量二进制差分更新。 +- 运行期后台静默更新。 +- 公网更新通道。 +- 按电脑名/用户的差异化版本下发。 +- 更新源防篡改签名。 + +如需上述能力,再行扩展本文档,不直接混入本阶段设计。 + +## 18. 验收要点 + +- 启动器能在有新版本时自动完成安装并启动新版本。 +- 安装后用户自定义模板与偏好(`data\`)完整保留。 +- 更新源不可达时仍能启动本地当前版本。 +- 强制更新失败时不启动旧版本并给出提示。 +- 可通过改写 `current.txt` 回滚到上一版本。 +- 安装目录在非管理员权限下可正常更新。 diff --git a/src/services/config_service.py b/src/services/config_service.py index 5b057b8..49a8fe4 100644 --- a/src/services/config_service.py +++ b/src/services/config_service.py @@ -56,7 +56,7 @@ def save_config(data): config_file = get_config_path(_CONFIG_FILENAME) try: - config_file.parent.mkdir(exist_ok=True) + config_file.parent.mkdir(parents=True, exist_ok=True) with open(str(config_file), "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) logger.info("Config saved to %s", config_file) diff --git a/src/services/file_service.py b/src/services/file_service.py index 3be5b81..54395c3 100644 --- a/src/services/file_service.py +++ b/src/services/file_service.py @@ -1,4 +1,5 @@ import logging +import os import sys from datetime import datetime from pathlib import Path @@ -19,19 +20,41 @@ def is_supported_image(path): # --------------------------------------------------------------------------- def get_app_dir(): - """Return the application root directory as a Path. + """Return the program root directory (read-only program files) as a Path. - PyInstaller onedir: directory that contains the .exe. + PyInstaller onedir: directory that contains the .exe. In the versioned + install layout (docs/10-lan-update.md) this is versions//, which is + replaced wholesale on update. Development: project root (three levels above this file: src/services/file_service.py -> src/services -> src -> project root). + + Resources live here; writable user data does not — use get_data_dir(). """ if getattr(sys, "frozen", False): return Path(sys.executable).resolve().parent return Path(__file__).resolve().parent.parent.parent +def get_data_dir(): + """Return the writable data root (config, templates, logs, output) as a Path. + + Kept separate from the program root so that replacing the program on update + never touches user data (docs/10-lan-update.md §5). + + Resolution order: + 1. CMBOT_DATA_DIR environment variable, when set. The launcher points this + at the install-wide data/ folder in the versioned layout. + 2. Fallback to get_app_dir() — the flat layout used in development and in + the current onedir release, where data sits next to the program. + """ + env = os.environ.get("CMBOT_DATA_DIR", "").strip() + if env: + return Path(env) + return get_app_dir() + + def get_resource_path(relative_path): - """Return absolute Path for a resource file. + """Return absolute Path for a resource file under the program root. Development resources live under src/resources/. In a PyInstaller onedir build, resources are copied next to the executable under resources/. @@ -42,21 +65,21 @@ def get_resource_path(relative_path): def get_config_path(relative_path): - """Return absolute Path for a file under /config/.""" - return get_app_dir() / "config" / relative_path + """Return absolute Path for a file under /config/.""" + return get_data_dir() / "config" / relative_path def get_log_dir(): - """Return /logs/ as a Path, creating the directory if absent.""" - d = get_app_dir() / "logs" - d.mkdir(exist_ok=True) + """Return /logs/ as a Path, creating the directory if absent.""" + d = get_data_dir() / "logs" + d.mkdir(parents=True, exist_ok=True) return d def get_output_dir(): - """Return /output/ as a Path, creating the directory if absent.""" - d = get_app_dir() / "output" - d.mkdir(exist_ok=True) + """Return /output/ as a Path, creating the directory if absent.""" + d = get_data_dir() / "output" + d.mkdir(parents=True, exist_ok=True) return d diff --git a/src/services/template_service.py b/src/services/template_service.py index ee420f4..01c8531 100644 --- a/src/services/template_service.py +++ b/src/services/template_service.py @@ -77,7 +77,7 @@ def _save_custom_templates(templates: List[Template]): """Write custom templates to JSON. Logs error on failure, does not raise.""" path = _templates_file() try: - path.parent.mkdir(exist_ok=True) + path.parent.mkdir(parents=True, exist_ok=True) payload = { "templates": [ {