diff --git a/app/gui/__init__.py b/app/gui/__init__.py index 9c4d9e2..e1d6886 100644 --- a/app/gui/__init__.py +++ b/app/gui/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import os import sys -import webbrowser from .. import appconfig, chrome, diagnostics, update_check from ..version import APP_NAME, display_name @@ -38,6 +37,7 @@ if QT_IMPORT_ERROR is None: from .tabs.image_studio import ImageStudioPreviewDialog, ImageStudioTab from .tabs.settings import SettingsTab from .main_window import MainWindow + from .update_dialog import ForcedUpdateDialog, UpdatePreparationWorker else: class MainWindow(QMainWindow): def __init__(self): @@ -74,42 +74,14 @@ def _write_update_check_diagnostic(message, *, result=None, exc=None): pass -def _forced_update_details(result): - online_version = result.latest_version or result.min_supported_version or "未知" - lines = [ - f"当前版本:{result.current_version}", - f"线上版本:{online_version}", - ] - if result.min_supported_version: - lines.append(f"最低支持版本:{result.min_supported_version}") - if result.message: - lines.append(f"升级说明:{result.message}") - if result.download_url: - lines.append("请下载新版,关闭程序后覆盖程序文件和 _internal/,保留 data/ 目录。") - else: - lines.append("版本接口未提供下载地址,请联系管理员获取新版后再使用。") - return "\n".join(lines) - - -def _show_forced_update_dialog(result, *, parent=None, opener=None) -> bool: - opener = opener or webbrowser.open - box = QMessageBox(parent) - box.setIcon(QMessageBox.Warning) - box.setWindowTitle("必须升级") - box.setText("当前版本已不能继续使用,请先升级到新版。") - box.setInformativeText(_forced_update_details(result)) - download_button = box.addButton("下载新版", QMessageBox.AcceptRole) - exit_button = box.addButton("退出程序", QMessageBox.RejectRole) - if not result.download_url and hasattr(download_button, "setEnabled"): - download_button.setEnabled(False) - box.setDefaultButton(download_button if result.download_url else exit_button) - box.exec() - if box.clickedButton() is download_button and result.download_url: - opener(result.download_url) +def _show_forced_update_dialog(result, *, parent=None, dialog_factory=None) -> bool: + dialog_class = dialog_factory or ForcedUpdateDialog + dialog = dialog_class(result, parent=parent) + dialog.exec() return False -def _run_startup_update_gate(*, checker=None, opener=None) -> bool: +def _run_startup_update_gate(*, checker=None, dialog_factory=None) -> bool: try: result = (checker or update_check.check_for_update)() except Exception as exc: @@ -119,7 +91,7 @@ def _run_startup_update_gate(*, checker=None, opener=None) -> bool: if result.error: _write_update_check_diagnostic("启动版本检查失败,已允许继续使用", result=result) if result.forced: - return _show_forced_update_dialog(result, opener=opener) + return _show_forced_update_dialog(result, dialog_factory=dialog_factory) return True diff --git a/app/gui/update_dialog.py b/app/gui/update_dialog.py new file mode 100644 index 0000000..f81c55c --- /dev/null +++ b/app/gui/update_dialog.py @@ -0,0 +1,235 @@ +"""启动阶段的强制升级进度窗口。""" + +from __future__ import annotations + +import os +from pathlib import Path + +from PySide6.QtCore import QObject, QThread, Signal, Slot +from PySide6.QtWidgets import ( + QDialog, + QHBoxLayout, + QLabel, + QProgressBar, + QPushButton, + QVBoxLayout, +) + +from .. import appconfig, diagnostics, update_installer, updater_entry + + +def _format_bytes(value): + value = max(0, int(value or 0)) + if value >= 1024 * 1024: + return "%.1f MB" % (value / (1024 * 1024)) + if value >= 1024: + return "%.1f KB" % (value / 1024) + return "%d B" % value + + +class UpdatePreparationWorker(QObject): + stage_changed = Signal(str) + progress_changed = Signal(int, int) + succeeded = Signal(object) + failed = Signal(str) + finished = Signal() + + def __init__(self, result, install_root, prepare=None): + super().__init__() + self.result = result + self.install_root = install_root + self.prepare = prepare or update_installer.prepare_update + self._cancelled = False + + @Slot() + def run(self): + try: + metadata = update_installer.metadata_from_update_info(self.result) + staged = self.prepare( + metadata, + self.install_root, + cancelled=lambda: self._cancelled, + progress=self.progress_changed.emit, + stage_callback=self.stage_changed.emit, + ) + if self._cancelled: + raise update_installer.UpdateCancelled("已取消下载新版") + self.succeeded.emit(staged) + except update_installer.UpdateCancelled: + self.failed.emit("已取消下载新版") + except update_installer.UpdateInstallError as exc: + self.failed.emit(str(exc)) + except Exception as exc: + try: + diagnostics.write_diagnostic_log( + "准备自动更新失败", + level="ERROR", + step="startup_update_prepare", + exc=exc, + ) + except Exception: + pass + self.failed.emit("准备新版失败,请稍后重试") + finally: + self.finished.emit() + + def cancel(self): + self._cancelled = True + + +class ForcedUpdateDialog(QDialog): + def __init__( + self, + result, + *, + parent=None, + install_root=None, + worker_factory=None, + updater_launcher=None, + ): + super().__init__(parent) + self.result = result + self.install_root = Path(install_root or appconfig.app_base_dir()).resolve() + self.worker_factory = worker_factory or UpdatePreparationWorker + self.updater_launcher = updater_launcher or updater_entry.copy_and_launch_updater + self.thread = None + self.worker = None + self.exit_requested = False + self.update_started = False + self._build_ui() + self._validate_available_update() + + def _build_ui(self): + self.setWindowTitle("必须升级") + self.setModal(True) + self.setMinimumWidth(460) + layout = QVBoxLayout(self) + title = QLabel("当前版本已不能继续使用,请先升级到新版。") + title.setWordWrap(True) + layout.addWidget(title) + online_version = self.result.latest_version or self.result.min_supported_version or "未知" + details = "当前版本:%s\n线上版本:%s" % ( + self.result.current_version, + online_version, + ) + if self.result.message: + details += "\n升级说明:%s" % self.result.message + self.details_label = QLabel(details) + self.details_label.setWordWrap(True) + layout.addWidget(self.details_label) + self.stage_label = QLabel("发现必须升级的新版本") + layout.addWidget(self.stage_label) + self.progress_bar = QProgressBar() + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(0) + layout.addWidget(self.progress_bar) + self.bytes_label = QLabel("等待开始") + layout.addWidget(self.bytes_label) + buttons = QHBoxLayout() + buttons.addStretch(1) + self.action_button = QPushButton("立即升级") + self.exit_button = QPushButton("退出程序") + self.action_button.clicked.connect(self.start_update) + self.exit_button.clicked.connect(self.request_exit) + buttons.addWidget(self.action_button) + buttons.addWidget(self.exit_button) + layout.addLayout(buttons) + + def _validate_available_update(self): + try: + update_installer.metadata_from_update_info(self.result) + except update_installer.UpdateInstallError as exc: + self._show_failure(str(exc)) + + @Slot() + def start_update(self): + if self.thread is not None: + return + self.exit_requested = False + self.action_button.setEnabled(False) + self.exit_button.setText("取消并退出") + self.stage_label.setText("正在准备新版") + self.worker = self.worker_factory(self.result, self.install_root) + self.thread = QThread(self) + self.worker.moveToThread(self.thread) + self.thread.started.connect(self.worker.run) + self.worker.stage_changed.connect(self.stage_label.setText) + self.worker.progress_changed.connect(self._update_progress) + self.worker.succeeded.connect(self._on_prepared) + self.worker.failed.connect(self._on_failed) + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + self.thread.finished.connect(self._on_thread_finished) + self.thread.start() + + @Slot(int, int) + def _update_progress(self, downloaded, total): + percent = int(downloaded * 100 / total) if total else 0 + self.progress_bar.setValue(max(0, min(100, percent))) + self.bytes_label.setText("已下载 %s / %s" % (_format_bytes(downloaded), _format_bytes(total))) + + @Slot(object) + def _on_prepared(self, staged): + self.stage_label.setText("新版准备完成,正在重启") + self.progress_bar.setValue(100) + try: + plan_path = updater_entry.create_plan( + self.install_root, + staged.staging_dir, + staged.version, + os.getpid(), + ) + updater_path = self.install_root / "cmshopee-updater.exe" + self.updater_launcher(updater_path, plan_path) + self.update_started = True + except Exception as exc: + try: + diagnostics.write_diagnostic_log( + "启动独立更新器失败", + level="ERROR", + step="startup_update_launch", + exc=exc, + ) + except Exception: + pass + self._show_failure("启动更新程序失败,请重试或联系管理员") + + @Slot(str) + def _on_failed(self, message): + if self.exit_requested: + self.reject() + return + self._show_failure(message) + + @Slot() + def _on_thread_finished(self): + thread = self.thread + self.thread = None + self.worker = None + if thread is not None: + thread.deleteLater() + if self.update_started: + self.accept() + + def _show_failure(self, message): + self.stage_label.setText("升级失败:%s" % message) + self.action_button.setText("重试") + self.action_button.setEnabled(True) + self.exit_button.setText("退出程序") + + @Slot() + def request_exit(self): + if self.worker is not None: + self.exit_requested = True + self.action_button.setEnabled(False) + self.exit_button.setEnabled(False) + self.stage_label.setText("正在取消下载并清理临时文件") + self.worker.cancel() + return + self.reject() + + def reject(self): + if self.worker is not None: + self.request_exit() + return + super().reject() diff --git a/app/update_check.py b/app/update_check.py index 57879a3..1e6bb3b 100644 --- a/app/update_check.py +++ b/app/update_check.py @@ -31,6 +31,8 @@ class UpdateInfo: package_format: str = "" updater_protocol: int = 0 min_updater_protocol: int = 0 + signature_algorithm: str = "" + manifest_signature: str = "" message: str = "" @@ -47,6 +49,8 @@ class UpdateCheckResult: package_format: str = "" updater_protocol: int = 0 min_updater_protocol: int = 0 + signature_algorithm: str = "" + manifest_signature: str = "" message: str = "" error: str = "" @@ -131,6 +135,16 @@ def parse_update_info(payload) -> UpdateInfo: or release.get("min_updater_protocol") or 0 ), + signature_algorithm=str( + payload.get("signature_algorithm") + or release.get("signature_algorithm") + or "" + ).strip(), + manifest_signature=str( + payload.get("manifest_signature") + or release.get("manifest_signature") + or "" + ).strip(), message=str( payload.get("message") or payload.get("release_notes") @@ -202,6 +216,8 @@ def check_for_update( package_format=info.package_format, updater_protocol=info.updater_protocol, min_updater_protocol=info.min_updater_protocol, + signature_algorithm=info.signature_algorithm, + manifest_signature=info.manifest_signature, message=info.message, ) except Exception as exc: diff --git a/app/update_installer.py b/app/update_installer.py index 57f6c35..05bcc73 100644 --- a/app/update_installer.py +++ b/app/update_installer.py @@ -377,17 +377,25 @@ def load_verified_pending(install_root, metadata): return None -def prepare_update(metadata, install_root, **download_kwargs): +def prepare_update(metadata, install_root, stage_callback=None, **download_kwargs): validate_metadata(metadata, download_kwargs.get("trusted_hosts", DEFAULT_TRUSTED_HOSTS)) + if stage_callback: + stage_callback("正在检查已下载的新版") reusable = load_verified_pending(install_root, metadata) if reusable is not None: + if stage_callback: + stage_callback("新版已经完成校验") return reusable update_root = _update_root(install_root) + if stage_callback: + stage_callback("正在下载新版") zip_path = download_package(metadata, install_root, **download_kwargs) staging_parent = update_root / "staging" staging_parent.mkdir(parents=True, exist_ok=True) staging_dir = staging_parent / (metadata.version + "-" + uuid.uuid4().hex[:12]) try: + if stage_callback: + stage_callback("正在校验并准备新版") safe_extract(zip_path, staging_dir) validate_staging(staging_dir, metadata) pending_path = _write_pending(update_root, metadata, staging_dir, zip_path) @@ -395,4 +403,6 @@ def prepare_update(metadata, install_root, **download_kwargs): if staging_dir.exists(): shutil.rmtree(str(staging_dir), ignore_errors=True) raise + if stage_callback: + stage_callback("新版已经完成校验") return StagedUpdate(metadata.version, zip_path, staging_dir, pending_path, metadata.sha256) diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 5345b0f..a8475e4 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -501,6 +501,7 @@ cmshopee/ │ ├── release_manifest.py # 发布包文件清单、zip哈希与服务端元数据模板 │ ├── update_installer.py # 自动升级安全下载、解压、manifest校验与同盘暂存 │ ├── updater_entry.py # 独立更新器入口、事务根项目切换、journal与回滚 +│ ├── gui/update_dialog.py # 强制升级模态进度、下载worker与重启编排 ├── main.py # GUI 启动入口:from app.gui import main ├── shopee待处理任务模板.xlsx # 标准空 Excel 模板,可提交;业务填写后的副本不提交 ├── data/ # 用户本地数据根(整体 gitignore;打包更新时保留) @@ -531,6 +532,7 @@ cmshopee/ - T-615 只提供可验证发布契约;启动门禁仍保持 T-544 的人工下载行为,直到后续下载、独立更新器、事务替换和失败熔断任务全部接入。 - T-616 的下载暂存根固定为安装目录下 `.cmshopee-update/`,与 `data/` 完全隔离。远程zip必须经过HTTPS/受信任域名、声明大小、整包SHA-256、安全zip路径和包内manifest逐文件校验,才写 `pending.json`;此阶段不替换任何运行中程序文件。 - T-617 的独立 `cmshopee-updater.exe` 必须先复制到系统临时目录运行,并等待主程序退出。替换粒度是manifest允许的程序根项目,旧根先整体移动到同盘backup,新根再整体移入;事务锁防止并发更新,journal记录每次移动,任一步失败逆序恢复。`data/`、更新管理目录与未知安装根项目永不进入替换清单。 +- T-618 在创建 `MainWindow` 前显示强制升级 `QDialog`;下载与校验只能在 `QObject + QThread` worker中执行,线程结束前保留引用,取消时等待part清理。独立更新器进程成功创建后才退出旧主程序;强制版本后续失败保持阻断,只有版本接口本身不可达/非法继续失败放行。 - CDP 交互事实变化同步第七节。 - 正式代码只放 `app/` 包;根目录只保留 `main.py`、配置/数据目录、文档和原型目录,不新增正式业务模块。 diff --git a/docs/packaging.md b/docs/packaging.md index ee4ad35..d46e451 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -180,6 +180,8 @@ T-616 提供无Qt依赖的安全下载暂存层 `app/update_installer.py`。它 T-617 增加独立 `cmshopee-updater.exe`。构建脚本用 `cmshopee-updater.spec` 生成无控制台单文件更新器并放进release和manifest。执行更新前,主程序把它复制到系统临时目录;更新器有上限地等待主程序退出,再按manifest白名单将旧程序根项目整体移动到 `.cmshopee-update/backup/`,把已验证暂存根项目移入安装目录。每一步写事务journal,移动或新版启动失败时逆序恢复;`data/` 与安装根未知文件不扫描、不移动、不删除。 +T-618 已把安全暂存和独立更新器接回启动门禁。强制升级窗口使用QThread执行下载、hash、解压和manifest校验,主线程持续显示中文阶段、进度和字节数;协作式取消会等待线程清理,避免线程仍运行时销毁。确认独立更新器进程创建成功后旧主程序退出;强制响应后的任何准备失败都保持阻断,只能重试或退出。版本接口完全不可达/非法仍按T-544策略失败放行。 + ## 四、绝不打包的本地数据 发布包里不能包含以下本地数据、密钥、业务数据或登录态: diff --git a/docs/routes.md b/docs/routes.md index 47ab962..d7a8dce 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -20,6 +20,10 @@ 任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,点击「开始更新」后必须弹窗确认当前筛选范围、任务数量和线上提交风险。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。 +## 启动强制升级门禁 + +创建五个业务Tab之前先请求版本接口。服务端明确要求强制升级时,不创建 `MainWindow`,而是显示「必须升级」模态进度窗口:用户点击「立即升级」后可看到下载、校验、准备新版和重启阶段,以及百分比和字节数;运行中可「取消并退出」,失败后可重试。校验完成后软件启动安装目录外的独立更新器并退出,更新器替换程序后自动重启新版。版本接口完全不可达或非法时仍失败放行;一旦已明确强制,元数据缺失或后续失败都不允许进入旧版主界面。 + ## 全局 Tab 栏可用性 当前 5 个主 Tab 是高频导航入口,不能使用 Qt 默认的紧凑宽度。`MainWindow` 必须为 `QTabWidget/QTabBar` 设置基础样式: diff --git a/docs/tasks/T-618.md b/docs/tasks/T-618.md index 5d1f58e..11c7e3c 100644 --- a/docs/tasks/T-618.md +++ b/docs/tasks/T-618.md @@ -3,7 +3,7 @@ id: T-618 title: 强制升级进度窗口与自动下载重启编排 phase: 8 deps: [T-616, T-617] -status: TODO +status: DONE created: 2026-07-13 --- @@ -45,4 +45,6 @@ T-544当前“下载新版”只打开浏览器并退出。完成安全下载暂 ## 执行记录 -(完成后记录实现、验证命令与结果。) +- 2026-07-13:新增强制升级模态进度窗口与专用QThread worker,显示中文阶段、百分比和下载字节;取消会等待worker清理并在线程结束后释放引用。 +- 2026-07-13:启动门禁已接入安全暂存、plan生成和临时目录独立更新器启动;强制响应后的元数据/下载/校验/启动失败保持阻断,版本接口自身失败仍按原策略放行。 +- 2026-07-13:干净worktree验证通过:ruff、compileall、完整unittest(411项)和 `git diff --check`。自动化测试未访问真实线上下载地址。 diff --git a/docs/update-check.md b/docs/update-check.md index 850d93b..8d142b1 100644 --- a/docs/update-check.md +++ b/docs/update-check.md @@ -41,15 +41,16 @@ 两者都不满足 → **非强制**。 -## 四、客户端行为(T-544 已实现) +## 四、客户端行为(T-618) -- **强制**:弹**模态框**,显示 `release_notes`/`message`,按钮只有「下载新版」(打开 `download_url`)和「退出程序」;无论点哪个,**程序退出、进不了主界面** → 必须升级后才能用。 +- **强制且自动安装元数据完整**:弹模态进度窗口,显示中文阶段、下载百分比/字节数和发布说明;点击「立即升级」后在工作线程下载、校验和暂存,再启动独立更新器并退出旧程序。更新器完成事务替换后自动启动新版。 +- **强制但元数据不完整,或下载/校验/更新器启动失败**:继续阻断主窗口,只允许「重试」或「退出程序」,不能降级放行旧版,也不再打开浏览器让用户手工覆盖。 - **非强制**:**不弹任何提示**,直接进主界面(当前无"温和可跳过提示"分支;如需另立任务)。 - **失败放行**:接口断网、超时、返回非法 JSON、缺 `latest_version`/`min_supported_version` 时,客户端记诊断日志(`data/logs/cmshopee.log`,`step=startup_update_check`「已允许继续使用」)并**放行**,不因服务器故障导致全员打不开。 T-616 已实现但尚未接入GUI的安全暂存层:仅接受受信任域名的 HTTPS 地址,流式下载到安装目录 `.cmshopee-update/`,校验zip大小和SHA-256,安全解压后再按包内manifest逐文件校验。任一步失败都不修改当前程序或 `data/`;GUI接入由T-618完成。 -T-617 已提供独立无控制台更新器和事务回滚能力:更新器从系统临时目录运行,旧主程序退出后才切换程序根项目,并在新版进程无法创建时恢复旧版。启动检查弹窗尚未调用该流程,直到T-618完成GUI编排。 +T-617 已提供独立无控制台更新器和事务回滚能力:更新器从系统临时目录运行,旧主程序退出后才切换程序根项目,并在新版进程无法创建时恢复旧版。T-618 已将其接入启动强制升级进度窗口。 ## 五、发版约定(服务端据此控制) diff --git a/tests/test_gui.py b/tests/test_gui.py index 1e275d5..f61554c 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -3,6 +3,7 @@ import unittest import os import sys import threading +import time from types import SimpleNamespace from unittest import mock @@ -12,7 +13,17 @@ sys.path.insert(0, os.path.dirname(__file__)) from _helpers import TempDirMixin from app import gui -from app import accounts, ai, appconfig, db, image_paths, image_studio, prompts, update_check +from app import ( + accounts, + ai, + appconfig, + db, + image_paths, + image_studio, + prompts, + update_check, + update_installer, +) if gui.QT_IMPORT_ERROR is not None: raise unittest.SkipTest("PySide6 未安装") @@ -42,12 +53,14 @@ from app.gui import ( CollectTab, GenerateWorker, GenerateTab, + ForcedUpdateDialog, ImageStudioTab, MainWindow, SettingsTab, TAB_STYLE, TAB_TITLES, WriteBackWorker, + UpdatePreparationWorker, ) from app.gui import file_manager import app.gui.workers as gui_workers @@ -1573,82 +1586,157 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertNotIn("/api/v1", message) self.assertNotIn("/generated/images", message) - def test_startup_update_gate_forced_blocks_and_opens_download(self): - boxes = [] + def test_startup_update_gate_forced_blocks_and_uses_upgrade_dialog(self): + dialogs = [] - class FakeButton: - def __init__(self, label): - self.label = label - self.enabled = True - - def setEnabled(self, enabled): - self.enabled = enabled - - class FakeMessageBox: - Warning = object() - AcceptRole = object() - RejectRole = object() - - def __init__(self, parent=None): + class FakeDialog: + def __init__(self, result, parent=None): + self.result = result self.parent = parent - self.icon = None - self.title = "" - self.text = "" - self.informative_text = "" - self.buttons = {} - self.default_button = None - boxes.append(self) - - def setIcon(self, icon): - self.icon = icon - - def setWindowTitle(self, title): - self.title = title - - def setText(self, text): - self.text = text - - def setInformativeText(self, text): - self.informative_text = text - - def addButton(self, label, role): - button = FakeButton(label) - self.buttons[label] = button - return button - - def setDefaultButton(self, button): - self.default_button = button + self.executed = False + dialogs.append(self) def exec(self): + self.executed = True return 0 - def clickedButton(self): - return self.buttons["下载新版"] - result = update_check.UpdateCheckResult( current_version="1.0.0", checked=True, forced=True, latest_version="1.2.0", min_supported_version="1.1.0", - download_url="https://example.test/cmshopee.zip", + download_url="https://cm.833729.com/cmshopee.zip", + sha256="a" * 64, + size_bytes=123, + package_format="cmshopee-portable-v1", + updater_protocol=1, + min_updater_protocol=1, message="必须升级", ) - opened = [] - with mock.patch("app.gui.QMessageBox", FakeMessageBox): - allowed = gui._run_startup_update_gate( - checker=lambda: result, - opener=opened.append, - ) + allowed = gui._run_startup_update_gate( + checker=lambda: result, + dialog_factory=FakeDialog, + ) self.assertFalse(allowed) - self.assertEqual(["https://example.test/cmshopee.zip"], opened) - self.assertEqual("必须升级", boxes[0].title) - self.assertIn("当前版本:1.0.0", boxes[0].informative_text) - self.assertIn("线上版本:1.2.0", boxes[0].informative_text) - self.assertIn("保留 data/ 目录", boxes[0].informative_text) - self.assertEqual(boxes[0].buttons["下载新版"], boxes[0].default_button) + self.assertEqual(1, len(dialogs)) + self.assertTrue(dialogs[0].executed) + self.assertIs(result, dialogs[0].result) + + def test_forced_update_dialog_prepares_and_launches_updater_without_blocking_ui(self): + with self.make_temp_dir() as temp_dir: + install_root = os.path.join(temp_dir, "install") + staging_root = os.path.join(install_root, ".cmshopee-update", "staging", "1.2.0-test") + os.makedirs(staging_root) + result = update_check.UpdateCheckResult( + current_version="1.0.0", + checked=True, + forced=True, + latest_version="1.2.0", + download_url="https://cm.833729.com/cmshopee.zip", + sha256="a" * 64, + size_bytes=100, + package_format="cmshopee-portable-v1", + updater_protocol=1, + min_updater_protocol=1, + message="修复并优化自动升级", + ) + staged = SimpleNamespace( + version="1.2.0", + staging_dir=os.path.abspath(staging_root), + ) + + def prepare(_metadata, _install_root, **callbacks): + callbacks["stage_callback"]("正在下载新版") + callbacks["progress"](50, 100) + return staged + + launched = [] + def factory(info, root): + return UpdatePreparationWorker(info, root, prepare=prepare) + + dialog = ForcedUpdateDialog( + result, + install_root=install_root, + worker_factory=factory, + updater_launcher=lambda updater, plan: launched.append((updater, plan)), + ) + self.addCleanup(dialog.close) + with mock.patch( + "app.gui.update_dialog.updater_entry.create_plan", + return_value=os.path.join(temp_dir, "plan.json"), + ): + dialog.start_update() + deadline = time.time() + 3 + while dialog.thread is not None and time.time() < deadline: + self.app.processEvents() + time.sleep(0.01) + + self.assertIsNone(dialog.thread) + self.assertTrue(dialog.update_started) + self.assertEqual(100, dialog.progress_bar.value()) + self.assertIn("50 B / 100 B", dialog.bytes_label.text()) + self.assertEqual(1, len(launched)) + self.assertIn("cmshopee-updater.exe", str(launched[0][0])) + + def test_forced_update_dialog_cancel_waits_for_worker_cleanup(self): + with self.make_temp_dir() as temp_dir: + result = update_check.UpdateCheckResult( + current_version="1.0.0", + checked=True, + forced=True, + latest_version="1.2.0", + download_url="https://cm.833729.com/cmshopee.zip", + sha256="a" * 64, + size_bytes=100, + package_format="cmshopee-portable-v1", + updater_protocol=1, + min_updater_protocol=1, + ) + + def prepare(_metadata, _install_root, **callbacks): + while not callbacks["cancelled"](): + time.sleep(0.01) + raise update_installer.UpdateCancelled("已取消下载新版") + + def factory(info, root): + return UpdatePreparationWorker(info, root, prepare=prepare) + + dialog = ForcedUpdateDialog( + result, + install_root=temp_dir, + worker_factory=factory, + ) + self.addCleanup(dialog.close) + dialog.start_update() + deadline = time.time() + 1 + while dialog.worker is None and time.time() < deadline: + self.app.processEvents() + dialog.request_exit() + deadline = time.time() + 3 + while dialog.thread is not None and time.time() < deadline: + self.app.processEvents() + time.sleep(0.01) + + self.assertIsNone(dialog.thread) + self.assertIsNone(dialog.worker) + + def test_forced_update_dialog_blocks_when_auto_install_metadata_is_incomplete(self): + result = update_check.UpdateCheckResult( + current_version="1.0.0", + checked=True, + forced=True, + latest_version="1.2.0", + download_url="https://cm.833729.com/cmshopee.zip", + ) + dialog = ForcedUpdateDialog(result) + self.addCleanup(dialog.close) + + self.assertIn("校验值缺失", dialog.stage_label.text()) + self.assertEqual("重试", dialog.action_button.text()) + self.assertIsNone(dialog.thread) def test_startup_update_gate_check_failure_allows_entry_and_logs(self): result = update_check.UpdateCheckResult( diff --git a/tests/test_update_check.py b/tests/test_update_check.py index 20ca02e..dc0b9a7 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -46,6 +46,9 @@ class UpdateCheckTests(unittest.TestCase): "size_bytes": 123, "package_format": "cmshopee-portable-v1", "updater_protocol": 1, + "min_updater_protocol": 1, + "signature_algorithm": "", + "manifest_signature": "", "message": "请升级后继续使用", } @@ -62,6 +65,7 @@ class UpdateCheckTests(unittest.TestCase): self.assertEqual("https://example.test/cmshopee.zip", result.download_url) self.assertEqual(123, result.size_bytes) self.assertEqual("cmshopee-portable-v1", result.package_format) + self.assertEqual(1, result.min_updater_protocol) def test_check_for_update_accepts_release_wrapper_response(self): def fetcher(_url, _timeout):