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
+22 -109
View File
@@ -71,8 +71,10 @@ class MainWindow(QMainWindow):
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0) layout.setSpacing(0)
# Update notification banner (hidden until a newer version is found). # Update state: when a newer version is found the gear button shows a
layout.addWidget(self._create_update_banner()) # dot; the user downloads it from the settings dialog (no startup block).
self._update_info = None
self._update_found.connect(self._on_update_found)
# No in-app title bar: the OS window title (setWindowTitle) already # No in-app title bar: the OS window title (setWindowTitle) already
# shows the app name and version, so an in-content header would just # shows the app name and version, so an in-content header would just
@@ -120,13 +122,14 @@ class MainWindow(QMainWindow):
return container return container
def _open_settings(self): def _open_settings(self):
"""Open the settings dialog; persist + re-check on save (docs/07 §4.4).""" """Open the settings dialog (update config + manual update). Persist on save."""
dlg = SettingsDialog( dlg = SettingsDialog(
self, self,
update_source=self._config.get("update_source", ""), update_source=self._config.get("update_source", ""),
update_user=self._config.get("update_user", ""), update_user=self._config.get("update_user", ""),
update_pass=self._config.get("update_pass", ""), update_pass=self._config.get("update_pass", ""),
current_version=APP_VERSION, current_version=APP_VERSION,
update_info=self._update_info,
) )
if dlg.exec() == QDialog.Accepted: if dlg.exec() == QDialog.Accepted:
self._config.update(dlg.values()) self._config.update(dlg.values())
@@ -134,54 +137,20 @@ class MainWindow(QMainWindow):
self._refresh_update_check() self._refresh_update_check()
def _refresh_update_check(self): def _refresh_update_check(self):
"""Re-run the update check after the source/credentials changed.""" """Clear the indicator and re-check after the source/credentials changed."""
self._update_banner.setVisible(False) self._update_info = None
self._set_update_indicator(False)
self._start_update_check() self._start_update_check()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Update notification (stage 2: notify-only, see docs/10-lan-update.md) # Update availability (download happens in the settings dialog)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _create_update_banner(self):
"""A thin info bar shown when a newer version is found. Hidden by default."""
self._update_info = None
self._update_found.connect(self._on_update_found) # connect once
bar = QWidget()
bar.setObjectName("updateBanner")
bar.setVisible(False)
row = QHBoxLayout(bar)
row.setContentsMargins(12, 5, 8, 5)
row.setSpacing(8)
self._update_label = QLabel()
self._update_label.setObjectName("updateBannerText")
open_btn = QPushButton("打开更新目录")
open_btn.setObjectName("updateBannerOpen")
open_btn.setCursor(Qt.PointingHandCursor)
open_btn.clicked.connect(self._open_update_source)
close_btn = QPushButton("✕")
close_btn.setObjectName("updateBannerClose")
close_btn.setFixedWidth(24)
close_btn.setCursor(Qt.PointingHandCursor)
close_btn.setToolTip("关闭")
close_btn.clicked.connect(lambda: self._update_banner.setVisible(False))
row.addWidget(self._update_label)
row.addStretch()
row.addWidget(open_btn)
row.addWidget(close_btn)
self._update_banner = bar
return bar
def _start_update_check(self): def _start_update_check(self):
"""Check the configured update source for a newer version, off the UI thread. """Check the configured update source for a newer version, off the UI thread.
Safe to call again after the settings change; the result signal is Notify-only: a new version lights the gear button's dot; the user
connected once in _create_update_banner, not here. downloads it from the settings dialog. Startup is never blocked.
""" """
source = self._config.get("update_source", "") source = self._config.get("update_source", "")
if not source: if not source:
@@ -201,47 +170,17 @@ class MainWindow(QMainWindow):
threading.Thread(target=worker, name="update-check", daemon=True).start() threading.Thread(target=worker, name="update-check", daemon=True).start()
def _on_update_found(self, info): def _on_update_found(self, info):
"""Show the update banner (runs on the UI thread via queued signal).""" """A newer version is available — mark the gear button (UI thread)."""
self._update_info = info self._update_info = info
text = "发现新版本 v{},当前 v{}。".format(info.version, APP_VERSION) self._set_update_indicator(True, info.version)
if info.notes:
text += " " + info.notes
self._update_label.setText(text)
self._update_banner.setVisible(True)
def _open_update_source(self): def _set_update_indicator(self, available, version=""):
"""Open the update folder in the file explorer. self._settings_btn.setText("⚙ 配置 ●" if available else "⚙ 配置")
self._settings_btn.setProperty("hasUpdate", bool(available))
Prefer the version folder from the manifest; fall back to the configured self._settings_btn.setToolTip(
source root (which we know exists — the manifest was just read from it). "发现新版本 v{},点击进入设置更新".format(version) if available else "")
os.startfile is the reliable way to open a directory on Windows; self._settings_btn.style().unpolish(self._settings_btn)
QDesktopServices.openUrl mishandles file:// URLs to folders (ShellExecute self._settings_btn.style().polish(self._settings_btn)
error 2), so it is only a secondary fallback.
"""
if not self._update_info:
return
candidates = [
self._update_info.source,
self._config.get("update_source", ""),
]
for path in candidates:
if path and path.lower().startswith(("http://", "https://")):
if QDesktopServices.openUrl(QUrl(path)):
return
if not path or not Path(path).exists():
continue
try:
os.startfile(path) # noqa: S606 — native Explorer open
return
except (OSError, AttributeError) as exc:
logger.warning("startfile failed for %s: %s", path, exc)
if QDesktopServices.openUrl(QUrl.fromLocalFile(path)):
return
shown = self._update_info.source or self._config.get("update_source", "")
QMessageBox.information(
self, "更新目录",
"无法自动打开更新目录,请手动前往:\n{}".format(shown),
)
def _create_work_area(self): def _create_work_area(self):
"""Horizontal splitter: left material panel | canvas | right params.""" """Horizontal splitter: left material panel | canvas | right params."""
@@ -616,33 +555,6 @@ class MainWindow(QMainWindow):
background-color: #f0f0f0; background-color: #f0f0f0;
} }
/* Update notification banner */
#updateBanner {
background-color: #eef6ff;
border-bottom: 1px solid #cfe3fa;
}
#updateBannerText {
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
font-size: 12px;
color: #1b4f86;
}
#updateBannerOpen {
font-size: 12px;
padding: 3px 12px;
border: 1px solid #0078d4;
border-radius: 3px;
color: #0078d4;
background: transparent;
}
#updateBannerOpen:hover { background: #d8e9fb; }
#updateBannerClose {
font-size: 12px;
border: none;
color: #6a8bab;
background: transparent;
}
#updateBannerClose:hover { color: #1b4f86; }
/* Flow tab bar */ /* Flow tab bar */
#tabBarContainer { #tabBarContainer {
background-color: #f0f0f0; background-color: #f0f0f0;
@@ -677,6 +589,7 @@ class MainWindow(QMainWindow):
padding: 4px 14px; padding: 4px 14px;
} }
#settingsBtn:hover { color: #0078d4; background: #e8f0fb; } #settingsBtn:hover { color: #0078d4; background: #e8f0fb; }
#settingsBtn[hasUpdate="true"] { color: #c42b1c; font-weight: bold; }
/* Work splitter */ /* Work splitter */
QSplitter#workSplitter::handle { QSplitter#workSplitter::handle {
+64 -5
View File
@@ -20,24 +20,34 @@ from PySide6.QtWidgets import (
) )
from version import APP_VERSION from version import APP_VERSION
from services.update_service import is_newer, load_manifest from services.file_service import get_app_dir
from services import installer
from services.update_service import check_for_update, is_newer, load_manifest
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SettingsDialog(QDialog): class SettingsDialog(QDialog):
"""Edit update_source / update_user / update_pass with a connection test.""" """Edit update config; test the connection; download+stage an update.
_test_done = Signal(str) # result message, delivered to the UI thread Downloading happens here (app is running, writes to staging\\app.new); the
actual app/app.old swap is applied by the launcher on the next start, since
Windows locks the running exe (docs/10-lan-update.md).
"""
_test_done = Signal(str) # connection-test result message (UI thread)
_update_done = Signal(bool, str) # (ok, message) after download+stage
def __init__(self, parent=None, *, update_source="", update_user="", def __init__(self, parent=None, *, update_source="", update_user="",
update_pass="", current_version=APP_VERSION): update_pass="", current_version=APP_VERSION, update_info=None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("设置") self.setWindowTitle("设置")
self.setMinimumWidth(420) self.setMinimumWidth(420)
self._current_version = current_version self._current_version = current_version
self._update_info = update_info
self._setup_ui(update_source, update_user, update_pass) self._setup_ui(update_source, update_user, update_pass)
self._test_done.connect(self._on_test_done) self._test_done.connect(self._on_test_done)
self._update_done.connect(self._on_update_done)
# ── UI ─────────────────────────────────────────────────────────────────── # ── UI ───────────────────────────────────────────────────────────────────
@@ -83,7 +93,19 @@ class SettingsDialog(QDialog):
self._result.setWordWrap(True) self._result.setWordWrap(True)
col.addWidget(self._result) col.addWidget(self._result)
hint = QLabel("提示:建议使用只读账号;生产环境请走 HTTPS(凭据为明文存储)。") # ── update section ───────────────────────────────────────────────────
update_row = QHBoxLayout()
self._update_status = QLabel(self._update_status_text())
self._update_status.setObjectName("settingsUpdate")
self._update_status.setWordWrap(True)
update_row.addWidget(self._update_status, 1)
self._update_btn = QPushButton("检查并更新")
self._update_btn.clicked.connect(self._on_update)
update_row.addWidget(self._update_btn)
col.addLayout(update_row)
hint = QLabel("提示:建议使用只读账号;生产环境请走 HTTPS(凭据为明文存储)。"
"更新下载完成后,下次启动时自动生效。")
hint.setObjectName("settingsHint") hint.setObjectName("settingsHint")
hint.setWordWrap(True) hint.setWordWrap(True)
col.addWidget(hint) col.addWidget(hint)
@@ -144,3 +166,40 @@ class SettingsDialog(QDialog):
def _on_test_done(self, msg): def _on_test_done(self, msg):
self._result.setText(msg) self._result.setText(msg)
self._test_btn.setEnabled(True) self._test_btn.setEnabled(True)
# ── manual update: download + stage (swap applied by launcher next start) ──
def _update_status_text(self):
if self._update_info:
return "发现新版本 v{}(当前 v{})。".format(
self._update_info.version, self._current_version)
return "当前 v{}。".format(self._current_version)
def _on_update(self):
source = self._source_edit.text().strip()
if not source:
self._update_status.setText("请先填写更新地址。")
return
user, password = self._user_edit.text(), self._pass_edit.text()
install_root = installer.install_root_for(get_app_dir())
self._update_btn.setEnabled(False)
self._update_status.setText("正在下载…")
def worker():
try:
info = check_for_update(source, self._current_version, user, password)
if info is None:
self._update_done.emit(True, "已是最新版本(或更新源不可用)。")
return
installer.download_and_stage(install_root, info, user, password)
self._update_done.emit(
True, "已下载 v{},下次启动时自动更新。".format(info.version))
except Exception as exc:
logger.info("Update download failed: %s", exc)
self._update_done.emit(False, "更新失败:{}".format(exc))
threading.Thread(target=worker, name="settings-update", daemon=True).start()
def _on_update_done(self, ok, msg):
self._update_status.setText(msg)
self._update_btn.setEnabled(True)
+32 -158
View File
@@ -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 Compiled to Launcher.exe (PyInstaller onefile) and run instead of launching
app/CMBot.exe directly. On each start it: app/CMBot.exe directly. On each start it:
1. seeds default config/templates into ~/.cmbot on first run; 1. seeds default config/templates into ~/.cmbot on first run;
2. reads update_source / credentials from ~/.cmbot/config/app_config.json; 2. applies a previously staged update if one is ready (fast local swap of
3. checks the manifest and, when a newer version is advertised, downloads the staging\\app.new into app\\, keeping app.old\\ for rollback);
release zip, verifies SHA-256, extracts it, and swaps it into app\\ while 3. launches app\\CMBot.exe.
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 It does NOT download anything — downloading happens inside the app (manual
install root leaves the existing app\\ in place and launches it. The running "更新" button / background), so startup is never blocked on the network. The
program's data lives in ~/.cmbot (get_data_dir), so updates never touch it. running program's data lives in ~/.cmbot (get_data_dir), untouched by updates.
""" """
import argparse import argparse
import hashlib
import logging import logging
import shutil import shutil
import subprocess import subprocess
import sys import sys
import zipfile
from pathlib import Path from pathlib import Path
from urllib import error
# Make 'services' importable both frozen (PyInstaller --paths src) and from source. # Make 'services' importable both frozen (PyInstaller --paths src) and from source.
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
from services.update_service import check_for_update, download # noqa: E402 from services import installer # noqa: E402
from services.file_service import get_data_dir # 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" APP_EXE = "CMBot.exe"
VERSION_FILE = "version.txt"
CONFIG_FILES = ("app_config.json", "templates.json") CONFIG_FILES = ("app_config.json", "templates.json")
logger = logging.getLogger("launcher") 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): def seed_defaults(app_dir, data_dir):
"""First run: copy factory config/templates from app\\config into the data """First run: copy factory config/templates from app\\config into the data
root, without overwriting any existing user file.""" 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) 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): 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) install_root = Path(install_root)
app_dir = install_root / "app" app_dir = install_root / "app"
data_dir = get_data_dir() data_dir = get_data_dir()
seed_defaults(app_dir, data_dir) seed_defaults(app_dir, data_dir)
cfg = load_config() try:
source = cfg.get("update_source", "") applied = installer.apply_staged(install_root)
user = cfg.get("update_user", "") if applied:
password = cfg.get("update_pass", "") logger.info("Applied staged update v%s", applied)
except OSError as exc:
local_version = _read_version(app_dir) logger.warning("Apply staged update failed (using current version): %s", exc)
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 exe = app_dir / APP_EXE
if not exe.exists(): if not exe.exists():
@@ -205,25 +79,12 @@ def _default_install_root():
return Path.cwd() 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): def _setup_logging(root):
"""Configure logging defensively for both console and --windowed builds. """Configure logging defensively for both console and --windowed builds.
A PyInstaller --windowed exe has no stdout/stderr (StreamHandler(None) would A PyInstaller --windowed exe has no stdout/stderr; a read-only install root
fail), and a read-only install root makes the file handler fail. Add each makes the file handler fail. Add each handler only when it can be created so
handler only when it can be created so the launcher never crashes on logging. the launcher never crashes on logging.
""" """
handlers = [] handlers = []
if sys.stderr is not None: if sys.stderr is not None:
@@ -236,5 +97,18 @@ def _setup_logging(root):
handlers=handlers) 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__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())
+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
+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).""" """Tests for the launcher — no GUI/network. The launcher only seeds config and
import hashlib applies a pre-staged update (downloading lives in services.installer/the app)."""
import json import json
import os import os
import shutil import shutil
import sys import sys
import tempfile import tempfile
import unittest import unittest
import zipfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).parent.parent / "src")) sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import launcher import launcher
from services.update_service import UpdateInfo from services import installer
def _make_release_zip(dst_zip, version, exe_body): class _Base(unittest.TestCase):
"""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): def setUp(self):
self.tmp = Path(tempfile.mkdtemp()) self.tmp = Path(tempfile.mkdtemp())
self.install = self.tmp / "install" self.root = self.tmp / "install"
self.app = self.install / "app" self.app = self.root / "app"
(self.app / "config").mkdir(parents=True) (self.app / "config").mkdir(parents=True)
(self.app / "CMBot.exe").write_text("OLD-1.0.0", encoding="ascii") (self.app / "CMBot.exe").write_text("OLD-1.0.0", encoding="ascii")
(self.app / "version.txt").write_text("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( (self.app / "config" / "app_config.json").write_text(
json.dumps({"update_source": "http://x/manifest.json", "last_template": "正方形"}), json.dumps({"update_source": "http://x"}), encoding="utf-8")
encoding="utf-8")
(self.app / "config" / "templates.json").write_text("{}", 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.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) os.environ["CMBOT_DATA_DIR"] = str(self.data)
def tearDown(self): def tearDown(self):
if self._prev_env is None: if self._prev is None:
os.environ.pop("CMBOT_DATA_DIR", None) os.environ.pop("CMBOT_DATA_DIR", None)
else: else:
os.environ["CMBOT_DATA_DIR"] = self._prev_env os.environ["CMBOT_DATA_DIR"] = self._prev
shutil.rmtree(str(self.tmp), ignore_errors=True) 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) launcher.seed_defaults(self.app, self.data)
self.assertTrue((self.data / "config" / "app_config.json").exists()) self.assertTrue((self.data / "config" / "app_config.json").exists())
self.assertTrue((self.data / "config" / "templates.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").mkdir(parents=True)
(self.data / "config" / "app_config.json").write_text( (self.data / "config" / "app_config.json").write_text('{"update_source":"USER"}', encoding="utf-8")
'{"update_source":"USER"}', encoding="utf-8")
launcher.seed_defaults(self.app, self.data) launcher.seed_defaults(self.app, self.data)
kept = json.loads((self.data / "config" / "app_config.json").read_text(encoding="utf-8")) kept = json.loads((self.data / "config" / "app_config.json").read_text(encoding="utf-8"))
self.assertEqual(kept["update_source"], "USER") 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(rc, 0)
self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "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((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.assertEqual((installer.old_dir(self.root) / "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): def test_no_staged_just_launches(self):
with patch("launcher.check_for_update", return_value=None): rc = launcher.run(self.root, no_launch=True)
rc = launcher.run(self.install, no_launch=True)
self.assertEqual(rc, 0) self.assertEqual(rc, 0)
self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.0.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_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): def test_seeds_config_into_data_root(self):
with patch("launcher.check_for_update", return_value=None): launcher.run(self.root, no_launch=True)
launcher.run(self.install, no_launch=True)
self.assertTrue((self.data / "config" / "app_config.json").exists()) 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__": if __name__ == "__main__":
unittest.main() unittest.main()