Files
cmautobuy/client/src/update_ui_event.py
T

440 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""设置页在线更新配置、事件和 Qt 后台 Worker。"""
from __future__ import annotations
import sqlite3
from typing import Optional
from PyQt5.QtCore import QObject, QThread, QTimer, pyqtSignal, pyqtSlot
from qfluentwidgets import MessageBox
from .settings_repository import SettingsRepository
from .update_service import (
DEFAULT_UPDATE_MANIFEST_URL,
DEFAULT_UPDATE_USERNAME,
LEGACY_DEFAULT_UPDATE_MANIFEST_URL,
UPDATE_MANIFEST_SETTING,
UPDATE_USERNAME_SETTING,
UpdateCancelled,
UpdateCheckResult,
UpdateCredentials,
UpdateInfo,
UpdateService,
validate_manifest_url,
)
from .windows_credential_store import (
CredentialStoreError,
WindowsCredentialStore,
)
class UpdateCheckWorker(QObject):
"""在线程中使用内存凭据检查新版本。"""
succeeded = pyqtSignal(object)
failed = pyqtSignal(str)
completed = pyqtSignal()
def __init__(
self,
service: UpdateService,
manifest_url: str,
credentials: UpdateCredentials,
):
super().__init__()
self._service = service
self._manifest_url = manifest_url
self._credentials = credentials
self._cancelled = False
def cancel(self) -> None:
self._cancelled = True
@pyqtSlot()
def run(self) -> None:
try:
result = self._service.check(
self._manifest_url,
is_cancelled=lambda: self._cancelled,
credentials=self._credentials,
)
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._credentials = None
self.completed.emit()
class UpdateDownloadWorker(QObject):
"""在线程中认证下载、校验并安全暂存更新。"""
progressChanged = pyqtSignal(int)
succeeded = pyqtSignal(str)
failed = pyqtSignal(str)
completed = pyqtSignal()
def __init__(
self,
service: UpdateService,
update: UpdateInfo,
credentials: UpdateCredentials,
):
super().__init__()
self._service = service
self._update = update
self._credentials = credentials
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,
credentials=self._credentials,
)
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._credentials = None
self.completed.emit()
class UpdateUiEventBinder(QObject):
"""管理更新配置、保存反馈和两个后台线程。"""
def __init__(
self,
page,
repository: SettingsRepository,
service: Optional[UpdateService] = None,
credential_store=None,
parent=None,
):
super().__init__(parent or page)
self._page = page
self._repository = repository
self._service = service or UpdateService()
self._credential_store_override = credential_store
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_record = repository.get_record(UPDATE_MANIFEST_SETTING)
saved_username_record = repository.get_record(UPDATE_USERNAME_SETTING)
saved_url = (
saved_url_record.value
if saved_url_record is not None and isinstance(saved_url_record.value, str)
else DEFAULT_UPDATE_MANIFEST_URL
)
if saved_url == LEGACY_DEFAULT_UPDATE_MANIFEST_URL:
saved_url = DEFAULT_UPDATE_MANIFEST_URL
repository.set(UPDATE_MANIFEST_SETTING, saved_url)
saved_username = (
saved_username_record.value
if saved_username_record is not None
and isinstance(saved_username_record.value, str)
else DEFAULT_UPDATE_USERNAME
)
page.updateManifestUrlInput.setText(saved_url)
page.updateUsernameInput.setText(saved_username)
page.updatePasswordInput.clear()
self._saved_url = saved_url if saved_url_record is not None else ""
self._saved_username = (
saved_username if saved_username_record is not None else ""
)
credential_ready = self._set_password_placeholder(saved_url)
page.updateSaveButton.clicked.connect(self.request_save)
page.updateCheckButton.clicked.connect(self.request_check)
self._sync_controls()
if self._saved_url and self._saved_username and credential_ready:
QTimer.singleShot(0, self.request_check)
def _credential_store_for(self, manifest_url: str):
if self._credential_store_override is not None:
return self._credential_store_override
return WindowsCredentialStore.for_url(manifest_url)
def _set_password_placeholder(self, manifest_url: str) -> bool:
try:
saved = self._credential_store_for(manifest_url).read()
except CredentialStoreError as exc:
self._page.updatePasswordInput.setPlaceholderText("凭据管理器不可用")
self._page.set_update_status(str(exc))
return False
if saved is None:
self._page.updatePasswordInput.setPlaceholderText("请输入密码")
return False
self._page.updatePasswordInput.setPlaceholderText(
"密码已安全保存;留空表示不修改"
)
return True
@pyqtSlot()
def request_save(self) -> None:
"""显式保存非敏感设置和 Windows 系统凭据。"""
if self._closing or self._is_busy():
return
manifest_url = self._page.updateManifestUrlInput.text().strip()
username = self._page.updateUsernameInput.text().strip()
password = self._page.updatePasswordInput.text()
self._page.updateManifestUrlInput.setText(manifest_url)
self._page.updateUsernameInput.setText(username)
try:
normalized_url = validate_manifest_url(manifest_url)
except Exception as exc:
self._page.set_update_status(f"保存失败:{exc}")
self._page.updateManifestUrlInput.setFocus()
return
if not username:
self._page.set_update_status("保存失败:更新账号不能为空")
self._page.updateUsernameInput.setFocus()
return
try:
credential_store = self._credential_store_for(normalized_url)
existing = credential_store.read()
if password:
credential_store.save(username, password)
elif existing is None:
self._page.set_update_status("保存失败:请填写更新密码")
self._page.updatePasswordInput.setFocus()
return
elif existing[0] != username:
self._page.set_update_status("账号已修改,请重新填写密码后保存")
self._page.updatePasswordInput.setFocus()
return
self._repository.set_many(
{
UPDATE_MANIFEST_SETTING: normalized_url,
UPDATE_USERNAME_SETTING: username,
}
)
except (CredentialStoreError, OSError, sqlite3.Error, ValueError) as exc:
self._page.set_update_status(f"保存失败:{exc}")
return
self._saved_url = normalized_url
self._saved_username = username
self._page.updatePasswordInput.clear()
self._set_password_placeholder(normalized_url)
self._page.set_update_status("更新设置已安全保存")
@pyqtSlot()
def request_check(self) -> None:
if self._closing or self._is_busy():
return
manifest_url = self._page.updateManifestUrlInput.text().strip()
username = self._page.updateUsernameInput.text().strip()
if not self._saved_url or not self._saved_username:
self._page.set_update_status("尚未保存更新账号和密码,请填写后保存")
self._page.updatePasswordInput.setFocus()
return
if (
manifest_url != self._saved_url
or username != self._saved_username
or self._page.updatePasswordInput.text()
):
self._page.set_update_status("更新设置有修改,请先保存后再检查")
self._page.updateSaveButton.setFocus()
return
credentials = self._read_credentials(username)
if credentials is None:
return
self._page.set_update_status("正在检查新版本…")
thread = QThread(self)
worker = UpdateCheckWorker(self._service, manifest_url, credentials)
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_controls()
thread.start()
def _read_credentials(self, expected_username: str) -> Optional[UpdateCredentials]:
try:
saved = self._credential_store_for(self._saved_url).read()
except CredentialStoreError as exc:
self._page.set_update_status(f"无法读取更新密码:{exc}")
return None
if saved is None:
self._page.set_update_status("尚未保存更新密码,请填写后保存")
self._page.updatePasswordInput.setFocus()
return None
username, password = saved
if username != expected_username:
self._page.set_update_status("保存的账号不一致,请重新填写密码并保存")
self._page.updatePasswordInput.setFocus()
return None
return UpdateCredentials(username, password)
@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_controls()
def _start_download(self, update: UpdateInfo) -> None:
if self._closing or self._download_thread is not None:
return
credentials = self._read_credentials(self._saved_username)
if credentials is None:
return
self._page.set_update_status(f"正在下载版本 {update.version}(0%)…")
thread = QThread(self)
worker = UpdateDownloadWorker(self._service, update, credentials)
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_controls()
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_controls()
def _is_busy(self) -> bool:
return self._check_thread is not None or self._download_thread is not None
def _sync_controls(self) -> None:
enabled = not self._closing and not self._is_busy()
self._page.updateSaveButton.setEnabled(enabled)
self._page.updateCheckButton.setEnabled(enabled)
self._page.updateManifestUrlInput.setEnabled(enabled)
self._page.updateUsernameInput.setEnabled(enabled)
self._page.updatePasswordInput.setEnabled(enabled)
@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()
thread.wait(11000)
self._sync_controls()