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 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 = ""
|
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
@@ -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)
|
||||||
|
|||||||
@@ -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`、配置/数据目录、文档和原型目录,不新增正式业务模块。
|
||||||
|
|||||||
@@ -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策略失败放行。
|
||||||
|
|
||||||
## 四、绝不打包的本地数据
|
## 四、绝不打包的本地数据
|
||||||
|
|
||||||
发布包里不能包含以下本地数据、密钥、业务数据或登录态:
|
发布包里不能包含以下本地数据、密钥、业务数据或登录态:
|
||||||
|
|||||||
@@ -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
@@ -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`。自动化测试未访问真实线上下载地址。
|
||||||
|
|||||||
@@ -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 已将其接入启动强制升级进度窗口。
|
||||||
|
|
||||||
## 五、发版约定(服务端据此控制)
|
## 五、发版约定(服务端据此控制)
|
||||||
|
|
||||||
|
|||||||
+148
-60
@@ -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,
|
dialog_factory=FakeDialog,
|
||||||
opener=opened.append,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
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(
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user