feat: 实现 Client 在线更新与安全回退 (#93)
This commit is contained in:
+263
-13
@@ -1,14 +1,28 @@
|
|||||||
"""发布包的轻量启动器。
|
"""发布包的轻量启动器和下次启动更新器。
|
||||||
|
|
||||||
启动器不联网、不更新文件,只负责从发布根目录启动 ``app/CMAutoBuy.exe``。
|
Launcher 不联网。主程序已经下载并校验好 ``data/update/app.new`` 后,本模块在
|
||||||
|
主程序未运行时替换 ``app``,失败时恢复 ``app.old``。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from pathlib import Path
|
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:
|
def install_root(executable: str | None = None) -> Path:
|
||||||
@@ -23,29 +37,265 @@ def app_executable(root: Path) -> Path:
|
|||||||
return root / "app" / "CMAutoBuy.exe"
|
return root / "app" / "CMAutoBuy.exe"
|
||||||
|
|
||||||
|
|
||||||
|
def update_directory(root: Path) -> Path:
|
||||||
|
"""返回在线更新状态目录。"""
|
||||||
|
|
||||||
|
return root / "data" / "update"
|
||||||
|
|
||||||
|
|
||||||
def show_error(message: str) -> None:
|
def show_error(message: str) -> None:
|
||||||
"""使用 Windows 原生对话框显示启动错误。"""
|
"""使用 Windows 原生对话框显示启动错误。"""
|
||||||
|
|
||||||
ctypes.windll.user32.MessageBoxW(0, message, "商品采集采购工具", 0x10)
|
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)
|
target = app_executable(root)
|
||||||
if not target.is_file():
|
if not target.is_file():
|
||||||
show_error(
|
raise OSError(f"主程序不存在:{target}")
|
||||||
"主程序不存在,请确认 app 文件夹完整。\n\n"
|
process = subprocess.Popen([str(target)], cwd=str(target.parent))
|
||||||
f"缺少文件:{target}"
|
_write_json_atomic(
|
||||||
)
|
update_directory(root) / "running.json",
|
||||||
return 1
|
{"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:
|
try:
|
||||||
subprocess.Popen([str(target)], cwd=str(target.parent))
|
applied_version = apply_pending_update(root)
|
||||||
except OSError as exc:
|
process = _start_main_program(root)
|
||||||
show_error(f"主程序启动失败。\n\n{exc}")
|
except (OSError, UpdateApplyError) as exc:
|
||||||
|
show_error(f"主程序启动或更新失败。\n\n{exc}")
|
||||||
return 1
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""设置页的界面结构。
|
"""设置页的界面结构。
|
||||||
|
|
||||||
本文件只负责显示设备信息和提供界面更新入口。按钮事件统一由
|
本文件只负责显示设备和软件更新信息,并提供界面更新入口。按钮事件统一由
|
||||||
``settings_ui_event.py`` 绑定;ADB、SQLite 和硬件信息读取不得放在这里。
|
``settings_ui_event.py`` 绑定;ADB、SQLite 和硬件信息读取不得放在这里。
|
||||||
|
|
||||||
改动本文件前必读 ``client/AGENTS.md``。页面 ``objectName`` 固定为
|
改动本文件前必读 ``client/AGENTS.md``。页面 ``objectName`` 固定为
|
||||||
@@ -32,6 +32,8 @@ from qfluentwidgets import (
|
|||||||
TitleLabel,
|
TitleLabel,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .version import __version__
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AndroidDeviceRow:
|
class AndroidDeviceRow:
|
||||||
@@ -207,6 +209,7 @@ class SettingsPage(QWidget):
|
|||||||
settings_repository=None,
|
settings_repository=None,
|
||||||
admin_gateway=None,
|
admin_gateway=None,
|
||||||
android_device_service=None,
|
android_device_service=None,
|
||||||
|
update_service=None,
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName("settingsPage")
|
self.setObjectName("settingsPage")
|
||||||
@@ -220,6 +223,7 @@ class SettingsPage(QWidget):
|
|||||||
settings_repository=settings_repository,
|
settings_repository=settings_repository,
|
||||||
admin_gateway=admin_gateway,
|
admin_gateway=admin_gateway,
|
||||||
android_device_service=android_device_service,
|
android_device_service=android_device_service,
|
||||||
|
update_service=update_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _build_ui(self) -> None:
|
def _build_ui(self) -> None:
|
||||||
@@ -289,6 +293,23 @@ class SettingsPage(QWidget):
|
|||||||
self.pddAppStatusLabel.setWordWrap(True)
|
self.pddAppStatusLabel.setWordWrap(True)
|
||||||
self.androidDeviceCard = self._build_android_device_card()
|
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 = QWidget(self)
|
||||||
content.setObjectName("settingsContent")
|
content.setObjectName("settingsContent")
|
||||||
contentLayout = QVBoxLayout(content)
|
contentLayout = QVBoxLayout(content)
|
||||||
@@ -297,6 +318,7 @@ class SettingsPage(QWidget):
|
|||||||
contentLayout.addWidget(TitleLabel("设置", content))
|
contentLayout.addWidget(TitleLabel("设置", content))
|
||||||
contentLayout.addWidget(self.currentDeviceCard)
|
contentLayout.addWidget(self.currentDeviceCard)
|
||||||
contentLayout.addWidget(self.androidDeviceCard)
|
contentLayout.addWidget(self.androidDeviceCard)
|
||||||
|
contentLayout.addWidget(self.softwareUpdateCard)
|
||||||
contentLayout.addStretch(1)
|
contentLayout.addStretch(1)
|
||||||
|
|
||||||
scrollArea = ScrollArea(self)
|
scrollArea = ScrollArea(self)
|
||||||
@@ -383,6 +405,36 @@ class SettingsPage(QWidget):
|
|||||||
layout.addWidget(self.deviceTable, 1)
|
layout.addWidget(self.deviceTable, 1)
|
||||||
return card
|
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:
|
def set_client_info(self, device_id: str, device_name: str) -> None:
|
||||||
"""显示后续设备身份服务提供的当前客户端信息。"""
|
"""显示后续设备身份服务提供的当前客户端信息。"""
|
||||||
|
|
||||||
@@ -405,6 +457,11 @@ class SettingsPage(QWidget):
|
|||||||
|
|
||||||
self.pddAppStatusLabel.setText(message)
|
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:
|
def set_saved_android_device(self, serial: str) -> None:
|
||||||
"""显示已经保存并实际用于自动化的 Android 设备。"""
|
"""显示已经保存并实际用于自动化的 Android 设备。"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""设置页事件、ADB 搜索、当前 Client 本地保存和后台登记。
|
"""设置页事件、ADB 搜索、Client 保存登记和软件更新。
|
||||||
|
|
||||||
本文件不直接写 SQL。ADB 搜索、SQLite 写入和 Admin HTTP 请求由 Worker 在线程中
|
本文件不直接写 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 .selected_android_device_service import SelectedAndroidDeviceService
|
||||||
from .settings_repository import SettingsRepository
|
from .settings_repository import SettingsRepository
|
||||||
from .settings_ui import AndroidDeviceRow
|
from .settings_ui import AndroidDeviceRow
|
||||||
|
from .update_ui_event import UpdateUiEventBinder
|
||||||
|
|
||||||
DEVICE_ID_PLACEHOLDER = "待生成"
|
DEVICE_ID_PLACEHOLDER = "待生成"
|
||||||
|
|
||||||
@@ -308,6 +309,7 @@ class SettingsPageEventBinder(QObject):
|
|||||||
settings_repository: Optional[SettingsRepository] = None,
|
settings_repository: Optional[SettingsRepository] = None,
|
||||||
admin_gateway: Optional[ClientRegistrationGateway] = None,
|
admin_gateway: Optional[ClientRegistrationGateway] = None,
|
||||||
android_device_service: Optional[AndroidDeviceService] = None,
|
android_device_service: Optional[AndroidDeviceService] = None,
|
||||||
|
update_service=None,
|
||||||
):
|
):
|
||||||
super().__init__(page)
|
super().__init__(page)
|
||||||
self._page = page
|
self._page = page
|
||||||
@@ -343,6 +345,12 @@ class SettingsPageEventBinder(QObject):
|
|||||||
)
|
)
|
||||||
|
|
||||||
repository = settings_repository or SettingsRepository()
|
repository = settings_repository or SettingsRepository()
|
||||||
|
self.updateEventBinder = UpdateUiEventBinder(
|
||||||
|
page,
|
||||||
|
repository,
|
||||||
|
service=update_service,
|
||||||
|
parent=self,
|
||||||
|
)
|
||||||
self._client_service = CurrentClientService(repository)
|
self._client_service = CurrentClientService(repository)
|
||||||
self._selected_android_device_service = SelectedAndroidDeviceService(
|
self._selected_android_device_service = SelectedAndroidDeviceService(
|
||||||
repository
|
repository
|
||||||
@@ -1147,6 +1155,7 @@ class SettingsPageEventBinder(QObject):
|
|||||||
if self._closing:
|
if self._closing:
|
||||||
return
|
return
|
||||||
self._closing = True
|
self._closing = True
|
||||||
|
self.updateEventBinder.shutdown()
|
||||||
|
|
||||||
worker = self._worker
|
worker = self._worker
|
||||||
thread = self._thread
|
thread = self._thread
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from .pdd_ui_event import PDDTaskPageEvent
|
|||||||
from .pdd_u2_purchase_adapter import create_u2_purchase_adapter
|
from .pdd_u2_purchase_adapter import create_u2_purchase_adapter
|
||||||
from .settings_ui import SettingsPage
|
from .settings_ui import SettingsPage
|
||||||
from .task_repository import TaskRepository
|
from .task_repository import TaskRepository
|
||||||
|
from .update_service import mark_current_version_healthy
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(FluentWindow):
|
class MainWindow(FluentWindow):
|
||||||
@@ -96,6 +97,11 @@ def ui_main():
|
|||||||
|
|
||||||
window = MainWindow()
|
window = MainWindow()
|
||||||
window.show()
|
window.show()
|
||||||
|
try:
|
||||||
|
mark_current_version_healthy()
|
||||||
|
except OSError:
|
||||||
|
# 健康标记失败不能让界面崩溃;Launcher 会保留旧版本并在下次启动恢复。
|
||||||
|
pass
|
||||||
return app.exec_()
|
return app.exec_()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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__},
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -3,4 +3,4 @@
|
|||||||
发布脚本和程序界面需要版本号时都从这里读取,避免多个文件各写一份。
|
发布脚本和程序界面需要版本号时都从这里读取,避免多个文件各写一份。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.2.0"
|
||||||
|
|||||||
@@ -2,12 +2,20 @@
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
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 build_tools.release_manifest import MANIFEST_FILE_NAME, write_manifest
|
||||||
from src.db import data_dir
|
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):
|
class PackagedDataPathTest(unittest.TestCase):
|
||||||
def test_packaged_main_program_uses_root_data_directory(self):
|
def test_packaged_main_program_uses_root_data_directory(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -134,6 +134,7 @@ Client 应执行:
|
|||||||
2. **Android 设备:**ADB 地址、拼多多包名、设备测试连接。
|
2. **Android 设备:**ADB 地址、拼多多包名、设备测试连接。
|
||||||
3. **自动化:**轮询周期、任务超时、最大重试次数和演练模式。
|
3. **自动化:**轮询周期、任务超时、最大重试次数和演练模式。
|
||||||
4. **安全与诊断:**价格允许偏差、最大购买数量、日志目录、截图/XML 保留周期。
|
4. **安全与诊断:**价格允许偏差、最大购买数量、日志目录、截图/XML 保留周期。
|
||||||
|
5. **软件更新:**当前版本、HTTPS 更新清单地址、检查按钮和稳定状态文字。
|
||||||
|
|
||||||
密码和访问令牌不得以明文写入普通 SQLite 设置或日志。
|
密码和访问令牌不得以明文写入普通 SQLite 设置或日志。
|
||||||
|
|
||||||
@@ -211,7 +212,19 @@ CMAutoBuy/ 整个文件夹拷到任何机器都能用
|
|||||||
|
|
||||||
每次构建在 `client/release/` 生成更新包、完整便携包和
|
每次构建在 `client/release/` 生成更新包、完整便携包和
|
||||||
`autobuy——manifest.json`。清单至少记录版本、文件名、字节大小和 SHA256;SHA256
|
`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 的保护范围内,签名需要另建工单。
|
||||||
|
|
||||||
**已知风险点**(打包工单必须逐项验证):
|
**已知风险点**(打包工单必须逐项验证):
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ client/
|
|||||||
- Repository 封装 SQLite,界面和自动化代码不得直接拼接业务 SQL。
|
- Repository 封装 SQLite,界面和自动化代码不得直接拼接业务 SQL。
|
||||||
- PDD Adapter 封装设备连接、页面识别、采集和采购。
|
- PDD Adapter 封装设备连接、页面识别、采集和采购。
|
||||||
- ArtifactStore 保存失败截图、无障碍 XML 和结构化诊断文件。
|
- ArtifactStore 保存失败截图、无障碍 XML 和结构化诊断文件。
|
||||||
|
- `UpdateService` 只负责 HTTPS 清单、下载校验和安全暂存;正在运行的主程序不替换
|
||||||
|
自身,目录替换与回退只由下一次启动的 `Launcher.exe` 执行。
|
||||||
|
|
||||||
## 5. 线程模型
|
## 5. 线程模型
|
||||||
|
|
||||||
@@ -161,6 +163,10 @@ Qt 主线程
|
|||||||
|
|
||||||
结果提交工作线程或同一任务线程的独立队列
|
结果提交工作线程或同一任务线程的独立队列
|
||||||
└── Outbox 重试,不重复执行 PDD 操作
|
└── Outbox 重试,不重复执行 PDD 操作
|
||||||
|
|
||||||
|
更新工作线程
|
||||||
|
├── 检查 HTTPS 清单
|
||||||
|
└── 下载、SHA256 校验和安全解压到 data/update/
|
||||||
```
|
```
|
||||||
|
|
||||||
- `[必须]` QWidget 只能在 Qt 主线程创建和访问。
|
- `[必须]` QWidget 只能在 Qt 主线程创建和访问。
|
||||||
@@ -168,6 +174,8 @@ Qt 主线程
|
|||||||
- `[必须]` 后台信号只传递不可变数据、稳定编号或轻量视图模型。
|
- `[必须]` 后台信号只传递不可变数据、稳定编号或轻量视图模型。
|
||||||
- `[必须]` 点“停止获取”后不再领取新任务,当前任务在定义的安全点退出。
|
- `[必须]` 点“停止获取”后不再领取新任务,当前任务在定义的安全点退出。
|
||||||
- `[建议]` 关闭窗口时应选择停止、等待或后台继续;MVP 默认安全停止并持久化状态。
|
- `[建议]` 关闭窗口时应选择停止、等待或后台继续;MVP 默认安全停止并持久化状态。
|
||||||
|
- `[必须]` 更新检查和下载使用独立 `QObject + moveToThread` Worker;下载完成只提示
|
||||||
|
下次启动生效,不得为了更新强制中断采集或采购任务。
|
||||||
|
|
||||||
### 5.1 Worker 模板(项目统一写法,照抄即可)
|
### 5.1 Worker 模板(项目统一写法,照抄即可)
|
||||||
|
|
||||||
@@ -282,6 +290,18 @@ def _on_finished(self, remote_task_id: str, result) -> None:
|
|||||||
|
|
||||||
注意:**断开信号只是不更新界面,任务的数据该落库还是要落库**。落库由应用层负责,和界面在不在没关系——这正是"结果先写本地再提交 Admin"的意义,见 §8。
|
注意:**断开信号只是不更新界面,任务的数据该落库还是要落库**。落库由应用层负责,和界面在不在没关系——这正是"结果先写本地再提交 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. 任务引擎状态
|
## 6. 任务引擎状态
|
||||||
|
|
||||||
任务协调器状态:
|
任务协调器状态:
|
||||||
|
|||||||
@@ -296,6 +296,16 @@ class TaskTableModel(QAbstractTableModel):
|
|||||||
- 保留天数;
|
- 保留天数;
|
||||||
- 打开日志目录。
|
- 打开日志目录。
|
||||||
|
|
||||||
|
### 软件更新
|
||||||
|
|
||||||
|
- 显示只读的当前版本;
|
||||||
|
- 清单地址使用带可见标签的单行输入框,只允许完整 HTTPS URL;
|
||||||
|
- “检查更新”同时保存已验证的非敏感清单地址;检查和下载期间按钮防重复;
|
||||||
|
- 状态文字稳定显示未配置、检查中、已是最新、发现新版、下载进度、已准备、失败和
|
||||||
|
恢复建议;
|
||||||
|
- 发现新版本时使用有明确“下载更新 / 暂不下载”的确认框;下载完成不强制重启,
|
||||||
|
提示操作人员完成当前任务后自行关闭并重新启动。
|
||||||
|
|
||||||
设置采用显式“保存设置”或项目统一的即时保存模式,不能在同一页面随机混用。MVP 推荐显式保存,验证失败时保留输入并聚焦第一个错误字段。
|
设置采用显式“保存设置”或项目统一的即时保存模式,不能在同一页面随机混用。MVP 推荐显式保存,验证失败时保留输入并聚焦第一个错误字段。
|
||||||
|
|
||||||
## 9. 状态与反馈
|
## 9. 状态与反馈
|
||||||
@@ -311,6 +321,8 @@ class TaskTableModel(QAbstractTableModel):
|
|||||||
| 任务失败 | 行状态、详情和信息条,不显示原始堆栈 |
|
| 任务失败 | 行状态、详情和信息条,不显示原始堆栈 |
|
||||||
| 多个订单候选 | 状态改为“需要人工处理”,打开详情决策 |
|
| 多个订单候选 | 状态改为“需要人工处理”,打开详情决策 |
|
||||||
| 普通任务成功 | 更新行和状态区,不弹“成功”对话框 |
|
| 普通任务成功 | 更新行和状态区,不弹“成功”对话框 |
|
||||||
|
| 更新检查或下载失败 | 软件更新卡片保留地址并显示原因和重试方式,当前程序不变 |
|
||||||
|
| 更新下载完成 | 软件更新卡片持续提示“下次启动生效”,不强制关闭程序 |
|
||||||
|
|
||||||
### 9.1 错误提示模板(照抄即可)
|
### 9.1 错误提示模板(照抄即可)
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,20 @@ Artifact 写入前应脱敏,数据库只保存引用。保留周期由设置
|
|||||||
- 迟到的 Admin 响应不得覆盖更新的本地执行状态。
|
- 迟到的 Admin 响应不得覆盖更新的本地执行状态。
|
||||||
- 数据库损坏、磁盘写满和只读目录必须产生可操作错误,不能继续下单。
|
- 数据库损坏、磁盘写满和只读目录必须产生可操作错误,不能继续下单。
|
||||||
|
|
||||||
|
### 8.1 在线更新安全
|
||||||
|
|
||||||
|
- 清单和更新包只允许 HTTPS,不提供忽略证书错误的开关;URL 不得包含账号密码、
|
||||||
|
查询参数或片段,避免凭据随地址写入 SQLite 或错误提示。
|
||||||
|
- 更新包必须与最终清单 URL 同源,并同时校验清单声明的字节大小和 SHA256。
|
||||||
|
- 清单、压缩包和解压后总大小均有限制;ZIP 只能包含安全的 `app/` 内容,拒绝路径
|
||||||
|
穿越、绝对路径、反斜杠路径和符号链接。
|
||||||
|
- 下载和解压只写 `data/update/`;`client.db`、日志、Artifact 和其他设置不得进入
|
||||||
|
替换范围。
|
||||||
|
- Launcher 发现主程序仍在运行时不得替换目录;替换失败必须恢复旧 `app`,新版本
|
||||||
|
未通过健康检查时恢复 `app.old`。
|
||||||
|
- SHA256 不证明发布者身份。当前发布服务器整体失陷不在保护范围内,正式扩大分发
|
||||||
|
前应另行评估签名清单和代码签名。
|
||||||
|
|
||||||
## 9. 性能和响应性
|
## 9. 性能和响应性
|
||||||
|
|
||||||
- 表格使用模型/视图和增量加载,不为每个单元格创建 QWidget。
|
- 表格使用模型/视图和增量加载,不为每个单元格创建 QWidget。
|
||||||
@@ -207,6 +221,7 @@ Artifact 写入前应脱敏,数据库只保存引用。保留周期由设置
|
|||||||
| 6 | Windows 干净环境启动测试通过 | **未打包版本**:找一台没装过本项目的机器,照 [00 上手指南](00-getting-started.md) 从头走一遍。**已打包版本**:把整个文件夹拷到一台**没装 Python** 的机器上双击 `Launcher.exe` | 开发者 |
|
| 6 | Windows 干净环境启动测试通过 | **未打包版本**:找一台没装过本项目的机器,照 [00 上手指南](00-getting-started.md) 从头走一遍。**已打包版本**:把整个文件夹拷到一台**没装 Python** 的机器上双击 `Launcher.exe` | 开发者 |
|
||||||
| 6b | 升级不丢数据(仅打包版本) | 关闭程序并只换掉 `app/`,保留 `Launcher.exe` 和 `data/`;启动后任务、日志、设置都还在 | 开发者 |
|
| 6b | 升级不丢数据(仅打包版本) | 关闭程序并只换掉 `app/`,保留 `Launcher.exe` 和 `data/`;启动后任务、日志、设置都还在 | 开发者 |
|
||||||
| 6c | 发布清单与产物一致 | `autobuy——manifest.json` 中的版本、文件名、字节大小和 SHA256 与实际压缩包一致,更新包中没有 `data/` | 开发者 |
|
| 6c | 发布清单与产物一致 | `autobuy——manifest.json` 中的版本、文件名、字节大小和 SHA256 与实际压缩包一致,更新包中没有 `data/` | 开发者 |
|
||||||
|
| 6d | 在线更新和回退通过 | 使用上一版本目录检查新版本、下载、下次启动替换、健康标记、文件占用失败和自动回退;确认数据库未被覆盖 | 开发者 |
|
||||||
| 7 | 日志和产物无敏感信息 | 翻一遍日志和 `artifacts/`,确认没有 token、Cookie、密码、收货人信息 | 开发者 |
|
| 7 | 日志和产物无敏感信息 | 翻一遍日志和 `artifacts/`,确认没有 token、Cookie、密码、收货人信息 | 开发者 |
|
||||||
| 8 | 开源和商业许可证已确认 | 新增依赖的许可证是否允许本项目的使用方式 | **项目负责人**(不是开发者自己判断) |
|
| 8 | 开源和商业许可证已确认 | 新增依赖的许可证是否允许本项目的使用方式 | **项目负责人**(不是开发者自己判断) |
|
||||||
| 9 | 真实下单版本额外满足采购安全门禁 | 逐条核对 §3 的 8 项 | **项目负责人 + 操作人员共同确认** |
|
| 9 | 真实下单版本额外满足采购安全门禁 | 逐条核对 §3 的 8 项 | **项目负责人 + 操作人员共同确认** |
|
||||||
|
|||||||
Reference in New Issue
Block a user