diff --git a/docs/10-lan-update.md b/docs/10-lan-update.md index 99e7b2b..e0a37d5 100644 --- a/docs/10-lan-update.md +++ b/docs/10-lan-update.md @@ -248,11 +248,11 @@ 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. **自动安装**:启动器完整流程(下载 → 校验 → `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\` 安装布局的步骤)。 +2. **只读通知**:启动时读取 `manifest.json` 比对版本,有新版仅提示。✅ 已实现——`services/update_service.py`(`check_for_update` / 版本比较,纯逻辑可测,支持 **`http(s)://` 源 + Basic Auth** 及本地路径;manifest 用 `utf-8-sig` 解码以容忍 BOM)+ 主窗口顶部通知横幅,后台线程检查(不可达不阻塞启动),更新源 / 凭据由 `update_source` / `update_user` / `update_pass` 配置。 +3. **自动安装**:启动器完整流程(下载 → 校验 → `app` 目录切换 → 启动 → `app.old` 回滚)。✅ 已实现并端到端验证——`scripts/update.ps1`(HTTP 下载 zip + SHA-256 + 解压 + `app/app.old` 切换);`scripts/build.ps1` 产出 `version.txt` + zip + 写 `manifest.json`(无 BOM)+ 可选发布;`scripts/install_local.ps1` 在 `%LOCALAPPDATA%\CMBot` 铺出 `app/app.old/data/staging` 布局并迁移用户配置。已用本地 HTTP server 假发布包验证「拉清单 → 下载 → 校验 → 切换」全流程通过。**待办**:真实环境端到端实测;接入正式发布流水线。 4. **强制更新与保留策略**:补全 `mandatory` / `min_supported` 与 `app.old` 回滚策略。⛔ 未做。 -启动器(`scripts/update.ps1`,HTTP 版目标)要点: +启动器(`scripts/update.ps1`)要点: - 入口参数 `-InstallRoot`(默认脚本所在目录)、`-NoLaunch`(测试用,只更新不启动)。 - 从 `\data\config\app_config.json` 读 `update_source` / `update_user` / `update_pass`,与 stage ② 同源。 diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 3e6951f..f4918e8 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -158,7 +158,13 @@ $Manifest = [ordered]@{ min_supported = $MinSupported notes = $ReleaseNotes } -$Manifest | ConvertTo-Json | Set-Content -LiteralPath $ManifestPath -Encoding UTF8 +# Write UTF-8 WITHOUT BOM: Set-Content -Encoding UTF8 emits a BOM that breaks +# json.loads in the in-app update check (services/update_service.py). +[System.IO.File]::WriteAllText( + $ManifestPath, + ($Manifest | ConvertTo-Json), + (New-Object System.Text.UTF8Encoding $false) +) if ($PublishDir) { New-Item -ItemType Directory -Force -Path $PublishDir | Out-Null diff --git a/src/services/update_service.py b/src/services/update_service.py index 4e797dc..9138a54 100644 --- a/src/services/update_service.py +++ b/src/services/update_service.py @@ -86,13 +86,15 @@ def _load_http_manifest(update_source, update_user="", update_pass=""): with request.urlopen(req, timeout=5) as response: payload = response.read() if isinstance(payload, bytes): - payload = payload.decode("utf-8") + # utf-8-sig tolerates a UTF-8 BOM (PowerShell Set-Content -Encoding UTF8 + # emits one); plain utf-8 would make json.loads reject it. + payload = payload.decode("utf-8-sig") return json.loads(payload) def _load_local_manifest(update_source): manifest_path = Path(update_source) / MANIFEST_NAME - with open(str(manifest_path), encoding="utf-8") as f: + with open(str(manifest_path), encoding="utf-8-sig") as f: return json.load(f) diff --git a/tasks.md b/tasks.md index 6377932..cc42445 100644 --- a/tasks.md +++ b/tasks.md @@ -912,8 +912,10 @@ - [x] 启动器 `update.ps1` 改 HTTP:带凭据下载 zip → 校验 SHA-256 → 解压到 `staging\app.new` → `app/app.old` 切换回滚 - [x] manifest 字段由 `source`/`files`/`marker` 改为 `url`/`sha256`/`size` - [x] 发布流程脚本:build → 打 zip → 算 SHA-256 → 写 manifest → 上传 +- [x] BOM 兼容:`check_for_update` 用 `utf-8-sig` 解码,`build.ps1` 写 manifest 不带 BOM(否则 PS5.1 的 UTF8 BOM 会让 app 内 `json.loads` 静默失效);补 2 个 BOM 单测 +- [x] `update.ps1` HTTP 版以本地 HTTP server 假发布包端到端验证(拉清单→下载→SHA-256→`app/app.old` 切换) - [ ] 生产前将更新源切到 HTTPS、客户端改用只读账号 -- [ ] 真实环境端到端实测 +- [ ] 真实环境(cm.xiapi.com)端到端实测 ## 18. 后续暂缓任务 diff --git a/tests/test_update_service.py b/tests/test_update_service.py index 72cb8a9..28739fe 100644 --- a/tests/test_update_service.py +++ b/tests/test_update_service.py @@ -98,6 +98,14 @@ class TestCheckForUpdate(unittest.TestCase): self._write_manifest({"notes": "no version here"}) self.assertIsNone(check_for_update(str(self.tmp), "1.0.0")) + def test_local_manifest_with_utf8_bom(self): + # PowerShell Set-Content -Encoding UTF8 emits a BOM; must still parse. + with open(str(self.tmp / "manifest.json"), "w", encoding="utf-8-sig") as f: + json.dump({"version": "1.1.0"}, f) + info = check_for_update(str(self.tmp), "1.0.0") + self.assertIsInstance(info, UpdateInfo) + self.assertEqual(info.version, "1.1.0") + def test_source_falls_back_to_update_source(self): self._write_manifest({"version": "2.0.0"}) # no "source" field info = check_for_update(str(self.tmp), "1.0.0") @@ -168,6 +176,15 @@ class TestCheckForUpdate(unittest.TestCase): with patch("services.update_service.request.urlopen", fake_urlopen): self.assertIsNone(check_for_update("https://example.test", "1.0.0")) + def test_http_manifest_with_utf8_bom(self): + def fake_urlopen(req, timeout=0): + return _FakeResponse('{"version": "2.0.0"}', raw=True) + + with patch("services.update_service.request.urlopen", fake_urlopen): + info = check_for_update("https://example.test", "1.0.0") + self.assertIsInstance(info, UpdateInfo) + self.assertEqual(info.version, "2.0.0") + class _FakeResponse: def __init__(self, data, raw=False):