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
+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"