feat: 实现 Client 在线更新与安全回退 (#93)

This commit is contained in:
chengma
2026-08-10 12:32:39 +08:00
parent c24fac964e
commit 6d6829e374
14 changed files with 1609 additions and 18 deletions
+263 -13
View File
@@ -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
+58 -1
View File
@@ -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 设备。"""
+10 -1
View File
@@ -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
+6
View File
@@ -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_()
+439
View File
@@ -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__},
)
+295
View File
@@ -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()
+1 -1
View File
@@ -3,4 +3,4 @@
发布脚本和程序界面需要版本号时都从这里读取,避免多个文件各写一份。
"""
__version__ = "0.1.0"
__version__ = "0.2.0"
+99 -1
View File
@@ -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:
+203
View File
@@ -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()
+174
View File
@@ -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()
+14 -1
View File
@@ -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 的保护范围内,签名需要另建工单。
**已知风险点**(打包工单必须逐项验证):
+20
View File
@@ -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. 任务引擎状态
任务协调器状态:
+12
View File
@@ -296,6 +296,16 @@ class TaskTableModel(QAbstractTableModel):
- 保留天数;
- 打开日志目录。
### 软件更新
- 显示只读的当前版本;
- 清单地址使用带可见标签的单行输入框,只允许完整 HTTPS URL;
- “检查更新”同时保存已验证的非敏感清单地址;检查和下载期间按钮防重复;
- 状态文字稳定显示未配置、检查中、已是最新、发现新版、下载进度、已准备、失败和
恢复建议;
- 发现新版本时使用有明确“下载更新 / 暂不下载”的确认框;下载完成不强制重启,
提示操作人员完成当前任务后自行关闭并重新启动。
设置采用显式“保存设置”或项目统一的即时保存模式,不能在同一页面随机混用。MVP 推荐显式保存,验证失败时保留输入并聚焦第一个错误字段。
## 9. 状态与反馈
@@ -311,6 +321,8 @@ class TaskTableModel(QAbstractTableModel):
| 任务失败 | 行状态、详情和信息条,不显示原始堆栈 |
| 多个订单候选 | 状态改为“需要人工处理”,打开详情决策 |
| 普通任务成功 | 更新行和状态区,不弹“成功”对话框 |
| 更新检查或下载失败 | 软件更新卡片保留地址并显示原因和重试方式,当前程序不变 |
| 更新下载完成 | 软件更新卡片持续提示“下次启动生效”,不强制关闭程序 |
### 9.1 错误提示模板(照抄即可)
+15
View File
@@ -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 项 | **项目负责人 + 操作人员共同确认** |