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()