feat(update): add forced upgrade progress flow

This commit is contained in:
chengma
2026-07-13 12:12:58 +08:00
parent bab2609a85
commit 04124060fc
11 changed files with 437 additions and 101 deletions
+7 -35
View File
@@ -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
+235
View File
@@ -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()
+16
View File
@@ -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:
+11 -1
View File
@@ -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)
+2
View File
@@ -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`、配置/数据目录、文档和原型目录,不新增正式业务模块。
+2
View File
@@ -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策略失败放行。
## 四、绝不打包的本地数据
发布包里不能包含以下本地数据、密钥、业务数据或登录态:
+4
View File
@@ -20,6 +20,10 @@
任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,点击「开始更新」后必须弹窗确认当前筛选范围、任务数量和线上提交风险。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。
## 启动强制升级门禁
创建五个业务Tab之前先请求版本接口。服务端明确要求强制升级时,不创建 `MainWindow`,而是显示「必须升级」模态进度窗口:用户点击「立即升级」后可看到下载、校验、准备新版和重启阶段,以及百分比和字节数;运行中可「取消并退出」,失败后可重试。校验完成后软件启动安装目录外的独立更新器并退出,更新器替换程序后自动重启新版。版本接口完全不可达或非法时仍失败放行;一旦已明确强制,元数据缺失或后续失败都不允许进入旧版主界面。
## 全局 Tab 栏可用性
当前 5 个主 Tab 是高频导航入口,不能使用 Qt 默认的紧凑宽度。`MainWindow` 必须为 `QTabWidget/QTabBar` 设置基础样式:
+4 -2
View File
@@ -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`。自动化测试未访问真实线上下载地址。
+4 -3
View File
@@ -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 已将其接入启动强制升级进度窗口。
## 五、发版约定(服务端据此控制)
+148 -60
View File
@@ -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(
+4
View File
@@ -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):