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