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 os
import sys import sys
import webbrowser
from .. import appconfig, chrome, diagnostics, update_check from .. import appconfig, chrome, diagnostics, update_check
from ..version import APP_NAME, display_name 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.image_studio import ImageStudioPreviewDialog, ImageStudioTab
from .tabs.settings import SettingsTab from .tabs.settings import SettingsTab
from .main_window import MainWindow from .main_window import MainWindow
from .update_dialog import ForcedUpdateDialog, UpdatePreparationWorker
else: else:
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self): def __init__(self):
@@ -74,42 +74,14 @@ def _write_update_check_diagnostic(message, *, result=None, exc=None):
pass pass
def _forced_update_details(result): def _show_forced_update_dialog(result, *, parent=None, dialog_factory=None) -> bool:
online_version = result.latest_version or result.min_supported_version or "未知" dialog_class = dialog_factory or ForcedUpdateDialog
lines = [ dialog = dialog_class(result, parent=parent)
f"当前版本:{result.current_version}", dialog.exec()
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)
return False return False
def _run_startup_update_gate(*, checker=None, opener=None) -> bool: def _run_startup_update_gate(*, checker=None, dialog_factory=None) -> bool:
try: try:
result = (checker or update_check.check_for_update)() result = (checker or update_check.check_for_update)()
except Exception as exc: except Exception as exc:
@@ -119,7 +91,7 @@ def _run_startup_update_gate(*, checker=None, opener=None) -> bool:
if result.error: if result.error:
_write_update_check_diagnostic("启动版本检查失败,已允许继续使用", result=result) _write_update_check_diagnostic("启动版本检查失败,已允许继续使用", result=result)
if result.forced: if result.forced:
return _show_forced_update_dialog(result, opener=opener) return _show_forced_update_dialog(result, dialog_factory=dialog_factory)
return True 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 = "" package_format: str = ""
updater_protocol: int = 0 updater_protocol: int = 0
min_updater_protocol: int = 0 min_updater_protocol: int = 0
signature_algorithm: str = ""
manifest_signature: str = ""
message: str = "" message: str = ""
@@ -47,6 +49,8 @@ class UpdateCheckResult:
package_format: str = "" package_format: str = ""
updater_protocol: int = 0 updater_protocol: int = 0
min_updater_protocol: int = 0 min_updater_protocol: int = 0
signature_algorithm: str = ""
manifest_signature: str = ""
message: str = "" message: str = ""
error: str = "" error: str = ""
@@ -131,6 +135,16 @@ def parse_update_info(payload) -> UpdateInfo:
or release.get("min_updater_protocol") or release.get("min_updater_protocol")
or 0 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( message=str(
payload.get("message") payload.get("message")
or payload.get("release_notes") or payload.get("release_notes")
@@ -202,6 +216,8 @@ def check_for_update(
package_format=info.package_format, package_format=info.package_format,
updater_protocol=info.updater_protocol, updater_protocol=info.updater_protocol,
min_updater_protocol=info.min_updater_protocol, min_updater_protocol=info.min_updater_protocol,
signature_algorithm=info.signature_algorithm,
manifest_signature=info.manifest_signature,
message=info.message, message=info.message,
) )
except Exception as exc: except Exception as exc:
+11 -1
View File
@@ -377,17 +377,25 @@ def load_verified_pending(install_root, metadata):
return None 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)) validate_metadata(metadata, download_kwargs.get("trusted_hosts", DEFAULT_TRUSTED_HOSTS))
if stage_callback:
stage_callback("正在检查已下载的新版")
reusable = load_verified_pending(install_root, metadata) reusable = load_verified_pending(install_root, metadata)
if reusable is not None: if reusable is not None:
if stage_callback:
stage_callback("新版已经完成校验")
return reusable return reusable
update_root = _update_root(install_root) update_root = _update_root(install_root)
if stage_callback:
stage_callback("正在下载新版")
zip_path = download_package(metadata, install_root, **download_kwargs) zip_path = download_package(metadata, install_root, **download_kwargs)
staging_parent = update_root / "staging" staging_parent = update_root / "staging"
staging_parent.mkdir(parents=True, exist_ok=True) staging_parent.mkdir(parents=True, exist_ok=True)
staging_dir = staging_parent / (metadata.version + "-" + uuid.uuid4().hex[:12]) staging_dir = staging_parent / (metadata.version + "-" + uuid.uuid4().hex[:12])
try: try:
if stage_callback:
stage_callback("正在校验并准备新版")
safe_extract(zip_path, staging_dir) safe_extract(zip_path, staging_dir)
validate_staging(staging_dir, metadata) validate_staging(staging_dir, metadata)
pending_path = _write_pending(update_root, metadata, staging_dir, zip_path) 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(): if staging_dir.exists():
shutil.rmtree(str(staging_dir), ignore_errors=True) shutil.rmtree(str(staging_dir), ignore_errors=True)
raise raise
if stage_callback:
stage_callback("新版已经完成校验")
return StagedUpdate(metadata.version, zip_path, staging_dir, pending_path, metadata.sha256) return StagedUpdate(metadata.version, zip_path, staging_dir, pending_path, metadata.sha256)
+2
View File
@@ -501,6 +501,7 @@ cmshopee/
│ ├── release_manifest.py # 发布包文件清单、zip哈希与服务端元数据模板 │ ├── release_manifest.py # 发布包文件清单、zip哈希与服务端元数据模板
│ ├── update_installer.py # 自动升级安全下载、解压、manifest校验与同盘暂存 │ ├── update_installer.py # 自动升级安全下载、解压、manifest校验与同盘暂存
│ ├── updater_entry.py # 独立更新器入口、事务根项目切换、journal与回滚 │ ├── updater_entry.py # 独立更新器入口、事务根项目切换、journal与回滚
│ ├── gui/update_dialog.py # 强制升级模态进度、下载worker与重启编排
├── main.py # GUI 启动入口:from app.gui import main ├── main.py # GUI 启动入口:from app.gui import main
├── shopee待处理任务模板.xlsx # 标准空 Excel 模板,可提交;业务填写后的副本不提交 ├── shopee待处理任务模板.xlsx # 标准空 Excel 模板,可提交;业务填写后的副本不提交
├── data/ # 用户本地数据根(整体 gitignore;打包更新时保留) ├── data/ # 用户本地数据根(整体 gitignore;打包更新时保留)
@@ -531,6 +532,7 @@ cmshopee/
- T-615 只提供可验证发布契约;启动门禁仍保持 T-544 的人工下载行为,直到后续下载、独立更新器、事务替换和失败熔断任务全部接入。 - T-615 只提供可验证发布契约;启动门禁仍保持 T-544 的人工下载行为,直到后续下载、独立更新器、事务替换和失败熔断任务全部接入。
- T-616 的下载暂存根固定为安装目录下 `.cmshopee-update/`,与 `data/` 完全隔离。远程zip必须经过HTTPS/受信任域名、声明大小、整包SHA-256、安全zip路径和包内manifest逐文件校验,才写 `pending.json`;此阶段不替换任何运行中程序文件。 - T-616 的下载暂存根固定为安装目录下 `.cmshopee-update/`,与 `data/` 完全隔离。远程zip必须经过HTTPS/受信任域名、声明大小、整包SHA-256、安全zip路径和包内manifest逐文件校验,才写 `pending.json`;此阶段不替换任何运行中程序文件。
- T-617 的独立 `cmshopee-updater.exe` 必须先复制到系统临时目录运行,并等待主程序退出。替换粒度是manifest允许的程序根项目,旧根先整体移动到同盘backup,新根再整体移入;事务锁防止并发更新,journal记录每次移动,任一步失败逆序恢复。`data/`、更新管理目录与未知安装根项目永不进入替换清单。 - T-617 的独立 `cmshopee-updater.exe` 必须先复制到系统临时目录运行,并等待主程序退出。替换粒度是manifest允许的程序根项目,旧根先整体移动到同盘backup,新根再整体移入;事务锁防止并发更新,journal记录每次移动,任一步失败逆序恢复。`data/`、更新管理目录与未知安装根项目永不进入替换清单。
- T-618 在创建 `MainWindow` 前显示强制升级 `QDialog`;下载与校验只能在 `QObject + QThread` worker中执行,线程结束前保留引用,取消时等待part清理。独立更新器进程成功创建后才退出旧主程序;强制版本后续失败保持阻断,只有版本接口本身不可达/非法继续失败放行。
- CDP 交互事实变化同步第七节。 - CDP 交互事实变化同步第七节。
- 正式代码只放 `app/` 包;根目录只保留 `main.py`、配置/数据目录、文档和原型目录,不新增正式业务模块。 - 正式代码只放 `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-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:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,点击「开始更新」后必须弹窗确认当前筛选范围、任务数量和线上提交风险。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。
## 启动强制升级门禁
创建五个业务Tab之前先请求版本接口。服务端明确要求强制升级时,不创建 `MainWindow`,而是显示「必须升级」模态进度窗口:用户点击「立即升级」后可看到下载、校验、准备新版和重启阶段,以及百分比和字节数;运行中可「取消并退出」,失败后可重试。校验完成后软件启动安装目录外的独立更新器并退出,更新器替换程序后自动重启新版。版本接口完全不可达或非法时仍失败放行;一旦已明确强制,元数据缺失或后续失败都不允许进入旧版主界面。
## 全局 Tab 栏可用性 ## 全局 Tab 栏可用性
当前 5 个主 Tab 是高频导航入口,不能使用 Qt 默认的紧凑宽度。`MainWindow` 必须为 `QTabWidget/QTabBar` 设置基础样式: 当前 5 个主 Tab 是高频导航入口,不能使用 Qt 默认的紧凑宽度。`MainWindow` 必须为 `QTabWidget/QTabBar` 设置基础样式:
+4 -2
View File
@@ -3,7 +3,7 @@ id: T-618
title: 强制升级进度窗口与自动下载重启编排 title: 强制升级进度窗口与自动下载重启编排
phase: 8 phase: 8
deps: [T-616, T-617] deps: [T-616, T-617]
status: TODO status: DONE
created: 2026-07-13 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`「已允许继续使用」)并**放行**,不因服务器故障导致全员打不开。 - **失败放行**:接口断网、超时、返回非法 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-616 已实现但尚未接入GUI的安全暂存层:仅接受受信任域名的 HTTPS 地址,流式下载到安装目录 `.cmshopee-update/`,校验zip大小和SHA-256,安全解压后再按包内manifest逐文件校验。任一步失败都不修改当前程序或 `data/`;GUI接入由T-618完成。
T-617 已提供独立无控制台更新器和事务回滚能力:更新器从系统临时目录运行,旧主程序退出后才切换程序根项目,并在新版进程无法创建时恢复旧版。启动检查弹窗尚未调用该流程,直到T-618完成GUI编排。 T-617 已提供独立无控制台更新器和事务回滚能力:更新器从系统临时目录运行,旧主程序退出后才切换程序根项目,并在新版进程无法创建时恢复旧版。T-618 已将其接入启动强制升级进度窗口。
## 五、发版约定(服务端据此控制) ## 五、发版约定(服务端据此控制)
+145 -57
View File
@@ -3,6 +3,7 @@ import unittest
import os import os
import sys import sys
import threading import threading
import time
from types import SimpleNamespace from types import SimpleNamespace
from unittest import mock from unittest import mock
@@ -12,7 +13,17 @@ sys.path.insert(0, os.path.dirname(__file__))
from _helpers import TempDirMixin from _helpers import TempDirMixin
from app import gui 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: if gui.QT_IMPORT_ERROR is not None:
raise unittest.SkipTest("PySide6 未安装") raise unittest.SkipTest("PySide6 未安装")
@@ -42,12 +53,14 @@ from app.gui import (
CollectTab, CollectTab,
GenerateWorker, GenerateWorker,
GenerateTab, GenerateTab,
ForcedUpdateDialog,
ImageStudioTab, ImageStudioTab,
MainWindow, MainWindow,
SettingsTab, SettingsTab,
TAB_STYLE, TAB_STYLE,
TAB_TITLES, TAB_TITLES,
WriteBackWorker, WriteBackWorker,
UpdatePreparationWorker,
) )
from app.gui import file_manager from app.gui import file_manager
import app.gui.workers as gui_workers import app.gui.workers as gui_workers
@@ -1573,82 +1586,157 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertNotIn("/api/v1", message) self.assertNotIn("/api/v1", message)
self.assertNotIn("/generated/images", message) self.assertNotIn("/generated/images", message)
def test_startup_update_gate_forced_blocks_and_opens_download(self): def test_startup_update_gate_forced_blocks_and_uses_upgrade_dialog(self):
boxes = [] dialogs = []
class FakeButton: class FakeDialog:
def __init__(self, label): def __init__(self, result, parent=None):
self.label = label self.result = result
self.enabled = True
def setEnabled(self, enabled):
self.enabled = enabled
class FakeMessageBox:
Warning = object()
AcceptRole = object()
RejectRole = object()
def __init__(self, parent=None):
self.parent = parent self.parent = parent
self.icon = None self.executed = False
self.title = "" dialogs.append(self)
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
def exec(self): def exec(self):
self.executed = True
return 0 return 0
def clickedButton(self):
return self.buttons["下载新版"]
result = update_check.UpdateCheckResult( result = update_check.UpdateCheckResult(
current_version="1.0.0", current_version="1.0.0",
checked=True, checked=True,
forced=True, forced=True,
latest_version="1.2.0", latest_version="1.2.0",
min_supported_version="1.1.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="必须升级", message="必须升级",
) )
opened = []
with mock.patch("app.gui.QMessageBox", FakeMessageBox):
allowed = gui._run_startup_update_gate( allowed = gui._run_startup_update_gate(
checker=lambda: result, checker=lambda: result,
opener=opened.append, dialog_factory=FakeDialog,
) )
self.assertFalse(allowed) self.assertFalse(allowed)
self.assertEqual(["https://example.test/cmshopee.zip"], opened) self.assertEqual(1, len(dialogs))
self.assertEqual("必须升级", boxes[0].title) self.assertTrue(dialogs[0].executed)
self.assertIn("当前版本:1.0.0", boxes[0].informative_text) self.assertIs(result, dialogs[0].result)
self.assertIn("线上版本:1.2.0", boxes[0].informative_text)
self.assertIn("保留 data/ 目录", boxes[0].informative_text) def test_forced_update_dialog_prepares_and_launches_updater_without_blocking_ui(self):
self.assertEqual(boxes[0].buttons["下载新版"], boxes[0].default_button) 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): def test_startup_update_gate_check_failure_allows_entry_and_logs(self):
result = update_check.UpdateCheckResult( result = update_check.UpdateCheckResult(
+4
View File
@@ -46,6 +46,9 @@ class UpdateCheckTests(unittest.TestCase):
"size_bytes": 123, "size_bytes": 123,
"package_format": "cmshopee-portable-v1", "package_format": "cmshopee-portable-v1",
"updater_protocol": 1, "updater_protocol": 1,
"min_updater_protocol": 1,
"signature_algorithm": "",
"manifest_signature": "",
"message": "请升级后继续使用", "message": "请升级后继续使用",
} }
@@ -62,6 +65,7 @@ class UpdateCheckTests(unittest.TestCase):
self.assertEqual("https://example.test/cmshopee.zip", result.download_url) self.assertEqual("https://example.test/cmshopee.zip", result.download_url)
self.assertEqual(123, result.size_bytes) self.assertEqual(123, result.size_bytes)
self.assertEqual("cmshopee-portable-v1", result.package_format) 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 test_check_for_update_accepts_release_wrapper_response(self):
def fetcher(_url, _timeout): def fetcher(_url, _timeout):