Files
cmautobuy/client/src/update_ui_event.py
T

456 lines
16 KiB
Python
Raw Normal View History

"""设置页在线更新配置、事件和 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):
"""管理更新配置、保存反馈和两个后台线程。"""
updateAvailabilityChanged = pyqtSignal(bool, str, str)
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
self._page.set_update_versions(
result.current_version,
result.latest_version,
)
if not result.available or result.update is None:
self.updateAvailabilityChanged.emit(
False,
result.current_version,
result.latest_version,
)
self._page.set_update_status(
f"当前已是最新版本({result.current_version})"
)
return
update = result.update
self.updateAvailabilityChanged.emit(
True,
result.current_version,
update.version,
)
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()