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
+229
View File
@@ -0,0 +1,229 @@
"""CMBot update launcher (docs/10-lan-update.md stage 3/4).
Compiled to Launcher.exe (PyInstaller onefile) and run instead of launching
app/CMBot.exe directly. On each start it:
1. seeds default config/templates into ~/.cmbot on first run;
2. reads update_source / credentials from ~/.cmbot/config/app_config.json;
3. checks the manifest and, when a newer version is advertised, downloads the
release zip, verifies SHA-256, extracts it, and swaps it into app\\ while
keeping the previous version in app.old\\ for rollback;
4. launches app\\CMBot.exe.
Degrades safely: an unreachable source, a bad download, or a non-writable
install root leaves the existing app\\ in place and launches it. The running
program's data lives in ~/.cmbot (get_data_dir), so updates never touch it.
"""
import argparse
import hashlib
import logging
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path
from urllib import error
# Make 'services' importable both frozen (PyInstaller --paths src) and from source.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from services.update_service import check_for_update, download # noqa: E402
from services.file_service import get_data_dir # noqa: E402
from services.config_service import load_config # noqa: E402
APP_EXE = "CMBot.exe"
VERSION_FILE = "version.txt"
CONFIG_FILES = ("app_config.json", "templates.json")
logger = logging.getLogger("launcher")
# ── helpers ──────────────────────────────────────────────────────────────────
def _read_version(app_dir):
"""Return the version recorded in app/version.txt, or '' if absent."""
try:
# utf-8-sig tolerates a BOM written by PowerShell.
return (app_dir / VERSION_FILE).read_text(encoding="utf-8-sig").strip()
except OSError:
return ""
def seed_defaults(app_dir, data_dir):
"""First run: copy factory config/templates from app\\config into the data
root, without overwriting any existing user file."""
src = app_dir / "config"
if not src.is_dir():
return
dst = data_dir / "config"
dst.mkdir(parents=True, exist_ok=True)
for name in CONFIG_FILES:
s, d = src / name, dst / name
if s.exists() and not d.exists():
shutil.copy2(str(s), str(d))
logger.info("Seeded default %s into %s", name, dst)
def is_writable(path):
"""True if a file can be created under *path* (so self-update can proceed)."""
try:
path.mkdir(parents=True, exist_ok=True)
probe = path / ".write_test"
probe.write_text("x", encoding="ascii")
probe.unlink()
return True
except OSError:
return False
def _sha256(path):
h = hashlib.sha256()
with open(str(path), "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def _rm(path):
if path.is_dir():
shutil.rmtree(str(path), ignore_errors=True)
elif path.exists():
path.unlink()
def _find_package_root(extract_dir):
"""Locate the folder that holds CMBot.exe inside an extracted zip.
Handles both a flat zip (exe at root) and one wrapped in a single folder.
"""
if (extract_dir / APP_EXE).exists():
return extract_dir
subs = [p for p in extract_dir.iterdir() if p.is_dir()]
if len(subs) == 1 and (subs[0] / APP_EXE).exists():
return subs[0]
return None
def _swap_in(app_dir, old_dir, package_root):
"""Rename app->app.old, then package_root->app. Roll back on failure.
Never overwrites a running app dir: if app\\CMBot.exe is locked (running),
the rename raises and the caller degrades to the existing version.
"""
if old_dir.exists():
shutil.rmtree(str(old_dir))
if app_dir.exists():
app_dir.rename(old_dir)
try:
package_root.rename(app_dir)
except OSError:
if not app_dir.exists() and old_dir.exists():
old_dir.rename(app_dir) # restore previous version
raise
# ── update flow ──────────────────────────────────────────────────────────────
def _try_update(install_root, app_dir, source, user, password, local_version):
info = check_for_update(source, local_version, user, password)
if info is None:
return # no update / unreachable / not newer — already logged
if not is_writable(install_root):
logger.warning("Install root not writable, skipping update: %s", install_root)
return
staging = install_root / "staging"
staging.mkdir(parents=True, exist_ok=True)
zip_path = staging / "{}.zip".format(info.version)
extract_dir = staging / "app.new"
_rm(zip_path)
_rm(extract_dir)
logger.info("Downloading v%s from %s", info.version, info.source)
download(info.source, zip_path, user, password)
if info.size and zip_path.stat().st_size != info.size:
raise ValueError("download size mismatch: got {}, expected {}".format(
zip_path.stat().st_size, info.size))
if info.sha256 and _sha256(zip_path).lower() != info.sha256.lower():
raise ValueError("sha256 mismatch")
with zipfile.ZipFile(str(zip_path)) as z:
z.extractall(str(extract_dir))
package_root = _find_package_root(extract_dir)
if not package_root:
raise ValueError("downloaded package does not contain {}".format(APP_EXE))
pkg_version = _read_version(package_root)
if pkg_version != info.version:
raise ValueError("package version '{}' != manifest '{}'".format(
pkg_version, info.version))
_swap_in(app_dir, install_root / "app.old", package_root)
_rm(zip_path)
_rm(extract_dir)
logger.info("Installed and switched to v%s", info.version)
def run(install_root, no_launch=False):
"""Seed config, attempt an update, then launch the app. Returns exit code."""
install_root = Path(install_root)
app_dir = install_root / "app"
data_dir = get_data_dir()
seed_defaults(app_dir, data_dir)
cfg = load_config()
source = cfg.get("update_source", "")
user = cfg.get("update_user", "")
password = cfg.get("update_pass", "")
local_version = _read_version(app_dir)
if source:
try:
_try_update(install_root, app_dir, source, user, password, local_version)
except (OSError, ValueError, error.URLError) as exc:
logger.warning("Update skipped (using local version): %s", exc)
else:
logger.info("No update_source configured, skipping update check.")
exe = app_dir / APP_EXE
if not exe.exists():
logger.error("Executable not found: %s", exe)
return 1
if no_launch:
logger.info("NoLaunch: would start %s (data=%s)", exe, data_dir)
return 0
logger.info("Launching %s", exe)
subprocess.Popen([str(exe)], cwd=str(exe.parent))
return 0
def _default_install_root():
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path.cwd()
def main(argv=None):
parser = argparse.ArgumentParser(description="CMBot update launcher")
parser.add_argument("--install-root", default=None,
help="install root (default: Launcher.exe's folder)")
parser.add_argument("--no-launch", action="store_true",
help="update only, do not start the app")
args = parser.parse_args(argv)
root = Path(args.install_root) if args.install_root else _default_install_root()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler(str(root / "launcher.log"), encoding="utf-8"),
],
)
return run(root, no_launch=args.no_launch)
if __name__ == "__main__":
sys.exit(main())
+38 -10
View File
@@ -26,9 +26,12 @@ MANIFEST_NAME = "manifest.json"
class UpdateInfo: class UpdateInfo:
"""A newer version advertised by the update source.""" """A newer version advertised by the update source."""
version: str version: str
source: str # folder or URL the user opens to get the new version source: str # absolute zip URL (http) or folder/path to the new version
notes: str = "" notes: str = ""
mandatory: bool = False mandatory: bool = False
sha256: str = "" # expected hash of the release zip (launcher verifies)
size: int = 0 # release zip size in bytes (0 = unknown)
min_supported: str = ""
def parse_version(text) -> tuple: def parse_version(text) -> tuple:
@@ -70,17 +73,35 @@ def _manifest_url(update_source):
return parse.urljoin(source, MANIFEST_NAME) return parse.urljoin(source, MANIFEST_NAME)
def make_auth_header(update_user="", update_pass=""):
"""Return an HTTP Basic Auth header dict, or {} when no credentials."""
if not (update_user or update_pass):
return {}
raw = "{}:{}".format(update_user or "", update_pass or "").encode("utf-8")
return {"Authorization": "Basic {}".format(base64.b64encode(raw).decode("ascii"))}
def download(url, dest, update_user="", update_pass="", timeout=120):
"""Download *url* to *dest* (Path/str) with optional Basic Auth.
Streams in chunks so large release zips don't load fully into memory.
Raises (OSError / URLError) on failure — callers handle degradation.
"""
headers = {"User-Agent": "CMBot"}
headers.update(make_auth_header(update_user, update_pass))
req = request.Request(url, headers=headers)
with request.urlopen(req, timeout=timeout) as response, open(str(dest), "wb") as f:
while True:
chunk = response.read(65536)
if not chunk:
break
f.write(chunk)
def _load_http_manifest(update_source, update_user="", update_pass=""): def _load_http_manifest(update_source, update_user="", update_pass=""):
url = _manifest_url(update_source) url = _manifest_url(update_source)
headers = { headers = {"Accept": "application/json", "User-Agent": "CMBot"}
"Accept": "application/json", headers.update(make_auth_header(update_user, update_pass))
"User-Agent": "CMBot",
}
if update_user or update_pass:
raw = "{}:{}".format(update_user or "", update_pass or "").encode("utf-8")
headers["Authorization"] = "Basic {}".format(
base64.b64encode(raw).decode("ascii")
)
req = request.Request(url, headers=headers) req = request.Request(url, headers=headers)
with request.urlopen(req, timeout=5) as response: with request.urlopen(req, timeout=5) as response:
@@ -136,11 +157,18 @@ def check_for_update(
source = str(data.get("url", "") or data.get("source", "")).strip() source = str(data.get("url", "") or data.get("source", "")).strip()
if not source: if not source:
source = str(update_source) source = str(update_source)
elif _is_http_source(update_source):
# Resolve a relative release URL against the manifest URL so callers
# (in-app banner / launcher) always get a directly usable absolute URL.
source = parse.urljoin(_manifest_url(update_source), source)
info = UpdateInfo( info = UpdateInfo(
version=version, version=version,
source=source, source=source,
notes=str(data.get("notes", "")), notes=str(data.get("notes", "")),
mandatory=bool(data.get("mandatory", False)), mandatory=bool(data.get("mandatory", False)),
sha256=str(data.get("sha256", "")).strip(),
size=int(data.get("size", 0) or 0),
min_supported=str(data.get("min_supported", "")).strip(),
) )
logger.info("Update available: v%s (current v%s)", version, current_version) logger.info("Update available: v%s (current v%s)", version, current_version)
return info return info
+1 -1
View File
@@ -933,7 +933,7 @@
- [x] 文档:`docs/10` 改为便携 + `Launcher.exe` + `~/.cmbot` 模型(§3/§4/§5/§8/§9/§11/§16) - [x] 文档:`docs/10` 改为便携 + `Launcher.exe` + `~/.cmbot` 模型(§3/§4/§5/§8/§9/§11/§16)
- [x] `get_data_dir()` 三级回退:`CMBOT_DATA_DIR` → 打包态 `~/.cmbot` → 开发态项目根;`tests/test_file_service.py` 5 个单测 - [x] `get_data_dir()` 三级回退:`CMBOT_DATA_DIR` → 打包态 `~/.cmbot` → 开发态项目根;`tests/test_file_service.py` 5 个单测
- [ ] `src/launcher.py`:复用 `update_service`,下载 zip→SHA-256→解压→`app/app.old` 切换→启动;安装根可写性检测;首次把 `app\config\` 默认模板播种到 `~/.cmbot` - [x] `src/launcher.py`:复用 `update_service`,下载 zip→SHA-256→解压→`app/app.old` 切换→启动;安装根可写性检测;首次把 `app\config\` 默认模板播种到 `~/.cmbot`;`update_service` 扩展(`UpdateInfo.sha256/size/min_supported`、绝对 url 解析、`make_auth_header`/`download`);`tests/test_launcher.py` 11 个单测 + 本地 HTTP server 真实端到端验证
- [ ] `build.ps1` 增产 `Launcher.exe`(PyInstaller onefile),发布 zip 含 `Launcher.exe` + `app\` - [ ] `build.ps1` 增产 `Launcher.exe`(PyInstaller onefile),发布 zip 含 `Launcher.exe` + `app\`
- [ ] 退休 `scripts/update.ps1` 与 `scripts/install_local.ps1` - [ ] 退休 `scripts/update.ps1` 与 `scripts/install_local.ps1`
- [ ] 端到端实测(解压到 D 盘运行、自更新、回滚) - [ ] 端到端实测(解压到 D 盘运行、自更新、回滚)
+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()