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