feat: 实现 Client 在线更新与安全回退 (#93)
This commit is contained in:
@@ -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 设备。"""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_()
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user