diff --git a/app/update_installer.py b/app/update_installer.py index b731fb2..cde7dad 100644 --- a/app/update_installer.py +++ b/app/update_installer.py @@ -75,9 +75,9 @@ def metadata_from_update_info(info): download_url=str(getattr(info, "download_url", "") or "").strip(), sha256=str(getattr(info, "sha256", "") or "").strip().lower(), size_bytes=int(getattr(info, "size_bytes", 0) or 0), - package_format=str(getattr(info, "package_format", "") or "").strip(), - updater_protocol=int(getattr(info, "updater_protocol", 0) or 0), - min_updater_protocol=int(getattr(info, "min_updater_protocol", 0) or 1), + package_format=PACKAGE_FORMAT, + updater_protocol=UPDATER_PROTOCOL, + min_updater_protocol=UPDATER_PROTOCOL, ) validate_metadata(metadata) return metadata diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 58d90b4..f47c5de 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -535,6 +535,7 @@ cmshopee/ - T-617 的独立 `cmshopee-updater.exe` 必须先复制到系统临时目录运行,并等待主程序退出。替换粒度是manifest允许的程序根项目,旧根先整体移动到同盘backup,新根再整体移入;事务锁防止并发更新,journal记录每次移动,任一步失败逆序恢复。`data/`、更新管理目录与未知安装根项目永不进入替换清单。 - T-618 在创建 `MainWindow` 前显示强制升级 `QDialog`;下载与校验只能在 `QObject + QThread` worker中执行,线程结束前保留引用,取消时等待part清理。独立更新器进程成功创建后才退出旧主程序;强制版本后续失败保持阻断,只有版本接口本身不可达/非法继续失败放行。 - T-619 要求新版按事务写 `process_started`、`main_window_ready`、`environment_blocked` 健康标记。更新器只在主窗口就绪后认定升级成功并清理成功备份;无标记/早期退出/超时则事务回滚并按目标版本+包hash熔断。环境阻断保留新版,不把本地数据目录、配置或Chrome问题误判成坏发布包。 +- T-624 以 cmhub 实际响应作为版本接口边界:服务端只需提供版本、下载地址、zip SHA-256、准确大小、强制策略、发布说明和发布时间;包格式与更新器协议由客户端内置,并在下载解压后通过包内manifest验证,不要求版本接口重复返回。 - CDP 交互事实变化同步第七节。 - 正式代码只放 `app/` 包;根目录只保留 `main.py`、配置/数据目录、文档和原型目录,不新增正式业务模块。 diff --git a/docs/packaging.md b/docs/packaging.md index 2fd55cb..c886e3d 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -186,6 +186,8 @@ T-619 增加启动健康确认与失败熔断。新版带事务参数启动, T-623 为外部对象存储/CDN下载链接增加临时 HTTP 兼容。版本检查接口仍固定使用受信任 HTTPS;安装包 `download_url` 不限制域名,可以使用任意公网 HTTP/HTTPS 域名或公网 IP,并允许跨域、跨协议重定向。初始地址、每次跳转和最终响应都会拒绝本机、内网、账号信息和非 HTTP(S) 协议。HTTP 不会降低安装校验标准:声明大小、整包 SHA-256、安全解压、包内manifest、事务替换和健康回滚全部照常执行;强制升级窗口会显示临时 HTTP 通道提示。服务端外链支持 HTTPS 后应删除 HTTP 分支。 +T-624 将 cmhub 版本接口收敛为实际字段:`version`、`download_url`、`sha256`、`release_notes`、`force_update`、`size_bytes`、`published_at`。服务端不需要返回 `package_format` 或更新器协议;客户端固定使用当前 `cmshopee-portable-v1` 和更新器协议构造安装元数据,并在解压后以包内manifest复核真实格式和协议。因此接口字段减少不等于跳过包格式或协议安全检查。 + ## 四、绝不打包的本地数据 发布包里不能包含以下本地数据、密钥、业务数据或登录态: diff --git a/docs/tasks/T-624.md b/docs/tasks/T-624.md new file mode 100644 index 0000000..e9de545 --- /dev/null +++ b/docs/tasks/T-624.md @@ -0,0 +1,44 @@ +--- +id: T-624 +title: 自动升级适配 cmhub 最小发布接口字段 +phase: 8 +deps: [T-623] +status: DONE +created: 2026-07-13 +--- + +## 问题 / 背景 + +cmhub `GET /api/v1/client/releases/latest?platform=windows` 的正式响应只提供 `platform` 和 `release` 内的 `version`、`download_url`、`sha256`、`release_notes`、`force_update`、`size_bytes`、`published_at`。客户端当前还把 `package_format`、`updater_protocol`、`min_updater_protocol` 当作服务端自动安装必填字段,导致线上已经提供 hash 和大小后仍被阻断。 + +## 方案 + +1. 版本接口只读取并依赖 cmhub 正式响应中已有的发布字段,不要求服务端增加包格式或更新器协议字段。 +2. `app/update_installer.py` 从接口结果构造安装元数据时,`package_format` 固定使用客户端 `PACKAGE_FORMAT`,`updater_protocol` 固定使用客户端 `UPDATER_PROTOCOL`,最低协议使用当前兼容默认值。 +3. 不降低安装包验证:下载后仍要求包内 `package-manifest.json` 的格式、协议、版本、文件大小和 SHA-256 与客户端支持范围一致;不兼容的包仍在替换程序前拒绝。 +4. 同步版本接口、打包和架构文档,明确服务端字段与包内 manifest 字段的职责边界。 + +## 验收要点 + +- 与线上示例完全同形、缺少 `package_format` 和协议字段的强制更新响应可以进入自动下载流程。 +- 缺少或错误的 `version`、`download_url`、`sha256`、`size_bytes` 仍被阻断;`force_update` 继续决定是否强制。 +- 包内 manifest 的包格式或更新器协议不兼容时仍拒绝安装。 +- HTTP 外链继续沿用 T-623 的公网地址与完整性校验。 + +## 测试要求 + +- 增加 cmhub 正式 JSON 结构的解析、强制门禁和内部安装元数据默认值测试。 +- 运行 `python -m ruff check app tests main.py`、`python -m compileall app main.py`、`python -m unittest discover -s tests` 和 `git diff --check`。 + +## 边界(不改什么) + +- 不修改 cmhub 服务端,不要求新增接口字段。 +- 不移除大小、整包 SHA-256、安全解压、manifest、事务替换、健康确认或回滚。 +- 不修改业务 Tab、CDP、AI、Excel、SQLite 业务数据或蝦皮更新流程。 + +## 执行记录 + +- 2026-07-13:`metadata_from_update_info()` 不再要求版本接口提供包格式和更新器协议,统一使用客户端内置 `PACKAGE_FORMAT` / `UPDATER_PROTOCOL`;包内manifest格式、协议、版本和逐文件hash校验保持不变。 +- 2026-07-13:新增与 cmhub 线上响应完全同形的解析、强制门禁、内部安装元数据和 GUI HTTP 提示回归测试,并同步版本接口、打包和架构文档。 +- 2026-07-13:使用线上接口实测通过:旧版本 `0.1.5` 能识别 `0.1.6` 强制更新,读取 `size_bytes=53318076`,并补齐内部包格式 `cmshopee-portable-v1`、协议 `1`。 +- 2026-07-13:为隔离工作区既有提示词/版本号改动,在独立干净 worktree 验证通过:Ruff、Python 3.10 compileall、完整 unittest 438 项及 `git diff --check` 全绿;未执行完整安装包替换和重启。 diff --git a/docs/update-check.md b/docs/update-check.md index 74a3efc..ad9b801 100644 --- a/docs/update-check.md +++ b/docs/update-check.md @@ -25,10 +25,10 @@ | `download_url` | string(URL) | 安装包外链;优先 HTTPS,T-623 临时兼容任意公网 HTTP/HTTPS 地址 | | `sha256` | string | zip 的 SHA-256;自动安装时必须是 64 位十六进制且不可为空 | | `size_bytes` | integer | zip 的准确字节数;自动安装时必须大于 0 | -| `package_format` | string | 自动安装固定为 `cmshopee-portable-v1` | -| `updater_protocol` | integer | 发布包要求的更新器协议版本,当前为 `1` | -| `min_updater_protocol` | integer | 可安装此包的最低更新器协议,当前为 `1` | | `release_notes` / `message` | string | 升级说明,显示在强制升级弹窗里。客户端取 `message` 或 `release_notes` | +| `published_at` | string | 服务端发布时间;客户端当前不参与强制判定 | + +`package_format`、`updater_protocol`、`min_updater_protocol` 不是 cmhub 版本接口字段。客户端使用内置 `cmshopee-portable-v1` 和当前更新器协议构造安装元数据,下载后再以包内 `package-manifest.json` 验证真实格式和协议;不兼容的包仍会在替换程序前拒绝。 版本号比较用**语义化数字段比较**(`compare_versions`,逐段比数字),不是字符串字典序:`0.1.10 > 0.1.9`。 @@ -55,7 +55,7 @@ T-617 已提供独立无控制台更新器和事务回滚能力:更新器从 ## 五、发版约定(服务端据此控制) -自动安装使用的全部字段必须放在同一个 `release` 对象内,不得把版本取自一个对象、hash 取自另一个对象。构建脚本生成的 `release/release-metadata.json` 是服务端录入模板;发布人员补公网 HTTP/HTTPS `download_url`、强制策略和中文发布说明,不得手工改写 hash、大小、包格式或协议版本。正式发布仍应优先使用 HTTPS;HTTP 只用于外链暂时无法提供 HTTPS 的兼容期。 +自动安装使用的全部字段必须放在同一个 `release` 对象内,不得把版本取自一个对象、hash 取自另一个对象。构建脚本生成的 `release/release-metadata.json` 提供版本、zip SHA-256 和准确大小;发布人员录入公网 HTTP/HTTPS `download_url`、强制策略和中文发布说明,不得手工改写 hash 或大小。正式发布仍应优先使用 HTTPS;HTTP 只用于外链暂时无法提供 HTTPS 的兼容期。 | 想要的效果 | 服务端返回 | | --- | --- | @@ -75,9 +75,6 @@ T-617 已提供独立无控制台更新器和事务回滚能力:更新器从 "download_url": "https://cm.833729.com/media/downloads/%E8%9D%A6%E7%9A%AE%E5%9C%88%E5%84%AA%E5%8C%96%E5%8A%A9%E6%89%8B0.1.1.zip", "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "size_bytes": 123456789, - "package_format": "cmshopee-portable-v1", - "updater_protocol": 1, - "min_updater_protocol": 1, "release_notes": "优化了ai模块的生图的功能", "published_at": "2026-07-08T14:30:26.935549+08:00", "force_update": true @@ -121,11 +118,11 @@ T-617 已提供独立无控制台更新器和事务回滚能力:更新器从 - `download_url` 里的中文可用 URL 编码(如 `%E8%9D%A6...`),系统浏览器能正确打开。 - 版本检查接口 `APP_UPDATE_CHECK_URL` 必须保持受信任 HTTPS;只有它返回的 `download_url` 可以在 T-623 兼容期使用公网 HTTP 外链。客户端不提供手工输入下载地址或关闭校验的入口。 - 若将来要"非强制也温和提示(可跳过、不阻断)",需在客户端加一个非强制分支,另立任务。 -- 空 `sha256` 只兼容 T-544 的人工下载引导,绝不能进入自动安装。自动安装无论使用 HTTP 还是 HTTPS,都必须同时校验 `size_bytes`、整包 SHA-256、包格式、更新器协议和包内manifest。 +- 空 `sha256` 只兼容 T-544 的人工下载引导,绝不能进入自动安装。自动安装无论使用 HTTP 还是 HTTPS,都必须同时校验接口 `size_bytes`、整包 SHA-256,以及客户端内置格式/协议对应的包内manifest。 - `manifest_signature` 与 `signature_algorithm` 是预留字段;当前未启用数字签名,不能将 SHA-256 描述为发布者身份认证。 ## 八、引导版本与灰度发布 -仍只有T-544“浏览器下载”能力的旧客户端无法凭空获得独立更新器,必须先人工覆盖一个同时包含T-615至T-619代码和 `cmshopee-updater.exe` 的引导版本。后续自动发布先以非强制方式灰度确认接口字段、下载和manifest,再开启 `force_update` 或提高 `min_supported_version`。发布包的 `updater_protocol` / `min_updater_protocol` 必须与引导版本兼容。 +仍只有T-544“浏览器下载”能力的旧客户端无法凭空获得独立更新器,必须先人工覆盖一个同时包含T-615至T-619代码和 `cmshopee-updater.exe` 的引导版本。后续自动发布先以非强制方式灰度确认接口字段、下载和manifest,再开启 `force_update` 或提高 `min_supported_version`。包内manifest的 `updater_protocol` / `min_updater_protocol` 必须与引导版本兼容,cmhub 版本接口无需重复返回。 更新器启动新版后等待健康标记:`process_started` 表示基础导入、Qt和版本核对完成;`main_window_ready` 表示数据目录、必要初始化和主窗口显示完成,此时才清理成功备份;`environment_blocked` 表示数据目录、配置或Chrome等本地环境需用户处理,保留新版且不误回滚。无已知标记、版本不符、早期退出或健康等待超时会恢复旧版并写失败版本熔断记录。 diff --git a/tests/test_gui.py b/tests/test_gui.py index e4c734a..ae1c55d 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -1747,9 +1747,6 @@ class GuiTests(TempDirMixin, unittest.TestCase): "latest_version": "1.2.0", "sha256": "a" * 64, "size_bytes": 100, - "package_format": "cmshopee-portable-v1", - "updater_protocol": 1, - "min_updater_protocol": 1, } http_dialog = ForcedUpdateDialog( update_check.UpdateCheckResult( diff --git a/tests/test_update_check.py b/tests/test_update_check.py index dc0b9a7..86396b5 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -92,6 +92,34 @@ class UpdateCheckTests(unittest.TestCase): self.assertEqual("abc", result.sha256) self.assertEqual("新版说明", result.message) + def test_check_for_update_accepts_cmhub_release_contract(self): + def fetcher(_url, _timeout): + return { + "platform": "windows", + "release": { + "version": "0.1.6", + "download_url": "http://185.216.248.75:24521/down/release.zip", + "sha256": "a" * 64, + "release_notes": "启动时联网获取更新信息", + "force_update": True, + "size_bytes": 53318076, + "published_at": "2026-07-13T17:10:59.660653+08:00", + }, + } + + result = update_check.check_for_update( + current_version="0.1.5", + url="https://cm.example.test/api/v1/client/releases/latest?platform=windows", + fetcher=fetcher, + ) + + self.assertTrue(result.forced) + self.assertFalse(result.can_enter) + self.assertEqual("0.1.6", result.latest_version) + self.assertEqual(53318076, result.size_bytes) + self.assertEqual("", result.package_format) + self.assertEqual(0, result.updater_protocol) + def test_network_failure_allows_entry(self): def fetcher(_url, _timeout): raise socket.timeout("timeout") diff --git a/tests/test_update_installer.py b/tests/test_update_installer.py index 33cfedb..d8ec2ed 100644 --- a/tests/test_update_installer.py +++ b/tests/test_update_installer.py @@ -6,6 +6,7 @@ import unittest import urllib.request import zipfile from pathlib import Path +from types import SimpleNamespace from app import release_manifest, update_installer @@ -71,6 +72,20 @@ class UpdateInstallerTests(unittest.TestCase): values.update(changes) return update_installer.UpdatePackageMetadata(**values) + def test_online_release_uses_internal_package_contract_defaults(self): + info = SimpleNamespace( + latest_version="0.1.6", + download_url="http://downloads.external.test/cmshopee.zip", + sha256="a" * 64, + size_bytes=53318076, + ) + + metadata = update_installer.metadata_from_update_info(info) + + self.assertEqual(release_manifest.PACKAGE_FORMAT, metadata.package_format) + self.assertEqual(release_manifest.UPDATER_PROTOCOL, metadata.updater_protocol) + self.assertEqual(release_manifest.UPDATER_PROTOCOL, metadata.min_updater_protocol) + def test_prepare_update_verifies_and_reuses_pending(self): with tempfile.TemporaryDirectory() as temp_dir: payload = self.make_zip(temp_dir)