From 41549848edca03527eda1c0be7176a348e1c10ed Mon Sep 17 00:00:00 2001 From: claudi Date: Thu, 29 Jan 2026 08:44:03 +0100 Subject: [PATCH] feat: Implement asynchronous update check on startup with UI integration --- src/webdrop_bridge/main.py | 3 + src/webdrop_bridge/ui/main_window.py | 117 +++++++++++++++++++++++- tests/unit/test_startup_check.py | 128 +++++++++++++++++++++++++++ 3 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_startup_check.py diff --git a/src/webdrop_bridge/main.py b/src/webdrop_bridge/main.py index d6dad60..6c33e88 100644 --- a/src/webdrop_bridge/main.py +++ b/src/webdrop_bridge/main.py @@ -53,6 +53,9 @@ def main() -> int: window.show() logger.info("Main window opened successfully") + + # Check for updates on startup (non-blocking, async) + window.check_for_updates_startup() # Run event loop return app.exec() diff --git a/src/webdrop_bridge/ui/main_window.py b/src/webdrop_bridge/ui/main_window.py index 2e0a913..850d8de 100644 --- a/src/webdrop_bridge/ui/main_window.py +++ b/src/webdrop_bridge/ui/main_window.py @@ -1,16 +1,20 @@ """Main application window with web engine integration.""" +import asyncio +import logging from pathlib import Path from typing import Optional -from PySide6.QtCore import QSize, Qt, QUrl, Signal -from PySide6.QtWidgets import QMainWindow, QToolBar, QVBoxLayout, QWidget, QLabel, QStatusBar +from PySide6.QtCore import QSize, Qt, QThread, QUrl, Signal +from PySide6.QtWidgets import QLabel, QMainWindow, QStatusBar, QToolBar, QVBoxLayout, QWidget from webdrop_bridge.config import Config from webdrop_bridge.core.drag_interceptor import DragInterceptor from webdrop_bridge.core.validator import PathValidator from webdrop_bridge.ui.restricted_web_view import RestrictedWebEngineView +logger = logging.getLogger(__name__) + # Default welcome page HTML when no webapp is configured DEFAULT_WELCOME_PAGE = """ @@ -175,6 +179,7 @@ class MainWindow(QMainWindow): # Signals check_for_updates = Signal() + update_available = Signal(object) # Emits Release object def __init__( self, @@ -424,3 +429,111 @@ class MainWindow(QMainWindow): True if drag was initiated successfully """ return self.drag_interceptor.initiate_drag(file_paths) + + def check_for_updates_startup(self) -> None: + """Check for updates on application startup. + + Runs asynchronously in background without blocking UI. + Uses 24h cache so won't hammer the API. + """ + from webdrop_bridge.core.updater import UpdateManager + + try: + # Create update manager + cache_dir = Path.home() / ".webdrop-bridge" + manager = UpdateManager( + current_version=self.config.app_version, + config_dir=cache_dir + ) + + # Run async check in background + self._run_async_check(manager) + + except Exception as e: + logger.error(f"Failed to initialize update check: {e}") + + def _run_async_check(self, manager) -> None: + """Run update check in background thread. + + Args: + manager: UpdateManager instance + """ + # Create and start background thread + thread = QThread() + worker = UpdateCheckWorker(manager, self.config.app_version) + + # Connect signals + worker.update_available.connect(self._on_update_available) + worker.update_status.connect(self._on_update_status) + worker.finished.connect(thread.quit) + + # Start thread + worker.moveToThread(thread) + thread.started.connect(worker.run) + thread.start() + + def _on_update_status(self, status: str, emoji: str) -> None: + """Handle update status changes. + + Args: + status: Status text + emoji: Status emoji + """ + self.set_update_status(status, emoji) + + def _on_update_available(self, release) -> None: + """Handle update available notification. + + Args: + release: Release object with update info + """ + # Update status to show update available + self.set_update_status(f"Update available: v{release.version}", emoji="✅") + + # Emit signal for main app to show dialog + self.update_available.emit(release) + + +class UpdateCheckWorker: + """Worker for running update check asynchronously.""" + + def __init__(self, manager, current_version: str): + """Initialize worker. + + Args: + manager: UpdateManager instance + current_version: Current app version + """ + self.manager = manager + self.current_version = current_version + + # Create signals + from PySide6.QtCore import Signal + self.update_available = Signal(object) + self.update_status = Signal(str, str) + self.finished = Signal() + + def run(self) -> None: + """Run the update check.""" + try: + # Notify checking status + self.update_status.emit("Checking for updates", "🔄") + + # Run async check + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + release = loop.run_until_complete(self.manager.check_for_updates()) + loop.close() + + # Emit result + if release: + self.update_available.emit(release) + else: + # No update available - show ready status + self.update_status.emit("Ready", "") + + except Exception as e: + logger.error(f"Update check failed: {e}") + self.update_status.emit("Update check failed", "⚠️") + finally: + self.finished.emit() diff --git a/tests/unit/test_startup_check.py b/tests/unit/test_startup_check.py new file mode 100644 index 0000000..b64d912 --- /dev/null +++ b/tests/unit/test_startup_check.py @@ -0,0 +1,128 @@ +"""Tests for update startup check functionality.""" + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from webdrop_bridge.config import Config +from webdrop_bridge.ui.main_window import UpdateCheckWorker + + +@pytest.fixture +def sample_config(tmp_path): + """Create a sample config for testing.""" + return Config( + app_name="Test WebDrop", + app_version="1.0.0", + log_level="INFO", + log_file=None, + allowed_roots=[tmp_path], + allowed_urls=[], + webapp_url="file:///./webapp/index.html", + window_width=800, + window_height=600, + enable_logging=False, + ) + + +class TestUpdateCheckWorker: + """Tests for UpdateCheckWorker.""" + + def test_worker_initialization(self): + """Test worker can be initialized.""" + manager = MagicMock() + worker = UpdateCheckWorker(manager, "0.0.1") + + assert worker.manager is manager + assert worker.current_version == "0.0.1" + + def test_worker_has_signals(self): + """Test worker has required signals.""" + manager = MagicMock() + worker = UpdateCheckWorker(manager, "0.0.1") + + assert hasattr(worker, "update_available") + assert hasattr(worker, "update_status") + assert hasattr(worker, "finished") + + def test_worker_run_method_exists(self): + """Test worker has run method.""" + manager = MagicMock() + worker = UpdateCheckWorker(manager, "0.0.1") + + assert hasattr(worker, "run") + assert callable(worker.run) + + +class TestMainWindowStartupCheck: + """Test startup check integration in MainWindow.""" + + def test_window_has_startup_check_method(self, qtbot, sample_config): + """Test MainWindow has check_for_updates_startup method.""" + from webdrop_bridge.ui.main_window import MainWindow + + window = MainWindow(sample_config) + qtbot.addWidget(window) + + assert hasattr(window, "check_for_updates_startup") + assert callable(window.check_for_updates_startup) + + def test_window_has_update_available_signal(self, qtbot, sample_config): + """Test MainWindow has update_available signal.""" + from webdrop_bridge.ui.main_window import MainWindow + + window = MainWindow(sample_config) + qtbot.addWidget(window) + + assert hasattr(window, "update_available") + + def test_startup_check_initializes_without_error(self, qtbot, sample_config): + """Test startup check can be called without raising.""" + from webdrop_bridge.ui.main_window import MainWindow + + window = MainWindow(sample_config) + qtbot.addWidget(window) + + # Should not raise + window.check_for_updates_startup() + + def test_on_update_status_updates_status_bar(self, qtbot, sample_config): + """Test _on_update_status updates the status bar.""" + from webdrop_bridge.ui.main_window import MainWindow + + window = MainWindow(sample_config) + qtbot.addWidget(window) + + window._on_update_status("Testing", "✓") + assert "Testing" in window.update_status_label.text() + assert "✓" in window.update_status_label.text() + + def test_on_update_available_emits_signal(self, qtbot, sample_config): + """Test _on_update_available emits update_available signal.""" + from webdrop_bridge.ui.main_window import MainWindow + + window = MainWindow(sample_config) + qtbot.addWidget(window) + + # Create mock release + mock_release = MagicMock() + mock_release.version = "0.0.2" + + with qtbot.waitSignal(window.update_available): + window._on_update_available(mock_release) + + def test_on_update_available_updates_status(self, qtbot, sample_config): + """Test _on_update_available updates status bar.""" + from webdrop_bridge.ui.main_window import MainWindow + + window = MainWindow(sample_config) + qtbot.addWidget(window) + + # Create mock release + mock_release = MagicMock() + mock_release.version = "0.0.2" + + window._on_update_available(mock_release) + assert "0.0.2" in window.update_status_label.text() + assert "✅" in window.update_status_label.text()