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
+186
View File
@@ -0,0 +1,186 @@
"""Stage and apply portable-layout updates (docs/10-lan-update.md).
Split into two phases because Windows locks a running .exe and its folder:
- download_and_stage(): runs WHILE the app is open — downloads the release zip,
verifies SHA-256, extracts it to staging\\app.new. No swap (app\\ is in use).
- apply_staged(): runs at launch time from the launcher, when the app is NOT
running — swaps staging\\app.new into app\\, keeping the old one as app.old\\.
All paths are relative to the install root (the folder that holds app\\ and
Launcher.exe).
"""
import hashlib
import logging
import shutil
import zipfile
from pathlib import Path
from services.update_service import download, is_newer
logger = logging.getLogger(__name__)
APP_EXE = "CMBot.exe"
VERSION_FILE = "version.txt"
# ── layout helpers ───────────────────────────────────────────────────────────
def app_dir(install_root):
return Path(install_root) / "app"
def old_dir(install_root):
return Path(install_root) / "app.old"
def staging_dir(install_root):
return Path(install_root) / "staging"
def staged_app(install_root):
"""staging\\app.new — a downloaded+verified package waiting to be applied."""
return staging_dir(install_root) / "app.new"
def install_root_for(app_path):
"""Install root given the running app\\ directory (its parent)."""
return Path(app_path).resolve().parent
def read_version(folder):
"""version.txt inside *folder*, or '' if absent (utf-8-sig tolerates BOM)."""
try:
return (Path(folder) / VERSION_FILE).read_text(encoding="utf-8-sig").strip()
except OSError:
return ""
def is_writable(path):
"""True if a file can be created under *path* (self-update needs this)."""
path = Path(path)
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
# ── internal ─────────────────────────────────────────────────────────────────
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):
path = Path(path)
if path.is_dir():
shutil.rmtree(str(path), ignore_errors=True)
elif path.exists():
path.unlink()
def _find_package_root(extract_dir):
"""Folder holding CMBot.exe — flat zip (root) or wrapped in one subfolder."""
extract_dir = Path(extract_dir)
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
# ── phase 1: download + stage (runs while the app is open) ───────────────────
def download_and_stage(install_root, info, update_user="", update_pass=""):
"""Download, verify and extract *info* into staging\\app.new. Does NOT swap.
Returns the staged version string. Raises (OSError/ValueError) on any failure
(not writable, download error, size/hash mismatch, bad package).
"""
install_root = Path(install_root)
if not is_writable(install_root):
raise OSError("安装目录不可写:{}".format(install_root))
st = staging_dir(install_root)
st.mkdir(parents=True, exist_ok=True)
zip_path = st / "{}.zip".format(info.version)
extract_tmp = st / "extract.tmp"
target = staged_app(install_root)
_rm(zip_path)
_rm(extract_tmp)
_rm(target)
logger.info("Downloading v%s from %s", info.version, info.source)
download(info.source, zip_path, update_user, update_pass)
if info.size and zip_path.stat().st_size != info.size:
raise ValueError("下载大小不符:得到 {},期望 {}".format(
zip_path.stat().st_size, info.size))
if info.sha256 and _sha256(zip_path).lower() != info.sha256.lower():
raise ValueError("SHA-256 校验不通过")
with zipfile.ZipFile(str(zip_path)) as z:
z.extractall(str(extract_tmp))
root = _find_package_root(extract_tmp)
if not root:
raise ValueError("更新包缺少 {}".format(APP_EXE))
pkg_ver = read_version(root)
if pkg_ver != info.version:
raise ValueError("包内版本 {} 与清单 {} 不一致".format(pkg_ver, info.version))
root.rename(target)
_rm(extract_tmp)
_rm(zip_path)
logger.info("Staged update v%s at %s", info.version, target)
return info.version
def staged_version(install_root):
"""Version ready in staging\\app.new, or '' if none/invalid."""
src = staged_app(install_root)
return read_version(src) if (src / APP_EXE).exists() else ""
# ── phase 2: apply (runs at launch, app not running) ─────────────────────────
def apply_staged(install_root):
"""Swap a ready staging\\app.new into app\\ (keeping app.old\\). Returns the
applied version, or '' if nothing valid/newer was staged.
Never overwrites a running app\\: if app\\CMBot.exe is locked the rename
raises and the caller degrades to the current version.
"""
install_root = Path(install_root)
src = staged_app(install_root)
if not (src / APP_EXE).exists():
return ""
ver = read_version(src)
cur = read_version(app_dir(install_root))
if cur and ver and not is_newer(ver, cur):
_rm(src) # stale/not newer — discard
return ""
a = app_dir(install_root)
o = old_dir(install_root)
if o.exists():
shutil.rmtree(str(o))
if a.exists():
a.rename(o)
try:
src.rename(a)
except OSError:
if not a.exists() and o.exists():
o.rename(a) # restore previous version
raise
logger.info("Applied staged update v%s", ver)
return ver