"""设置页在线更新配置、事件和 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()