From 6d6829e374a613b5d31590cd2c5a330e8f97489b Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 10 Aug 2026 12:32:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20Client=20=E5=9C=A8?= =?UTF-8?q?=E7=BA=BF=E6=9B=B4=E6=96=B0=E4=B8=8E=E5=AE=89=E5=85=A8=E5=9B=9E?= =?UTF-8?q?=E9=80=80=20(#93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/build_tools/launcher.py | 276 ++++++++++++++++- client/src/settings_ui.py | 59 +++- client/src/settings_ui_event.py | 11 +- client/src/ui_main.py | 6 + client/src/update_service.py | 439 ++++++++++++++++++++++++++++ client/src/update_ui_event.py | 295 +++++++++++++++++++ client/src/version.py | 2 +- client/test/test_packaging.py | 100 ++++++- client/test/test_update_service.py | 203 +++++++++++++ client/test/test_update_ui_event.py | 174 +++++++++++ docs/client/01-requirements.md | 15 +- docs/client/02-architecture.md | 20 ++ docs/client/05-ui-specification.md | 12 + docs/client/06-quality-security.md | 15 + 14 files changed, 1609 insertions(+), 18 deletions(-) create mode 100644 client/src/update_service.py create mode 100644 client/src/update_ui_event.py create mode 100644 client/test/test_update_service.py create mode 100644 client/test/test_update_ui_event.py diff --git a/client/build_tools/launcher.py b/client/build_tools/launcher.py index 4b58040..8187411 100644 --- a/client/build_tools/launcher.py +++ b/client/build_tools/launcher.py @@ -1,14 +1,28 @@ -"""发布包的轻量启动器。 +"""发布包的轻量启动器和下次启动更新器。 -启动器不联网、不更新文件,只负责从发布根目录启动 ``app/CMAutoBuy.exe``。 +Launcher 不联网。主程序已经下载并校验好 ``data/update/app.new`` 后,本模块在 +主程序未运行时替换 ``app``,失败时恢复 ``app.old``。 """ from __future__ import annotations import ctypes +import json +import os +import shutil import subprocess import sys +import time from pathlib import Path +from typing import Callable, Optional + + +PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +HEALTH_WAIT_SECONDS = 15.0 + + +class UpdateApplyError(RuntimeError): + """Launcher 无法安全应用或恢复更新。""" def install_root(executable: str | None = None) -> Path: @@ -23,29 +37,265 @@ def app_executable(root: Path) -> Path: return root / "app" / "CMAutoBuy.exe" +def update_directory(root: Path) -> Path: + """返回在线更新状态目录。""" + + return root / "data" / "update" + + def show_error(message: str) -> None: """使用 Windows 原生对话框显示启动错误。""" ctypes.windll.user32.MessageBoxW(0, message, "商品采集采购工具", 0x10) -def main() -> int: - """启动主程序;找不到程序时返回非零退出码。""" +def show_information(message: str) -> None: + """使用 Windows 原生对话框显示普通提示。""" - root = install_root() + ctypes.windll.user32.MessageBoxW(0, message, "商品采集采购工具", 0x40) + + +def _read_json(path: Path) -> Optional[dict]: + try: + value = json.loads(path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return None + return value if isinstance(value, dict) else None + + +def _write_json_atomic(path: Path, value: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text( + json.dumps(value, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + os.replace(str(temporary), str(path)) + + +def _safe_remove(root: Path, path: Path) -> None: + """只删除发布根目录内明确的更新目标。""" + + root_resolved = root.resolve() + path_resolved = path.resolve() + if path_resolved == root_resolved or not path_resolved.is_relative_to(root_resolved): + raise UpdateApplyError(f"拒绝删除发布目录之外的路径:{path_resolved}") + if not path.exists(): + return + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() + + +def _health_matches(directory: Path, version: str) -> bool: + health = _read_json(directory / "healthy.json") + return health is not None and health.get("version") == version + + +def is_process_running(pid: int, expected_executable: Path) -> bool: + """确认 PID 仍存在,并且确实是本发布目录的主程序。""" + + if pid <= 0 or sys.platform != "win32": + return False + kernel32 = ctypes.windll.kernel32 + kernel32.OpenProcess.argtypes = [ + ctypes.c_ulong, + ctypes.c_int, + ctypes.c_ulong, + ] + kernel32.OpenProcess.restype = ctypes.c_void_p + kernel32.QueryFullProcessImageNameW.argtypes = [ + ctypes.c_void_p, + ctypes.c_ulong, + ctypes.c_wchar_p, + ctypes.POINTER(ctypes.c_ulong), + ] + kernel32.QueryFullProcessImageNameW.restype = ctypes.c_int + kernel32.CloseHandle.argtypes = [ctypes.c_void_p] + kernel32.CloseHandle.restype = ctypes.c_int + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return False + try: + buffer = ctypes.create_unicode_buffer(32768) + size = ctypes.c_ulong(len(buffer)) + if not kernel32.QueryFullProcessImageNameW(handle, 0, buffer, ctypes.byref(size)): + return False + actual = Path(buffer.value).resolve() + return str(actual).casefold() == str(expected_executable.resolve()).casefold() + finally: + kernel32.CloseHandle(handle) + + +def is_main_program_running( + root: Path, + checker: Callable[[int, Path], bool] = is_process_running, +) -> bool: + """读取上次启动 PID,判断主程序是否仍在运行。""" + + directory = update_directory(root) + running_file = directory / "running.json" + running = _read_json(running_file) + if running is None: + return False + pid = running.get("pid") + if isinstance(pid, int) and checker(pid, app_executable(root)): + return True + try: + running_file.unlink(missing_ok=True) + except OSError: + pass + return False + + +def _validate_pending(pending: dict) -> tuple[str, str]: + version = pending.get("version") + state = pending.get("state") + if ( + pending.get("schema_version") != 1 + or not isinstance(version, str) + or state not in {"ready", "applied"} + ): + raise UpdateApplyError("待更新状态文件无效") + return version, state + + +def finalize_applied_update(root: Path) -> None: + """健康启动后清除待更新状态,保留一份 app.old。""" + + directory = update_directory(root) + (directory / "pending.json").unlink(missing_ok=True) + _safe_remove(root, directory / "app.new") + + +def rollback_applied_update(root: Path, version: str, reason: str) -> None: + """把未健康启动的新 app 移走,并恢复 app.old。""" + + directory = update_directory(root) + current_app = root / "app" + old_app = root / "app.old" + failed_app = directory / "app.failed" + if not old_app.is_dir(): + raise UpdateApplyError("新版本启动失败,但没有可恢复的 app.old") + + _safe_remove(root, failed_app) + if current_app.exists(): + os.replace(str(current_app), str(failed_app)) + try: + os.replace(str(old_app), str(current_app)) + except OSError as exc: + if failed_app.exists() and not current_app.exists(): + os.replace(str(failed_app), str(current_app)) + raise UpdateApplyError(f"恢复旧版本失败:{exc}") from exc + + (directory / "pending.json").unlink(missing_ok=True) + _write_json_atomic( + directory / "last_error.json", + {"schema_version": 1, "version": version, "reason": reason}, + ) + + +def apply_pending_update(root: Path) -> Optional[str]: + """应用已暂存更新,返回刚应用的版本;没有更新时返回 None。""" + + directory = update_directory(root) + pending_path = directory / "pending.json" + if not pending_path.exists(): + return None + pending = _read_json(pending_path) + if pending is None: + raise UpdateApplyError("待更新状态文件损坏,请删除 data/update/pending.json 后重试") + version, state = _validate_pending(pending) + + if state == "applied": + if _health_matches(directory, version): + finalize_applied_update(root) + return None + rollback_applied_update(root, version, "新版本上次启动未通过健康检查") + return None + + current_app = root / "app" + staged_app = directory / "app.new" + old_app = root / "app.old" + if not current_app.is_dir() or not (current_app / "CMAutoBuy.exe").is_file(): + raise UpdateApplyError("当前 app 目录不完整,不能应用更新") + if not staged_app.is_dir() or not (staged_app / "CMAutoBuy.exe").is_file(): + raise UpdateApplyError("已下载更新不完整,请在设置页重新下载") + + _safe_remove(root, old_app) + (directory / "healthy.json").unlink(missing_ok=True) + os.replace(str(current_app), str(old_app)) + try: + os.replace(str(staged_app), str(current_app)) + pending["state"] = "applied" + _write_json_atomic(pending_path, pending) + except Exception as exc: + if current_app.exists() and not staged_app.exists(): + os.replace(str(current_app), str(staged_app)) + if old_app.exists() and not current_app.exists(): + os.replace(str(old_app), str(current_app)) + raise UpdateApplyError(f"应用更新失败,已恢复旧版本:{exc}") from exc + return version + + +def wait_for_health(process, root: Path, version: str) -> Optional[bool]: + """等待健康标记;成功=True,提前退出=False,超时仍运行=None。""" + + directory = update_directory(root) + deadline = time.monotonic() + HEALTH_WAIT_SECONDS + while time.monotonic() < deadline: + if _health_matches(directory, version): + return True + if process.poll() is not None: + return False + time.sleep(0.25) + return None + + +def _start_main_program(root: Path): target = app_executable(root) if not target.is_file(): - show_error( - "主程序不存在,请确认 app 文件夹完整。\n\n" - f"缺少文件:{target}" - ) - return 1 + raise OSError(f"主程序不存在:{target}") + process = subprocess.Popen([str(target)], cwd=str(target.parent)) + _write_json_atomic( + update_directory(root) / "running.json", + {"schema_version": 1, "pid": process.pid}, + ) + return process + + +def main() -> int: + """安全应用待更新目录,然后启动主程序。""" + + root = install_root() + if is_main_program_running(root): + show_information("商品采集采购工具已经在运行,请勿重复启动。") + return 0 try: - subprocess.Popen([str(target)], cwd=str(target.parent)) - except OSError as exc: - show_error(f"主程序启动失败。\n\n{exc}") + applied_version = apply_pending_update(root) + process = _start_main_program(root) + except (OSError, UpdateApplyError) as exc: + show_error(f"主程序启动或更新失败。\n\n{exc}") return 1 + + if applied_version: + health = wait_for_health(process, root, applied_version) + if health is True: + finalize_applied_update(root) + elif health is False: + try: + rollback_applied_update( + root, + applied_version, + "新版本启动后提前退出", + ) + _start_main_program(root) + except (OSError, UpdateApplyError) as exc: + show_error(f"新版本启动失败,恢复旧版本时发生错误。\n\n{exc}") + return 1 + show_error("新版本未能正常启动,已自动恢复并启动旧版本。") return 0 diff --git a/client/src/settings_ui.py b/client/src/settings_ui.py index 68517a8..9931ae8 100644 --- a/client/src/settings_ui.py +++ b/client/src/settings_ui.py @@ -1,6 +1,6 @@ """设置页的界面结构。 -本文件只负责显示设备信息和提供界面更新入口。按钮事件统一由 +本文件只负责显示设备和软件更新信息,并提供界面更新入口。按钮事件统一由 ``settings_ui_event.py`` 绑定;ADB、SQLite 和硬件信息读取不得放在这里。 改动本文件前必读 ``client/AGENTS.md``。页面 ``objectName`` 固定为 @@ -32,6 +32,8 @@ from qfluentwidgets import ( TitleLabel, ) +from .version import __version__ + @dataclass(frozen=True) class AndroidDeviceRow: @@ -207,6 +209,7 @@ class SettingsPage(QWidget): settings_repository=None, admin_gateway=None, android_device_service=None, + update_service=None, ): super().__init__(parent) self.setObjectName("settingsPage") @@ -220,6 +223,7 @@ class SettingsPage(QWidget): settings_repository=settings_repository, admin_gateway=admin_gateway, android_device_service=android_device_service, + update_service=update_service, ) def _build_ui(self) -> None: @@ -289,6 +293,23 @@ class SettingsPage(QWidget): self.pddAppStatusLabel.setWordWrap(True) self.androidDeviceCard = self._build_android_device_card() + self.currentVersionLabel = CaptionLabel(__version__, self) + self.currentVersionLabel.setAccessibleName("当前软件版本") + self.updateManifestUrlInput = LineEdit(self) + self.updateManifestUrlInput.setPlaceholderText( + "https://updates.example.com/autobuy——manifest.json" + ) + self.updateManifestUrlInput.setClearButtonEnabled(True) + self.updateManifestUrlInput.setAccessibleName("在线更新清单地址") + self.updateCheckButton = PushButton(FIF.UPDATE, "检查更新", self) + self.updateCheckButton.setAccessibleName("检查并下载软件更新") + self.updateStatusLabel = CaptionLabel( + "尚未检查;请填写 HTTPS 更新清单地址", self + ) + self.updateStatusLabel.setAccessibleName("软件更新状态") + self.updateStatusLabel.setWordWrap(True) + self.softwareUpdateCard = self._build_software_update_card() + content = QWidget(self) content.setObjectName("settingsContent") contentLayout = QVBoxLayout(content) @@ -297,6 +318,7 @@ class SettingsPage(QWidget): contentLayout.addWidget(TitleLabel("设置", content)) contentLayout.addWidget(self.currentDeviceCard) contentLayout.addWidget(self.androidDeviceCard) + contentLayout.addWidget(self.softwareUpdateCard) contentLayout.addStretch(1) scrollArea = ScrollArea(self) @@ -383,6 +405,36 @@ class SettingsPage(QWidget): layout.addWidget(self.deviceTable, 1) return card + def _build_software_update_card(self) -> CardWidget: + card = CardWidget(self) + card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + layout = QVBoxLayout(card) + layout.setContentsMargins(24, 20, 24, 22) + layout.setSpacing(12) + + titleLayout = QHBoxLayout() + titleLayout.setSpacing(12) + titleLayout.addWidget(SubtitleLabel("软件更新", card)) + titleLayout.addStretch(1) + titleLayout.addWidget(self.updateStatusLabel, 1) + layout.addLayout(titleLayout) + + form = QFormLayout() + form.setHorizontalSpacing(16) + form.setVerticalSpacing(12) + versionLabel = CaptionLabel("当前版本", card) + manifestLabel = CaptionLabel("清单地址", card) + manifestLabel.setBuddy(self.updateManifestUrlInput) + form.addRow(versionLabel, self.currentVersionLabel) + form.addRow(manifestLabel, self.updateManifestUrlInput) + layout.addLayout(form) + + commandLayout = QHBoxLayout() + commandLayout.addStretch(1) + commandLayout.addWidget(self.updateCheckButton) + layout.addLayout(commandLayout) + return card + def set_client_info(self, device_id: str, device_name: str) -> None: """显示后续设备身份服务提供的当前客户端信息。""" @@ -405,6 +457,11 @@ class SettingsPage(QWidget): self.pddAppStatusLabel.setText(message) + def set_update_status(self, message: str) -> None: + """显示在线更新的检查、下载或恢复提示。""" + + self.updateStatusLabel.setText(message) + def set_saved_android_device(self, serial: str) -> None: """显示已经保存并实际用于自动化的 Android 设备。""" diff --git a/client/src/settings_ui_event.py b/client/src/settings_ui_event.py index eead193..2523ccf 100644 --- a/client/src/settings_ui_event.py +++ b/client/src/settings_ui_event.py @@ -1,4 +1,4 @@ -"""设置页事件、ADB 搜索、当前 Client 本地保存和后台登记。 +"""设置页事件、ADB 搜索、Client 保存登记和软件更新。 本文件不直接写 SQL。ADB 搜索、SQLite 写入和 Admin HTTP 请求由 Worker 在线程中 执行;页面启动时只同步读取少量索引设置,后台结果通过信号返回主线程更新页面。 @@ -37,6 +37,7 @@ from .http_admin_gateway import DEFAULT_ADMIN_BASE_URL, HttpAdminGateway from .selected_android_device_service import SelectedAndroidDeviceService from .settings_repository import SettingsRepository from .settings_ui import AndroidDeviceRow +from .update_ui_event import UpdateUiEventBinder DEVICE_ID_PLACEHOLDER = "待生成" @@ -308,6 +309,7 @@ class SettingsPageEventBinder(QObject): settings_repository: Optional[SettingsRepository] = None, admin_gateway: Optional[ClientRegistrationGateway] = None, android_device_service: Optional[AndroidDeviceService] = None, + update_service=None, ): super().__init__(page) self._page = page @@ -343,6 +345,12 @@ class SettingsPageEventBinder(QObject): ) repository = settings_repository or SettingsRepository() + self.updateEventBinder = UpdateUiEventBinder( + page, + repository, + service=update_service, + parent=self, + ) self._client_service = CurrentClientService(repository) self._selected_android_device_service = SelectedAndroidDeviceService( repository @@ -1147,6 +1155,7 @@ class SettingsPageEventBinder(QObject): if self._closing: return self._closing = True + self.updateEventBinder.shutdown() worker = self._worker thread = self._thread diff --git a/client/src/ui_main.py b/client/src/ui_main.py index 76c7117..9e33de9 100644 --- a/client/src/ui_main.py +++ b/client/src/ui_main.py @@ -32,6 +32,7 @@ from .pdd_ui_event import PDDTaskPageEvent from .pdd_u2_purchase_adapter import create_u2_purchase_adapter from .settings_ui import SettingsPage from .task_repository import TaskRepository +from .update_service import mark_current_version_healthy class MainWindow(FluentWindow): @@ -96,6 +97,11 @@ def ui_main(): window = MainWindow() window.show() + try: + mark_current_version_healthy() + except OSError: + # 健康标记失败不能让界面崩溃;Launcher 会保留旧版本并在下次启动恢复。 + pass return app.exec_() diff --git a/client/src/update_service.py b/client/src/update_service.py new file mode 100644 index 0000000..b5e3651 --- /dev/null +++ b/client/src/update_service.py @@ -0,0 +1,439 @@ +"""Client 在线更新的检查、下载、校验和安全暂存。 + +本模块不访问 Qt,也不替换正在运行的程序。Launcher 只在下次启动时应用这里 +准备好的 ``data/update/app.new``。 +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import stat +import urllib.error +import urllib.parse +import urllib.request +import zipfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Callable, Optional + +from .db import data_dir +from .version import __version__ + + +MAX_MANIFEST_BYTES = 1024 * 1024 +MAX_UPDATE_BYTES = 500 * 1024 * 1024 +MAX_EXTRACTED_BYTES = 1024 * 1024 * 1024 +UPDATE_MANIFEST_SETTING = "update.manifest_url" +_VERSION_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$") +_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") + + +class UpdateError(RuntimeError): + """在线更新失败。""" + + +class UpdateConfigurationError(UpdateError): + """更新地址或清单配置不合法。""" + + +class UpdateNetworkError(UpdateError): + """更新服务器访问失败。""" + + +class UpdateIntegrityError(UpdateError): + """更新文件大小、哈希或版本不一致。""" + + +class UnsafeUpdateArchiveError(UpdateError): + """更新压缩包包含不安全路径或内容。""" + + +class UpdateCancelled(UpdateError): + """用户关闭页面后取消继续处理更新。""" + + +@dataclass(frozen=True) +class UpdateInfo: + """清单中一份可下载更新的信息。""" + + version: str + manifest_url: str + update_url: str + file_name: str + size: int + sha256: str + + +@dataclass(frozen=True) +class UpdateCheckResult: + """检查更新结果。""" + + current_version: str + latest_version: str + available: bool + update: Optional[UpdateInfo] = None + + +def parse_version(version: str) -> tuple[int, int, int, int]: + """把三段或四段数字版本转换为可比较元组。""" + + match = _VERSION_PATTERN.fullmatch(version.strip()) + if match is None: + raise UpdateConfigurationError( + f"版本号格式无效:{version!r};应为 1.2.3 或 1.2.3.4" + ) + numbers = [int(part) for part in version.strip().split(".")] + while len(numbers) < 4: + numbers.append(0) + return tuple(numbers) # type: ignore[return-value] + + +def validate_manifest_url(url: str) -> str: + """验证并返回只允许 HTTPS、且不含凭据的清单地址。""" + + normalized = url.strip() + parsed = urllib.parse.urlsplit(normalized) + if parsed.scheme.lower() != "https" or not parsed.hostname: + raise UpdateConfigurationError("更新清单地址必须是有效的 HTTPS 地址") + if parsed.username is not None or parsed.password is not None: + raise UpdateConfigurationError("更新清单地址不能包含账号或密码") + if parsed.query: + raise UpdateConfigurationError("更新清单地址不能包含查询参数,避免把凭据写入本地") + if parsed.fragment: + raise UpdateConfigurationError("更新清单地址不能包含 # 片段") + return normalized + + +def _origin(url: str) -> tuple[str, str, int]: + parsed = urllib.parse.urlsplit(url) + return ( + parsed.scheme.lower(), + (parsed.hostname or "").lower(), + parsed.port or 443, + ) + + +class UpdateService: + """检查并把更新安全暂存到 ``data/update``。""" + + def __init__( + self, + update_directory: Optional[Path] = None, + urlopen: Callable = urllib.request.urlopen, + timeout_seconds: float = 10.0, + ): + self._update_directory = update_directory or (data_dir() / "update") + self._urlopen = urlopen + self._timeout_seconds = timeout_seconds + + @property + def update_directory(self) -> Path: + return self._update_directory + + def check( + self, + manifest_url: str, + current_version: str = __version__, + is_cancelled: Optional[Callable[[], bool]] = None, + ) -> UpdateCheckResult: + """下载并解析清单,返回是否存在新版本。""" + + configured_url = validate_manifest_url(manifest_url) + parse_version(current_version) + content, final_manifest_url = self._read_url( + configured_url, + MAX_MANIFEST_BYTES, + is_cancelled, + ) + try: + manifest = json.loads(content.decode("utf-8-sig")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise UpdateConfigurationError("更新清单不是有效的 UTF-8 JSON") from exc + + info = self._parse_manifest(manifest, final_manifest_url) + available = parse_version(info.version) > parse_version(current_version) + return UpdateCheckResult( + current_version=current_version, + latest_version=info.version, + available=available, + update=info if available else None, + ) + + def download_and_stage( + self, + info: UpdateInfo, + is_cancelled: Optional[Callable[[], bool]] = None, + on_progress: Optional[Callable[[int], None]] = None, + ) -> Path: + """下载、校验并安全解压更新,返回暂存的 ``app.new``。""" + + self._raise_if_cancelled(is_cancelled) + update_directory = self._update_directory + update_directory.mkdir(parents=True, exist_ok=True) + download_path = update_directory / "download.tmp" + extracting_directory = update_directory / "extracting" + staged_app = update_directory / "app.new" + + self._remove_path(download_path) + self._remove_path(extracting_directory) + digest = hashlib.sha256() + bytes_written = 0 + + try: + request = urllib.request.Request( + info.update_url, + headers={"User-Agent": f"CMAutoBuy/{__version__}"}, + ) + with self._open(request) as response: + final_url = validate_manifest_url(response.geturl()) + if _origin(final_url) != _origin(info.manifest_url): + raise UpdateConfigurationError("更新包必须与更新清单来自同一服务器") + declared_size = self._content_length(response) + if declared_size is not None and declared_size != info.size: + raise UpdateIntegrityError("更新包服务器大小与清单不一致") + + with download_path.open("wb") as output: + while True: + self._raise_if_cancelled(is_cancelled) + block = response.read(1024 * 1024) + if not block: + break + bytes_written += len(block) + if bytes_written > MAX_UPDATE_BYTES or bytes_written > info.size: + raise UpdateIntegrityError("更新包大小超过清单或安全限制") + output.write(block) + digest.update(block) + if on_progress is not None and info.size: + on_progress(min(100, bytes_written * 100 // info.size)) + + if bytes_written != info.size: + raise UpdateIntegrityError("更新包实际大小与清单不一致") + if digest.hexdigest() != info.sha256: + raise UpdateIntegrityError("更新包 SHA256 与清单不一致") + + extracted_app = self._extract_safely( + download_path, + extracting_directory, + info.version, + is_cancelled, + ) + self._remove_path(staged_app) + os.replace(str(extracted_app), str(staged_app)) + self._write_json_atomic( + update_directory / "pending.json", + { + "schema_version": 1, + "state": "ready", + "version": info.version, + "sha256": info.sha256, + "file": info.file_name, + }, + ) + if on_progress is not None: + on_progress(100) + return staged_app + except UpdateError: + raise + except (OSError, zipfile.BadZipFile) as exc: + raise UpdateError(f"无法暂存更新:{exc}") from exc + finally: + self._remove_path(download_path) + self._remove_path(extracting_directory) + + def _parse_manifest(self, manifest, manifest_url: str) -> UpdateInfo: + if not isinstance(manifest, dict): + raise UpdateConfigurationError("更新清单根节点必须是对象") + if manifest.get("schema_version") != 1: + raise UpdateConfigurationError("不支持的更新清单版本") + if manifest.get("product") != "CMAutoBuy": + raise UpdateConfigurationError("更新清单不属于 CMAutoBuy") + + version = manifest.get("version") + update = manifest.get("update") + if not isinstance(version, str) or not isinstance(update, dict): + raise UpdateConfigurationError("更新清单缺少版本或更新包信息") + parse_version(version) + + file_name = update.get("file") + size = update.get("size") + sha256 = update.get("sha256") + if ( + not isinstance(file_name, str) + or not file_name + or PurePosixPath(file_name).name != file_name + or "\\" in file_name + ): + raise UpdateConfigurationError("更新包文件名无效") + if not isinstance(size, int) or isinstance(size, bool) or not 0 < size <= MAX_UPDATE_BYTES: + raise UpdateConfigurationError("更新包大小无效或超过安全限制") + if not isinstance(sha256, str) or not _SHA256_PATTERN.fullmatch(sha256): + raise UpdateConfigurationError("更新包 SHA256 格式无效") + + update_url = urllib.parse.urljoin( + manifest_url, + urllib.parse.quote(file_name), + ) + validate_manifest_url(update_url) + if _origin(update_url) != _origin(manifest_url): + raise UpdateConfigurationError("更新包必须与更新清单来自同一服务器") + return UpdateInfo( + version=version, + manifest_url=manifest_url, + update_url=update_url, + file_name=file_name, + size=size, + sha256=sha256, + ) + + def _read_url( + self, + url: str, + maximum_bytes: int, + is_cancelled: Optional[Callable[[], bool]], + ) -> tuple[bytes, str]: + request = urllib.request.Request( + url, + headers={"User-Agent": f"CMAutoBuy/{__version__}"}, + ) + with self._open(request) as response: + final_url = validate_manifest_url(response.geturl()) + declared_size = self._content_length(response) + if declared_size is not None and declared_size > maximum_bytes: + raise UpdateConfigurationError("更新清单超过安全大小限制") + chunks = [] + total = 0 + while True: + self._raise_if_cancelled(is_cancelled) + block = response.read(64 * 1024) + if not block: + break + total += len(block) + if total > maximum_bytes: + raise UpdateConfigurationError("更新清单超过安全大小限制") + chunks.append(block) + return b"".join(chunks), final_url + + def _open(self, request): + try: + return self._urlopen(request, timeout=self._timeout_seconds) + except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError) as exc: + raise UpdateNetworkError(f"无法连接更新服务器:{exc}") from exc + + @staticmethod + def _content_length(response) -> Optional[int]: + value = response.headers.get("Content-Length") + if value is None: + return None + try: + size = int(value) + except (TypeError, ValueError) as exc: + raise UpdateConfigurationError("服务器返回了无效的文件大小") from exc + if size < 0: + raise UpdateConfigurationError("服务器返回了无效的文件大小") + return size + + def _extract_safely( + self, + archive_path: Path, + destination: Path, + expected_version: str, + is_cancelled: Optional[Callable[[], bool]], + ) -> Path: + destination.mkdir(parents=True, exist_ok=False) + destination_resolved = destination.resolve() + total_size = 0 + + with zipfile.ZipFile(archive_path) as archive: + members = archive.infolist() + if not members: + raise UnsafeUpdateArchiveError("更新压缩包为空") + for member in members: + self._raise_if_cancelled(is_cancelled) + if "\\" in member.filename: + raise UnsafeUpdateArchiveError("更新压缩包包含非法路径分隔符") + relative = PurePosixPath(member.filename) + if ( + relative.is_absolute() + or not relative.parts + or relative.parts[0] != "app" + or any(part in {"", ".", ".."} for part in relative.parts) + ): + raise UnsafeUpdateArchiveError("更新压缩包只能包含安全的 app/ 内容") + file_type = (member.external_attr >> 16) & 0o170000 + if file_type == stat.S_IFLNK: + raise UnsafeUpdateArchiveError("更新压缩包不能包含符号链接") + total_size += member.file_size + if total_size > MAX_EXTRACTED_BYTES: + raise UnsafeUpdateArchiveError("更新解压后超过安全大小限制") + + target = destination.joinpath(*relative.parts) + if not target.resolve().is_relative_to(destination_resolved): + raise UnsafeUpdateArchiveError("更新压缩包路径越界") + if member.is_dir(): + target.mkdir(parents=True, exist_ok=True) + continue + target.parent.mkdir(parents=True, exist_ok=True) + with archive.open(member) as source, target.open("wb") as output: + while True: + self._raise_if_cancelled(is_cancelled) + block = source.read(1024 * 1024) + if not block: + break + output.write(block) + + app_directory = destination / "app" + executable = app_directory / "CMAutoBuy.exe" + version_file = app_directory / "version.txt" + if not executable.is_file() or not version_file.is_file(): + raise UnsafeUpdateArchiveError("更新包缺少主程序或 version.txt") + try: + packaged_version = version_file.read_text(encoding="utf-8-sig").strip() + except (OSError, UnicodeDecodeError) as exc: + raise UpdateIntegrityError("无法读取更新包版本") from exc + if packaged_version != expected_version: + raise UpdateIntegrityError("更新包版本与清单不一致") + return app_directory + + @staticmethod + def _raise_if_cancelled(is_cancelled: Optional[Callable[[], bool]]) -> None: + if is_cancelled is not None and is_cancelled(): + raise UpdateCancelled("更新操作已取消") + + def _remove_path(self, path: Path) -> None: + if not path.exists(): + return + resolved = path.resolve() + root = self._update_directory.resolve() + if resolved == root or not resolved.is_relative_to(root): + raise UpdateError(f"拒绝清理更新目录之外的路径:{resolved}") + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() + + @staticmethod + def _write_json_atomic(path: Path, value: dict) -> None: + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text( + json.dumps(value, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + os.replace(str(temporary), str(path)) + + +def mark_current_version_healthy(update_directory: Optional[Path] = None) -> None: + """主窗口成功创建后写健康标记,供 Launcher 判断新版本能否启动。""" + + directory = update_directory or (data_dir() / "update") + pending = directory / "pending.json" + if not pending.is_file(): + return + directory.mkdir(parents=True, exist_ok=True) + UpdateService._write_json_atomic( + directory / "healthy.json", + {"schema_version": 1, "version": __version__}, + ) diff --git a/client/src/update_ui_event.py b/client/src/update_ui_event.py new file mode 100644 index 0000000..ade539e --- /dev/null +++ b/client/src/update_ui_event.py @@ -0,0 +1,295 @@ +"""设置页在线更新事件和 Qt 后台 Worker。""" + +from __future__ import annotations + +from typing import Optional + +from PyQt5.QtCore import QObject, QThread, pyqtSignal, pyqtSlot +from qfluentwidgets import MessageBox + +from .settings_repository import SettingsRepository +from .update_service import ( + UPDATE_MANIFEST_SETTING, + UpdateCancelled, + UpdateCheckResult, + UpdateInfo, + UpdateService, + validate_manifest_url, +) + + +class UpdateCheckWorker(QObject): + """在线程中保存清单地址并检查新版本。""" + + succeeded = pyqtSignal(object) + failed = pyqtSignal(str) + completed = pyqtSignal() + + def __init__( + self, + service: UpdateService, + repository: SettingsRepository, + manifest_url: str, + ): + super().__init__() + self._service = service + self._repository = repository + self._manifest_url = manifest_url + self._cancelled = False + + def cancel(self) -> None: + self._cancelled = True + + @pyqtSlot() + def run(self) -> None: + try: + normalized_url = validate_manifest_url(self._manifest_url) + if self._cancelled: + return + self._repository.set(UPDATE_MANIFEST_SETTING, normalized_url) + result = self._service.check( + normalized_url, + is_cancelled=lambda: self._cancelled, + ) + if not self._cancelled: + self.succeeded.emit(result) + except UpdateCancelled: + pass + except Exception as exc: + if not self._cancelled: + self.failed.emit(str(exc) or "检查更新失败") + finally: + self.completed.emit() + + +class UpdateDownloadWorker(QObject): + """在线程中下载、校验并安全暂存更新。""" + + progressChanged = pyqtSignal(int) + succeeded = pyqtSignal(str) + failed = pyqtSignal(str) + completed = pyqtSignal() + + def __init__(self, service: UpdateService, update: UpdateInfo): + super().__init__() + self._service = service + self._update = update + self._cancelled = False + + def cancel(self) -> None: + self._cancelled = True + + @pyqtSlot() + def run(self) -> None: + try: + self._service.download_and_stage( + self._update, + is_cancelled=lambda: self._cancelled, + on_progress=self.progressChanged.emit, + ) + if not self._cancelled: + self.succeeded.emit(self._update.version) + except UpdateCancelled: + pass + except Exception as exc: + if not self._cancelled: + self.failed.emit(str(exc) or "下载更新失败") + finally: + self.completed.emit() + + +class UpdateUiEventBinder(QObject): + """管理设置页更新按钮、反馈和两个后台线程。""" + + def __init__( + self, + page, + repository: SettingsRepository, + service: Optional[UpdateService] = None, + parent=None, + ): + super().__init__(parent or page) + self._page = page + self._repository = repository + self._service = service or UpdateService() + self._closing = False + self._check_thread: Optional[QThread] = None + self._check_worker: Optional[UpdateCheckWorker] = None + self._download_thread: Optional[QThread] = None + self._download_worker: Optional[UpdateDownloadWorker] = None + + saved_url = repository.get(UPDATE_MANIFEST_SETTING, "") + page.updateManifestUrlInput.setText( + saved_url if isinstance(saved_url, str) else "" + ) + page.updateCheckButton.clicked.connect(self.request_check) + self._sync_button() + + @pyqtSlot() + def request_check(self) -> None: + if self._closing or self._check_thread is not None or self._download_thread is not None: + return + manifest_url = self._page.updateManifestUrlInput.text().strip() + self._page.updateManifestUrlInput.setText(manifest_url) + try: + validate_manifest_url(manifest_url) + except Exception as exc: + self._page.set_update_status(f"无法检查:{exc}") + self._page.updateManifestUrlInput.setFocus() + return + + self._page.set_update_status("正在检查新版本…") + thread = QThread(self) + worker = UpdateCheckWorker( + self._service, + self._repository, + manifest_url, + ) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.succeeded.connect(self._on_check_succeeded) + worker.failed.connect(self._on_check_failed) + worker.completed.connect(thread.quit) + worker.completed.connect(worker.deleteLater) + thread.finished.connect(self._on_check_finished) + thread.finished.connect(thread.deleteLater) + self._check_thread = thread + self._check_worker = worker + self._sync_button() + thread.start() + + @pyqtSlot(object) + def _on_check_succeeded(self, result: UpdateCheckResult) -> None: + if self._closing: + return + if not result.available or result.update is None: + self._page.set_update_status( + f"当前已是最新版本({result.current_version})" + ) + return + + update = result.update + size_mb = update.size / (1024 * 1024) + dialog = MessageBox( + f"发现新版本 {update.version}", + f"当前版本:{result.current_version}\n" + f"下载大小:{size_mb:.1f} MB\n\n" + "下载完成后不会强制关闭程序,您可以完成当前任务后再重新启动。", + self._page.window(), + ) + dialog.yesButton.setText("下载更新") + dialog.cancelButton.setText("暂不下载") + dialog.cancelButton.setFocus() + if not dialog.exec(): + self._page.set_update_status( + f"发现新版本 {update.version},尚未下载" + ) + return + self._start_download(update) + + @pyqtSlot(str) + def _on_check_failed(self, message: str) -> None: + if not self._closing: + self._page.set_update_status( + f"检查失败:{message};地址已保留,可以重试" + ) + + @pyqtSlot() + def _on_check_finished(self) -> None: + self._check_worker = None + self._check_thread = None + if not self._closing: + self._sync_button() + + def _start_download(self, update: UpdateInfo) -> None: + if self._closing or self._download_thread is not None: + return + self._page.set_update_status(f"正在下载版本 {update.version}(0%)…") + thread = QThread(self) + worker = UpdateDownloadWorker(self._service, update) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.progressChanged.connect(self._on_download_progress) + worker.succeeded.connect(self._on_download_succeeded) + worker.failed.connect(self._on_download_failed) + worker.completed.connect(thread.quit) + worker.completed.connect(worker.deleteLater) + thread.finished.connect(self._on_download_finished) + thread.finished.connect(thread.deleteLater) + self._download_thread = thread + self._download_worker = worker + self._sync_button() + thread.start() + + @pyqtSlot(int) + def _on_download_progress(self, percent: int) -> None: + if not self._closing: + self._page.set_update_status(f"正在下载并校验更新({percent}%)…") + + @pyqtSlot(str) + def _on_download_succeeded(self, version: str) -> None: + if not self._closing: + self._page.set_update_status( + f"版本 {version} 已准备好;完成当前任务后关闭并重新启动程序即可更新" + ) + + @pyqtSlot(str) + def _on_download_failed(self, message: str) -> None: + if not self._closing: + self._page.set_update_status( + f"下载失败:{message};当前版本未改动,可以重试" + ) + + @pyqtSlot() + def _on_download_finished(self) -> None: + self._download_worker = None + self._download_thread = None + if not self._closing: + self._sync_button() + + def _sync_button(self) -> None: + busy = self._check_thread is not None or self._download_thread is not None + self._page.updateCheckButton.setEnabled(not self._closing and not busy) + self._page.updateManifestUrlInput.setEnabled(not self._closing and not busy) + + @pyqtSlot() + def shutdown(self) -> None: + """取消后续处理,断开业务结果并短暂等待线程退出。""" + + if self._closing: + return + self._closing = True + for worker, thread, signal_slots in ( + ( + self._check_worker, + self._check_thread, + ( + ("succeeded", self._on_check_succeeded), + ("failed", self._on_check_failed), + ), + ), + ( + self._download_worker, + self._download_thread, + ( + ("progressChanged", self._on_download_progress), + ("succeeded", self._on_download_succeeded), + ("failed", self._on_download_failed), + ), + ), + ): + if worker is not None: + try: + worker.cancel() + for signal_name, slot in signal_slots: + try: + getattr(worker, signal_name).disconnect(slot) + except (TypeError, RuntimeError): + pass + except RuntimeError: + pass + if thread is not None and thread.isRunning(): + thread.quit() + # 网络读超时是 10 秒;多等 1 秒,避免关闭窗口时销毁仍在运行的 QThread。 + thread.wait(11000) + self._sync_button() diff --git a/client/src/version.py b/client/src/version.py index 01d6abb..e8077fe 100644 --- a/client/src/version.py +++ b/client/src/version.py @@ -3,4 +3,4 @@ 发布脚本和程序界面需要版本号时都从这里读取,避免多个文件各写一份。 """ -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/client/test/test_packaging.py b/client/test/test_packaging.py index 4492485..24d935a 100644 --- a/client/test/test_packaging.py +++ b/client/test/test_packaging.py @@ -2,12 +2,20 @@ import hashlib import json +import os import tempfile import unittest from pathlib import Path from unittest.mock import patch -from build_tools.launcher import app_executable, install_root +from build_tools.launcher import ( + UpdateApplyError, + app_executable, + apply_pending_update, + finalize_applied_update, + install_root, + is_main_program_running, +) from build_tools.release_manifest import MANIFEST_FILE_NAME, write_manifest from src.db import data_dir @@ -27,6 +35,96 @@ class LauncherPathTest(unittest.TestCase): ) +class LauncherUpdateTest(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + self.current_app = self.root / "app" + self.staged_app = self.root / "data" / "update" / "app.new" + self.current_app.mkdir(parents=True) + self.staged_app.mkdir(parents=True) + (self.current_app / "CMAutoBuy.exe").write_bytes(b"old") + (self.staged_app / "CMAutoBuy.exe").write_bytes(b"new") + self.pending_path = self.root / "data" / "update" / "pending.json" + self.pending_path.write_text( + json.dumps( + { + "schema_version": 1, + "state": "ready", + "version": "0.2.0", + } + ), + encoding="utf-8", + ) + + def tearDown(self): + self.temporary_directory.cleanup() + + def test_apply_keeps_old_app_until_health_is_confirmed(self): + version = apply_pending_update(self.root) + + self.assertEqual(version, "0.2.0") + self.assertEqual((self.root / "app" / "CMAutoBuy.exe").read_bytes(), b"new") + self.assertEqual((self.root / "app.old" / "CMAutoBuy.exe").read_bytes(), b"old") + pending = json.loads(self.pending_path.read_text(encoding="utf-8")) + self.assertEqual(pending["state"], "applied") + + healthy_path = self.root / "data" / "update" / "healthy.json" + healthy_path.write_text('{"version":"0.2.0"}', encoding="utf-8") + finalize_applied_update(self.root) + self.assertFalse(self.pending_path.exists()) + self.assertTrue((self.root / "app.old").exists()) + + def test_applied_without_health_rolls_back_on_next_launch(self): + apply_pending_update(self.root) + + result = apply_pending_update(self.root) + + self.assertIsNone(result) + self.assertEqual((self.root / "app" / "CMAutoBuy.exe").read_bytes(), b"old") + self.assertFalse(self.pending_path.exists()) + self.assertTrue((self.root / "data" / "update" / "app.failed").exists()) + + def test_move_failure_restores_current_app(self): + real_replace = os.replace + + def fail_for_staged_app(source, destination): + if Path(source).name == "app.new": + raise OSError("simulated lock") + return real_replace(source, destination) + + with patch("build_tools.launcher.os.replace", side_effect=fail_for_staged_app): + with self.assertRaises(UpdateApplyError): + apply_pending_update(self.root) + + self.assertEqual((self.root / "app" / "CMAutoBuy.exe").read_bytes(), b"old") + self.assertEqual((self.staged_app / "CMAutoBuy.exe").read_bytes(), b"new") + + def test_running_pid_must_belong_to_expected_executable(self): + running_path = self.root / "data" / "update" / "running.json" + running_path.write_text('{"pid":123}', encoding="utf-8") + + self.assertTrue( + is_main_program_running( + self.root, + checker=lambda pid, executable: pid == 123 + and executable == self.root / "app" / "CMAutoBuy.exe", + ) + ) + self.assertFalse( + is_main_program_running(self.root, checker=lambda _pid, _path: False) + ) + self.assertFalse(running_path.exists()) + + def test_corrupt_pending_state_stops_update_instead_of_being_ignored(self): + self.pending_path.write_text("not-json", encoding="utf-8") + + with self.assertRaises(UpdateApplyError): + apply_pending_update(self.root) + + self.assertEqual((self.current_app / "CMAutoBuy.exe").read_bytes(), b"old") + + class PackagedDataPathTest(unittest.TestCase): def test_packaged_main_program_uses_root_data_directory(self): with tempfile.TemporaryDirectory() as directory: diff --git a/client/test/test_update_service.py b/client/test/test_update_service.py new file mode 100644 index 0000000..cfefc17 --- /dev/null +++ b/client/test/test_update_service.py @@ -0,0 +1,203 @@ +"""在线更新清单、下载校验和安全解压测试。""" + +import hashlib +import io +import json +import tempfile +import unittest +import zipfile +from pathlib import Path + +from src.update_service import ( + UnsafeUpdateArchiveError, + UpdateConfigurationError, + UpdateIntegrityError, + UpdateService, + mark_current_version_healthy, + parse_version, + validate_manifest_url, +) + + +class FakeResponse(io.BytesIO): + def __init__(self, content: bytes, url: str, declared_size=None): + super().__init__(content) + self._url = url + self.headers = {} + if declared_size is not None: + self.headers["Content-Length"] = str(declared_size) + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *_args): + self.close() + + +class FakeUrlOpen: + def __init__(self, responses): + self.responses = responses + self.requested_urls = [] + + def __call__(self, request, timeout): + self.requested_urls.append((request.full_url, timeout)) + content, final_url = self.responses[request.full_url] + return FakeResponse(content, final_url, len(content)) + + +def make_update_zip(version="0.2.0", extra_entries=None): + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + archive.writestr("app/CMAutoBuy.exe", b"exe") + archive.writestr("app/version.txt", version.encode("utf-8")) + for name, content in extra_entries or []: + archive.writestr(name, content) + return output.getvalue() + + +def make_manifest(update_content, version="0.2.0"): + return json.dumps( + { + "schema_version": 1, + "product": "CMAutoBuy", + "version": version, + "update": { + "file": f"CMAutoBuy-{version}-update.zip", + "size": len(update_content), + "sha256": hashlib.sha256(update_content).hexdigest(), + }, + } + ).encode("utf-8") + + +class UpdateServiceTest(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.update_directory = Path(self.temporary_directory.name) / "update" + self.manifest_url = "https://updates.example.test/releases/autobuy%E2%80%94%E2%80%94manifest.json" + + def tearDown(self): + self.temporary_directory.cleanup() + + def service_with(self, update_content, manifest_content=None): + file_name = "CMAutoBuy-0.2.0-update.zip" + update_url = f"https://updates.example.test/releases/{file_name}" + opener = FakeUrlOpen( + { + self.manifest_url: ( + manifest_content or make_manifest(update_content), + self.manifest_url, + ), + update_url: (update_content, update_url), + } + ) + return UpdateService(self.update_directory, opener), opener + + def test_strict_version_comparison(self): + self.assertLess(parse_version("1.2.3"), parse_version("1.2.4")) + self.assertEqual(parse_version("1.2.3"), parse_version("1.2.3.0")) + with self.assertRaises(UpdateConfigurationError): + parse_version("v1.2") + + def test_only_https_without_credentials_is_allowed(self): + self.assertEqual( + validate_manifest_url(self.manifest_url), + self.manifest_url, + ) + for invalid in ( + "http://updates.example.test/manifest.json", + "https://user:pass@updates.example.test/manifest.json", + "https://updates.example.test/manifest.json?token=secret", + "not-a-url", + ): + with self.subTest(invalid=invalid), self.assertRaises( + UpdateConfigurationError + ): + validate_manifest_url(invalid) + + def test_check_reports_newer_and_current_versions(self): + update_content = make_update_zip() + service, _opener = self.service_with(update_content) + + newer = service.check(self.manifest_url, "0.1.0") + current = service.check(self.manifest_url, "0.2.0") + + self.assertTrue(newer.available) + self.assertEqual(newer.latest_version, "0.2.0") + self.assertIsNotNone(newer.update) + self.assertFalse(current.available) + self.assertIsNone(current.update) + + def test_download_stages_verified_app_and_pending_state(self): + update_content = make_update_zip() + service, _opener = self.service_with(update_content) + result = service.check(self.manifest_url, "0.1.0") + progress = [] + + staged = service.download_and_stage(result.update, on_progress=progress.append) + + self.assertEqual(staged, self.update_directory / "app.new") + self.assertEqual((staged / "version.txt").read_text(), "0.2.0") + pending = json.loads( + (self.update_directory / "pending.json").read_text(encoding="utf-8") + ) + self.assertEqual(pending["state"], "ready") + self.assertEqual(pending["version"], "0.2.0") + self.assertEqual(progress[-1], 100) + + def test_hash_mismatch_never_creates_pending_state(self): + update_content = make_update_zip() + manifest = json.loads(make_manifest(update_content)) + manifest["update"]["sha256"] = "0" * 64 + service, _opener = self.service_with( + update_content, + json.dumps(manifest).encode("utf-8"), + ) + result = service.check(self.manifest_url, "0.1.0") + + with self.assertRaises(UpdateIntegrityError): + service.download_and_stage(result.update) + + self.assertFalse((self.update_directory / "pending.json").exists()) + self.assertFalse((self.update_directory / "app.new").exists()) + + def test_path_traversal_archive_is_rejected(self): + update_content = make_update_zip( + extra_entries=[("app/../../outside.txt", b"unsafe")] + ) + service, _opener = self.service_with(update_content) + result = service.check(self.manifest_url, "0.1.0") + + with self.assertRaises(UnsafeUpdateArchiveError): + service.download_and_stage(result.update) + + self.assertFalse((self.update_directory.parent / "outside.txt").exists()) + self.assertFalse((self.update_directory / "pending.json").exists()) + + def test_packaged_version_must_match_manifest(self): + update_content = make_update_zip(version="9.9.9") + service, _opener = self.service_with(update_content) + result = service.check(self.manifest_url, "0.1.0") + + with self.assertRaises(UpdateIntegrityError): + service.download_and_stage(result.update) + + def test_health_marker_is_only_written_for_pending_update(self): + mark_current_version_healthy(self.update_directory) + self.assertFalse((self.update_directory / "healthy.json").exists()) + self.update_directory.mkdir(parents=True) + (self.update_directory / "pending.json").write_text("{}", encoding="utf-8") + + mark_current_version_healthy(self.update_directory) + + health = json.loads( + (self.update_directory / "healthy.json").read_text(encoding="utf-8") + ) + self.assertEqual(health["version"], "0.2.0") + + +if __name__ == "__main__": + unittest.main() diff --git a/client/test/test_update_ui_event.py b/client/test/test_update_ui_event.py new file mode 100644 index 0000000..d6251a3 --- /dev/null +++ b/client/test/test_update_ui_event.py @@ -0,0 +1,174 @@ +"""设置页在线更新 UI 和后台线程测试。""" + +import os +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyQt5.QtCore import QTimer +from PyQt5.QtTest import QTest +from PyQt5.QtWidgets import QApplication + +from src.mock_admin_gateway import MockAdminGateway +from src.settings_repository import SettingsRepository +from src.settings_ui import SettingsPage +from src.update_service import ( + UPDATE_MANIFEST_SETTING, + UpdateCheckResult, + UpdateInfo, +) + + +MANIFEST_URL = "https://updates.example.test/autobuy%E2%80%94%E2%80%94manifest.json" + + +class FakeUpdateService: + def __init__(self, result=None, delay=0.0, download_error=None): + self.result = result or UpdateCheckResult("0.2.0", "0.2.0", False) + self.delay = delay + self.download_error = download_error + self.check_count = 0 + self.download_count = 0 + + def check(self, manifest_url, current_version="0.1.0", is_cancelled=None): + self.check_count += 1 + self.manifest_url = manifest_url + if self.delay: + time.sleep(self.delay) + return self.result + + def download_and_stage(self, update, is_cancelled=None, on_progress=None): + self.download_count += 1 + if on_progress is not None: + on_progress(50) + if self.download_error is not None: + raise self.download_error + if on_progress is not None: + on_progress(100) + return Path("app.new") + + +class FakeButton: + def setText(self, _text): + pass + + def setFocus(self): + pass + + +class AcceptDownloadMessageBox: + def __init__(self, _title, _content, _parent): + self.yesButton = FakeButton() + self.cancelButton = FakeButton() + + def exec(self): + return True + + +class UpdateUiEventTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QApplication.instance() or QApplication([]) + + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.repository = SettingsRepository( + Path(self.temporary_directory.name) / "client.db" + ) + + def tearDown(self): + self.temporary_directory.cleanup() + + def _page(self, service): + return SettingsPage( + settings_repository=self.repository, + admin_gateway=MockAdminGateway(), + update_service=service, + ) + + def _wait_until(self, predicate, timeout_ms=2000): + elapsed = 0 + while not predicate() and elapsed < timeout_ms: + QTest.qWait(10) + elapsed += 10 + self.assertTrue(predicate(), "等待在线更新线程超时") + + def test_update_card_loads_saved_url_and_current_version(self): + self.repository.set(UPDATE_MANIFEST_SETTING, MANIFEST_URL) + page = self._page(FakeUpdateService()) + + self.assertEqual(page.currentVersionLabel.text(), "0.2.0") + self.assertEqual(page.updateManifestUrlInput.text(), MANIFEST_URL) + self.assertTrue(page.updateCheckButton.isEnabled()) + page.eventBinder.shutdown() + page.deleteLater() + + def test_http_url_is_rejected_before_starting_worker(self): + service = FakeUpdateService() + page = self._page(service) + page.updateManifestUrlInput.setText("http://updates.example.test/manifest.json") + + page.updateCheckButton.click() + + self.assertIn("必须是有效的 HTTPS", page.updateStatusLabel.text()) + self.assertEqual(service.check_count, 0) + page.eventBinder.shutdown() + page.deleteLater() + + def test_slow_check_does_not_block_or_start_twice(self): + service = FakeUpdateService(delay=0.08) + page = self._page(service) + page.updateManifestUrlInput.setText(MANIFEST_URL) + timer_fired = [] + QTimer.singleShot(10, lambda: timer_fired.append(True)) + + page.updateCheckButton.click() + page.eventBinder.updateEventBinder.request_check() + self._wait_until(lambda: bool(timer_fired), timeout_ms=500) + self._wait_until( + lambda: page.eventBinder.updateEventBinder._check_thread is None + ) + + self.assertEqual(service.check_count, 1) + self.assertEqual( + self.repository.get(UPDATE_MANIFEST_SETTING), MANIFEST_URL + ) + self.assertIn("当前已是最新版本", page.updateStatusLabel.text()) + page.eventBinder.shutdown() + page.deleteLater() + + def test_confirmed_new_version_downloads_and_stages(self): + update = UpdateInfo( + version="0.2.0", + manifest_url=MANIFEST_URL, + update_url="https://updates.example.test/CMAutoBuy-0.2.0-update.zip", + file_name="CMAutoBuy-0.2.0-update.zip", + size=1024, + sha256="0" * 64, + ) + service = FakeUpdateService( + UpdateCheckResult("0.1.0", "0.2.0", True, update) + ) + page = self._page(service) + page.updateManifestUrlInput.setText(MANIFEST_URL) + + with patch("src.update_ui_event.MessageBox", AcceptDownloadMessageBox): + page.updateCheckButton.click() + self._wait_until( + lambda: page.eventBinder.updateEventBinder._check_thread is None + and page.eventBinder.updateEventBinder._download_thread is None + ) + + self.assertEqual(service.download_count, 1) + self.assertIn("已准备好", page.updateStatusLabel.text()) + self.assertIn("重新启动", page.updateStatusLabel.text()) + page.eventBinder.shutdown() + page.deleteLater() + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/client/01-requirements.md b/docs/client/01-requirements.md index 32763d5..33841be 100644 --- a/docs/client/01-requirements.md +++ b/docs/client/01-requirements.md @@ -134,6 +134,7 @@ Client 应执行: 2. **Android 设备:**ADB 地址、拼多多包名、设备测试连接。 3. **自动化:**轮询周期、任务超时、最大重试次数和演练模式。 4. **安全与诊断:**价格允许偏差、最大购买数量、日志目录、截图/XML 保留周期。 +5. **软件更新:**当前版本、HTTPS 更新清单地址、检查按钮和稳定状态文字。 密码和访问令牌不得以明文写入普通 SQLite 设置或日志。 @@ -211,7 +212,19 @@ CMAutoBuy/ 整个文件夹拷到任何机器都能用 每次构建在 `client/release/` 生成更新包、完整便携包和 `autobuy——manifest.json`。清单至少记录版本、文件名、字节大小和 SHA256;SHA256 -用于发现下载损坏,不等同于发布者身份认证。联网更新和清单签名必须另建工单。 +用于发现下载损坏,不等同于发布者身份认证。 + +**在线更新:**操作人员在设置页填写完整 HTTPS 清单地址并主动检查。发现新版本后 +必须由用户确认下载;下载、校验和安全解压在后台线程完成,只写入 +`data/update/`。程序不会强制退出,用户下次通过 `Launcher.exe` 启动时才替换 +`app/`。Launcher 保留一份 `app.old/`,目录移动失败或新版本没有写入健康标记时 +恢复旧版本。更新包必须与清单同源;重定向后仍必须是 HTTPS;不允许忽略 TLS +证书错误,也不在更新地址中保存账号密码。 +更新地址还不得包含查询参数或片段,避免 token 一类凭据被持久化。 + +`0.1.0` 的 Launcher 没有应用在线更新的能力,因此第一次升级到带在线更新的 +`0.2.0` 仍需人工替换一次完整程序;从 `0.2.0` 开始才可使用上述流程。当前没有 +清单数字签名,发布服务器整体失陷不在 SHA256 的保护范围内,签名需要另建工单。 **已知风险点**(打包工单必须逐项验证): diff --git a/docs/client/02-architecture.md b/docs/client/02-architecture.md index 7c226c8..2ac2853 100644 --- a/docs/client/02-architecture.md +++ b/docs/client/02-architecture.md @@ -145,6 +145,8 @@ client/ - Repository 封装 SQLite,界面和自动化代码不得直接拼接业务 SQL。 - PDD Adapter 封装设备连接、页面识别、采集和采购。 - ArtifactStore 保存失败截图、无障碍 XML 和结构化诊断文件。 +- `UpdateService` 只负责 HTTPS 清单、下载校验和安全暂存;正在运行的主程序不替换 + 自身,目录替换与回退只由下一次启动的 `Launcher.exe` 执行。 ## 5. 线程模型 @@ -161,6 +163,10 @@ Qt 主线程 结果提交工作线程或同一任务线程的独立队列 └── Outbox 重试,不重复执行 PDD 操作 + +更新工作线程 +├── 检查 HTTPS 清单 +└── 下载、SHA256 校验和安全解压到 data/update/ ``` - `[必须]` QWidget 只能在 Qt 主线程创建和访问。 @@ -168,6 +174,8 @@ Qt 主线程 - `[必须]` 后台信号只传递不可变数据、稳定编号或轻量视图模型。 - `[必须]` 点“停止获取”后不再领取新任务,当前任务在定义的安全点退出。 - `[建议]` 关闭窗口时应选择停止、等待或后台继续;MVP 默认安全停止并持久化状态。 +- `[必须]` 更新检查和下载使用独立 `QObject + moveToThread` Worker;下载完成只提示 + 下次启动生效,不得为了更新强制中断采集或采购任务。 ### 5.1 Worker 模板(项目统一写法,照抄即可) @@ -282,6 +290,18 @@ def _on_finished(self, remote_task_id: str, result) -> None: 注意:**断开信号只是不更新界面,任务的数据该落库还是要落库**。落库由应用层负责,和界面在不在没关系——这正是"结果先写本地再提交 Admin"的意义,见 §8。 +### 5.3 在线更新启动顺序 + +```text +主程序:HTTPS 清单 → 用户确认 → 下载并校验 → data/update/app.new + pending.json +Launcher:确认主程序未运行 → app 改名 app.old → app.new 改名 app → 启动主程序 +主程序:窗口成功创建 → 写 healthy.json +Launcher:健康标记正确则完成;提前退出则恢复 app.old +``` + +更新 ZIP 只允许 `app/` 内容,拒绝绝对路径、`..`、反斜杠路径和符号链接。Launcher +不联网、不处理凭据,也不自更新。 + ## 6. 任务引擎状态 任务协调器状态: diff --git a/docs/client/05-ui-specification.md b/docs/client/05-ui-specification.md index 6a99cad..688a468 100644 --- a/docs/client/05-ui-specification.md +++ b/docs/client/05-ui-specification.md @@ -296,6 +296,16 @@ class TaskTableModel(QAbstractTableModel): - 保留天数; - 打开日志目录。 +### 软件更新 + +- 显示只读的当前版本; +- 清单地址使用带可见标签的单行输入框,只允许完整 HTTPS URL; +- “检查更新”同时保存已验证的非敏感清单地址;检查和下载期间按钮防重复; +- 状态文字稳定显示未配置、检查中、已是最新、发现新版、下载进度、已准备、失败和 + 恢复建议; +- 发现新版本时使用有明确“下载更新 / 暂不下载”的确认框;下载完成不强制重启, + 提示操作人员完成当前任务后自行关闭并重新启动。 + 设置采用显式“保存设置”或项目统一的即时保存模式,不能在同一页面随机混用。MVP 推荐显式保存,验证失败时保留输入并聚焦第一个错误字段。 ## 9. 状态与反馈 @@ -311,6 +321,8 @@ class TaskTableModel(QAbstractTableModel): | 任务失败 | 行状态、详情和信息条,不显示原始堆栈 | | 多个订单候选 | 状态改为“需要人工处理”,打开详情决策 | | 普通任务成功 | 更新行和状态区,不弹“成功”对话框 | +| 更新检查或下载失败 | 软件更新卡片保留地址并显示原因和重试方式,当前程序不变 | +| 更新下载完成 | 软件更新卡片持续提示“下次启动生效”,不强制关闭程序 | ### 9.1 错误提示模板(照抄即可) diff --git a/docs/client/06-quality-security.md b/docs/client/06-quality-security.md index 00a2e22..32c0402 100644 --- a/docs/client/06-quality-security.md +++ b/docs/client/06-quality-security.md @@ -185,6 +185,20 @@ Artifact 写入前应脱敏,数据库只保存引用。保留周期由设置 - 迟到的 Admin 响应不得覆盖更新的本地执行状态。 - 数据库损坏、磁盘写满和只读目录必须产生可操作错误,不能继续下单。 +### 8.1 在线更新安全 + +- 清单和更新包只允许 HTTPS,不提供忽略证书错误的开关;URL 不得包含账号密码、 + 查询参数或片段,避免凭据随地址写入 SQLite 或错误提示。 +- 更新包必须与最终清单 URL 同源,并同时校验清单声明的字节大小和 SHA256。 +- 清单、压缩包和解压后总大小均有限制;ZIP 只能包含安全的 `app/` 内容,拒绝路径 + 穿越、绝对路径、反斜杠路径和符号链接。 +- 下载和解压只写 `data/update/`;`client.db`、日志、Artifact 和其他设置不得进入 + 替换范围。 +- Launcher 发现主程序仍在运行时不得替换目录;替换失败必须恢复旧 `app`,新版本 + 未通过健康检查时恢复 `app.old`。 +- SHA256 不证明发布者身份。当前发布服务器整体失陷不在保护范围内,正式扩大分发 + 前应另行评估签名清单和代码签名。 + ## 9. 性能和响应性 - 表格使用模型/视图和增量加载,不为每个单元格创建 QWidget。 @@ -207,6 +221,7 @@ Artifact 写入前应脱敏,数据库只保存引用。保留周期由设置 | 6 | Windows 干净环境启动测试通过 | **未打包版本**:找一台没装过本项目的机器,照 [00 上手指南](00-getting-started.md) 从头走一遍。**已打包版本**:把整个文件夹拷到一台**没装 Python** 的机器上双击 `Launcher.exe` | 开发者 | | 6b | 升级不丢数据(仅打包版本) | 关闭程序并只换掉 `app/`,保留 `Launcher.exe` 和 `data/`;启动后任务、日志、设置都还在 | 开发者 | | 6c | 发布清单与产物一致 | `autobuy——manifest.json` 中的版本、文件名、字节大小和 SHA256 与实际压缩包一致,更新包中没有 `data/` | 开发者 | +| 6d | 在线更新和回退通过 | 使用上一版本目录检查新版本、下载、下次启动替换、健康标记、文件占用失败和自动回退;确认数据库未被覆盖 | 开发者 | | 7 | 日志和产物无敏感信息 | 翻一遍日志和 `artifacts/`,确认没有 token、Cookie、密码、收货人信息 | 开发者 | | 8 | 开源和商业许可证已确认 | 新增依赖的许可证是否允许本项目的使用方式 | **项目负责人**(不是开发者自己判断) | | 9 | 真实下单版本额外满足采购安全门禁 | 逐条核对 §3 的 8 项 | **项目负责人 + 操作人员共同确认** |