feat: src/launcher.py — Python update launcher (HTTP, app/app.old swap)

Launcher core that reuses services/update_service: seed default config into
~/.cmbot on first run, check the manifest, download the release zip, verify
SHA-256, extract, validate version, swap into app/ (keeping app.old/ for
rollback), then launch app/CMBot.exe. Degrades to the local version on any
failure; checks install-root writability before updating.

- update_service: UpdateInfo gains sha256/size/min_supported; release URL
  resolved to absolute; extract make_auth_header + add download() helper
- tests/test_launcher.py: 11 tests (seed, find-root, swap, sha mismatch,
  no-update); also verified end-to-end against a local HTTP server

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 10:49:45 +08:00
co-authored by Claude Opus 4.8
parent 3d01bed98c
commit 1d8e4fc240
4 changed files with 421 additions and 11 deletions
+153
View File
@@ -0,0 +1,153 @@
"""Tests for launcher — no GUI/network dependency (download is mocked)."""
import hashlib
import json
import os
import shutil
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import launcher
from services.update_service import UpdateInfo
def _make_release_zip(dst_zip, version, exe_body):
"""Build a flat release zip (CMBot.exe + version.txt) and return its sha256."""
tmp = Path(tempfile.mkdtemp())
try:
(tmp / "CMBot.exe").write_text(exe_body, encoding="ascii")
(tmp / "version.txt").write_text(version, encoding="ascii")
with zipfile.ZipFile(str(dst_zip), "w", zipfile.ZIP_DEFLATED) as z:
z.write(str(tmp / "CMBot.exe"), "CMBot.exe")
z.write(str(tmp / "version.txt"), "version.txt")
finally:
shutil.rmtree(str(tmp), ignore_errors=True)
return hashlib.sha256(dst_zip.read_bytes()).hexdigest()
class _LauncherBase(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
self.install = self.tmp / "install"
self.app = self.install / "app"
(self.app / "config").mkdir(parents=True)
(self.app / "CMBot.exe").write_text("OLD-1.0.0", encoding="ascii")
(self.app / "version.txt").write_text("1.0.0", encoding="ascii")
# factory defaults shipped inside app\config
(self.app / "config" / "app_config.json").write_text(
json.dumps({"update_source": "http://x/manifest.json", "last_template": "正方形"}),
encoding="utf-8")
(self.app / "config" / "templates.json").write_text("{}", encoding="utf-8")
# data root -> a temp dir via the env override
self.data = self.tmp / "data"
self._prev_env = os.environ.get("CMBOT_DATA_DIR")
os.environ["CMBOT_DATA_DIR"] = str(self.data)
def tearDown(self):
if self._prev_env is None:
os.environ.pop("CMBOT_DATA_DIR", None)
else:
os.environ["CMBOT_DATA_DIR"] = self._prev_env
shutil.rmtree(str(self.tmp), ignore_errors=True)
class TestHelpers(_LauncherBase):
def test_seed_defaults_copies_when_missing(self):
launcher.seed_defaults(self.app, self.data)
self.assertTrue((self.data / "config" / "app_config.json").exists())
self.assertTrue((self.data / "config" / "templates.json").exists())
def test_seed_defaults_does_not_overwrite(self):
(self.data / "config").mkdir(parents=True)
(self.data / "config" / "app_config.json").write_text(
'{"update_source":"USER"}', encoding="utf-8")
launcher.seed_defaults(self.app, self.data)
kept = json.loads((self.data / "config" / "app_config.json").read_text(encoding="utf-8"))
self.assertEqual(kept["update_source"], "USER")
def test_is_writable(self):
self.assertTrue(launcher.is_writable(self.install))
def test_find_package_root_flat(self):
d = self.tmp / "flat"
d.mkdir()
(d / "CMBot.exe").write_text("x", encoding="ascii")
self.assertEqual(launcher._find_package_root(d), d)
def test_find_package_root_wrapped(self):
d = self.tmp / "wrap"
(d / "CMBot-1.1.0").mkdir(parents=True)
(d / "CMBot-1.1.0" / "CMBot.exe").write_text("x", encoding="ascii")
self.assertEqual(launcher._find_package_root(d), d / "CMBot-1.1.0")
def test_find_package_root_missing(self):
d = self.tmp / "empty"
d.mkdir()
self.assertIsNone(launcher._find_package_root(d))
def test_read_version_tolerates_bom(self):
(self.app / "version.txt").write_text("1.0.0", encoding="utf-8-sig")
self.assertEqual(launcher._read_version(self.app), "1.0.0")
class TestRun(_LauncherBase):
def _info(self, version, zip_path):
sha = hashlib.sha256(zip_path.read_bytes()).hexdigest()
return UpdateInfo(version=version, source="http://x/{}.zip".format(version),
sha256=sha, size=zip_path.stat().st_size)
def test_update_applied_and_swapped(self):
new_zip = self.tmp / "new.zip"
_make_release_zip(new_zip, "1.1.0", "NEW-1.1.0")
info = self._info("1.1.0", new_zip)
def fake_download(url, dest, user="", password="", timeout=120):
shutil.copy(str(new_zip), str(dest))
with patch("launcher.check_for_update", return_value=info), \
patch("launcher.download", side_effect=fake_download):
rc = launcher.run(self.install, no_launch=True)
self.assertEqual(rc, 0)
self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.1.0")
self.assertEqual((self.app / "CMBot.exe").read_text(encoding="ascii"), "NEW-1.1.0")
self.assertEqual(((self.install / "app.old") / "version.txt").read_text(encoding="ascii"), "1.0.0")
self.assertFalse((self.install / "staging" / "1.1.0.zip").exists())
def test_no_update_keeps_local(self):
with patch("launcher.check_for_update", return_value=None):
rc = launcher.run(self.install, no_launch=True)
self.assertEqual(rc, 0)
self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.0.0")
self.assertFalse((self.install / "app.old").exists())
def test_sha256_mismatch_degrades(self):
new_zip = self.tmp / "new.zip"
_make_release_zip(new_zip, "1.1.0", "NEW")
info = self._info("1.1.0", new_zip)
info.sha256 = "deadbeef" # force mismatch
def fake_download(url, dest, user="", password="", timeout=120):
shutil.copy(str(new_zip), str(dest))
with patch("launcher.check_for_update", return_value=info), \
patch("launcher.download", side_effect=fake_download):
rc = launcher.run(self.install, no_launch=True)
self.assertEqual(rc, 0)
self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.0.0")
self.assertFalse((self.install / "app.old").exists())
def test_seeds_config_into_data_root(self):
with patch("launcher.check_for_update", return_value=None):
launcher.run(self.install, no_launch=True)
self.assertTrue((self.data / "config" / "app_config.json").exists())
if __name__ == "__main__":
unittest.main()