feat: non-blocking updates — download in app, apply at launch

Startup no longer blocks on a download. New split:
- services/installer.py: download_and_stage() (runs while the app is open →
  staging\app.new, verified) and apply_staged() (launcher swaps it into app\
  at next launch, when the exe isn't locked).
- launcher.py: slimmed to seed + apply_staged + launch; no network at startup.
- main_window: a new version lights a dot on the ⚙ 配置 button instead of a
  blocking banner; the old open-folder banner is removed.
- settings_dialog: "检查并更新" downloads + stages, then "下次启动生效".

Verified end-to-end over a local HTTP server (download→stage→apply); 101 tests
pass (test_installer +11, test_launcher rewritten).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 11:58:06 +08:00
co-authored by Claude Opus 4.8
parent 30814b6bd6
commit 5a1a004daf
6 changed files with 468 additions and 373 deletions
+130
View File
@@ -0,0 +1,130 @@
"""Tests for services.installer — no GUI/network (download is mocked)."""
import hashlib
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"))
from services import installer
from services.update_service import UpdateInfo
def _make_release_zip(dst_zip, version, exe_body, wrap=False):
tmp = Path(tempfile.mkdtemp())
try:
base = tmp / ("CMBot-" + version) if wrap else tmp
base.mkdir(parents=True, exist_ok=True)
(base / "CMBot.exe").write_text(exe_body, encoding="ascii")
(base / "version.txt").write_text(version, encoding="ascii")
with zipfile.ZipFile(str(dst_zip), "w", zipfile.ZIP_DEFLATED) as z:
for p in base.rglob("*"):
z.write(str(p), str(p.relative_to(tmp)))
finally:
shutil.rmtree(str(tmp), ignore_errors=True)
return hashlib.sha256(dst_zip.read_bytes()).hexdigest()
class _Base(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
self.root = self.tmp / "install"
self.app = self.root / "app"
self.app.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")
def tearDown(self):
shutil.rmtree(str(self.tmp), ignore_errors=True)
def _info(self, version, zip_path):
return UpdateInfo(version=version, source="http://x/{}.zip".format(version),
sha256=hashlib.sha256(zip_path.read_bytes()).hexdigest(),
size=zip_path.stat().st_size)
class TestDownloadAndStage(_Base):
def _copy_download(self, src_zip):
def fake(url, dest, user="", password="", timeout=120):
shutil.copy(str(src_zip), str(dest))
return fake
def test_stage_flat_zip(self):
z = self.tmp / "n.zip"
_make_release_zip(z, "1.1.0", "NEW")
with patch("services.installer.download", side_effect=self._copy_download(z)):
ver = installer.download_and_stage(self.root, self._info("1.1.0", z))
self.assertEqual(ver, "1.1.0")
self.assertEqual(installer.staged_version(self.root), "1.1.0")
self.assertTrue((installer.staged_app(self.root) / "CMBot.exe").exists())
# app untouched until apply
self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.0.0")
def test_stage_wrapped_zip(self):
z = self.tmp / "n.zip"
_make_release_zip(z, "1.1.0", "NEW", wrap=True)
with patch("services.installer.download", side_effect=self._copy_download(z)):
ver = installer.download_and_stage(self.root, self._info("1.1.0", z))
self.assertEqual(ver, "1.1.0")
self.assertEqual(installer.staged_version(self.root), "1.1.0")
def test_sha_mismatch_raises(self):
z = self.tmp / "n.zip"
_make_release_zip(z, "1.1.0", "NEW")
info = self._info("1.1.0", z)
info.sha256 = "deadbeef"
with patch("services.installer.download", side_effect=self._copy_download(z)):
with self.assertRaises(ValueError):
installer.download_and_stage(self.root, info)
self.assertEqual(installer.staged_version(self.root), "")
def test_version_mismatch_raises(self):
z = self.tmp / "n.zip"
_make_release_zip(z, "1.1.0", "NEW")
info = self._info("9.9.9", z) # manifest claims a different version
with patch("services.installer.download", side_effect=self._copy_download(z)):
with self.assertRaises(ValueError):
installer.download_and_stage(self.root, info)
class TestApplyStaged(_Base):
def _stage(self, version, body):
s = installer.staged_app(self.root)
s.mkdir(parents=True, exist_ok=True)
(s / "CMBot.exe").write_text(body, encoding="ascii")
(s / "version.txt").write_text(version, encoding="ascii")
def test_apply_swaps(self):
self._stage("1.1.0", "NEW-1.1.0")
applied = installer.apply_staged(self.root)
self.assertEqual(applied, "1.1.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((installer.old_dir(self.root) / "version.txt").read_text(encoding="ascii"), "1.0.0")
self.assertEqual(installer.staged_version(self.root), "")
def test_nothing_staged_returns_empty(self):
self.assertEqual(installer.apply_staged(self.root), "")
self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.0.0")
def test_not_newer_staged_is_discarded(self):
self._stage("0.9.0", "OLDER")
self.assertEqual(installer.apply_staged(self.root), "")
self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.0.0")
self.assertFalse(installer.staged_app(self.root).exists())
class TestHelpers(_Base):
def test_is_writable(self):
self.assertTrue(installer.is_writable(self.root))
def test_install_root_for(self):
self.assertEqual(installer.install_root_for(self.app), self.root.resolve())
if __name__ == "__main__":
unittest.main()
+34 -101
View File
@@ -1,153 +1,86 @@
"""Tests for launcher — no GUI/network dependency (download is mocked)."""
import hashlib
"""Tests for the launcher — no GUI/network. The launcher only seeds config and
applies a pre-staged update (downloading lives in services.installer/the app)."""
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
from services import installer
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):
class _Base(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
self.install = self.tmp / "install"
self.app = self.install / "app"
self.root = self.tmp / "install"
self.app = self.root / "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")
json.dumps({"update_source": "http://x"}), 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")
self._prev = os.environ.get("CMBOT_DATA_DIR")
os.environ["CMBOT_DATA_DIR"] = str(self.data)
def tearDown(self):
if self._prev_env is None:
if self._prev is None:
os.environ.pop("CMBOT_DATA_DIR", None)
else:
os.environ["CMBOT_DATA_DIR"] = self._prev_env
os.environ["CMBOT_DATA_DIR"] = self._prev
shutil.rmtree(str(self.tmp), ignore_errors=True)
def _stage(self, version, body):
s = installer.staged_app(self.root)
s.mkdir(parents=True, exist_ok=True)
(s / "CMBot.exe").write_text(body, encoding="ascii")
(s / "version.txt").write_text(version, encoding="ascii")
class TestHelpers(_LauncherBase):
def test_seed_defaults_copies_when_missing(self):
class TestSeed(_Base):
def test_seed_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):
def test_seed_does_not_overwrite(self):
(self.data / "config").mkdir(parents=True)
(self.data / "config" / "app_config.json").write_text(
'{"update_source":"USER"}', encoding="utf-8")
(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)
class TestRun(_Base):
def test_applies_staged_update_then_launches(self):
self._stage("1.1.0", "NEW-1.1.0")
rc = launcher.run(self.root, 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())
self.assertEqual((installer.old_dir(self.root) / "version.txt").read_text(encoding="ascii"), "1.0.0")
def test_no_update_keeps_local(self):
with patch("launcher.check_for_update", return_value=None):
rc = launcher.run(self.install, no_launch=True)
def test_no_staged_just_launches(self):
rc = launcher.run(self.root, 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())
self.assertFalse(installer.old_dir(self.root).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)
launcher.run(self.root, no_launch=True)
self.assertTrue((self.data / "config" / "app_config.json").exists())
def test_missing_exe_returns_error(self):
(self.app / "CMBot.exe").unlink()
rc = launcher.run(self.root, no_launch=True)
self.assertEqual(rc, 1)
if __name__ == "__main__":
unittest.main()