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:
@@ -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
|
||||
Reference in New Issue
Block a user