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:
+32
-158
@@ -1,54 +1,36 @@
|
||||
"""CMBot update launcher (docs/10-lan-update.md stage 3/4).
|
||||
"""CMBot launcher (docs/10-lan-update.md).
|
||||
|
||||
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.
|
||||
2. applies a previously staged update if one is ready (fast local swap of
|
||||
staging\\app.new into app\\, keeping app.old\\ for rollback);
|
||||
3. 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.
|
||||
It does NOT download anything — downloading happens inside the app (manual
|
||||
"更新" button / background), so startup is never blocked on the network. The
|
||||
running program's data lives in ~/.cmbot (get_data_dir), untouched by updates.
|
||||
"""
|
||||
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
|
||||
from services import installer # noqa: E402
|
||||
from services.file_service import get_data_dir # 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."""
|
||||
@@ -64,128 +46,20 @@ def seed_defaults(app_dir, data_dir):
|
||||
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."""
|
||||
"""Seed config, apply any staged 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.")
|
||||
try:
|
||||
applied = installer.apply_staged(install_root)
|
||||
if applied:
|
||||
logger.info("Applied staged update v%s", applied)
|
||||
except OSError as exc:
|
||||
logger.warning("Apply staged update failed (using current version): %s", exc)
|
||||
|
||||
exe = app_dir / APP_EXE
|
||||
if not exe.exists():
|
||||
@@ -205,25 +79,12 @@ def _default_install_root():
|
||||
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()
|
||||
_setup_logging(root)
|
||||
return run(root, no_launch=args.no_launch)
|
||||
|
||||
|
||||
def _setup_logging(root):
|
||||
"""Configure logging defensively for both console and --windowed builds.
|
||||
|
||||
A PyInstaller --windowed exe has no stdout/stderr (StreamHandler(None) would
|
||||
fail), and a read-only install root makes the file handler fail. Add each
|
||||
handler only when it can be created so the launcher never crashes on logging.
|
||||
A PyInstaller --windowed exe has no stdout/stderr; a read-only install root
|
||||
makes the file handler fail. Add each handler only when it can be created so
|
||||
the launcher never crashes on logging.
|
||||
"""
|
||||
handlers = []
|
||||
if sys.stderr is not None:
|
||||
@@ -236,5 +97,18 @@ def _setup_logging(root):
|
||||
handlers=handlers)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description="CMBot 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="apply staged 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()
|
||||
_setup_logging(root)
|
||||
return run(root, no_launch=args.no_launch)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
Reference in New Issue
Block a user