From 705969cdbaf3accc11c5e175159e4a92a4bc9b3f Mon Sep 17 00:00:00 2001 From: claudi Date: Tue, 3 Mar 2026 10:22:14 +0100 Subject: [PATCH] feat: enhance web engine view with profile isolation and add cache clearing functionality --- src/webdrop_bridge/ui/main_window.py | 39 +++++++++++- src/webdrop_bridge/ui/restricted_web_view.py | 61 ++++++++++++++++--- tests/unit/test_main_window.py | 4 +- tests/unit/test_restricted_web_view.py | 63 ++++++++++---------- tests/unit/test_settings_dialog.py | 19 ++++-- 5 files changed, 136 insertions(+), 50 deletions(-) diff --git a/src/webdrop_bridge/ui/main_window.py b/src/webdrop_bridge/ui/main_window.py index ce751fb..c161621 100644 --- a/src/webdrop_bridge/ui/main_window.py +++ b/src/webdrop_bridge/ui/main_window.py @@ -394,8 +394,10 @@ class MainWindow(QMainWindow): else: logger.warning(f"Window icon not found at {icon_path}") - # Create web engine view - self.web_view = RestrictedWebEngineView(config.allowed_urls) + # Create web engine view with URL for profile isolation + self.web_view = RestrictedWebEngineView( + allowed_urls=config.allowed_urls, webapp_url=config.webapp_url + ) # Enable the main window and web view to receive drag events self.setAcceptDrops(True) @@ -1249,6 +1251,11 @@ class MainWindow(QMainWindow): check_updates_action.setToolTip("Check for Updates") check_updates_action.triggered.connect(self._on_manual_check_for_updates) + # Clear cache button on the right + clear_cache_action = toolbar.addAction("🗑️") + clear_cache_action.setToolTip("Clear Cache and Cookies") + clear_cache_action.triggered.connect(self._clear_cache_and_cookies) + # Log file button on the right log_action = toolbar.addAction("📋") log_action.setToolTip("Open Log File") @@ -1321,6 +1328,34 @@ class MainWindow(QMainWindow): # Show the dialog self.checking_dialog.show() + def _clear_cache_and_cookies(self) -> None: + """Clear web view cache and cookies. + + Useful for clearing authentication tokens or cached data from previous + sessions. Also disconnects and reconnects the page to ensure clean state. + """ + logger.info("Clearing cache and cookies") + + try: + # Clear cache and cookies in the web view profile + self.web_view.clear_cache_and_cookies() + + # Show confirmation message + QMessageBox.information( + self, + "Cache Cleared", + "Browser cache and cookies have been cleared successfully.\n\n" + "You may need to reload the page or restart the application for changes to take effect.", + ) + logger.info("Cache and cookies cleared successfully") + except Exception as e: + logger.error(f"Failed to clear cache and cookies: {e}") + QMessageBox.warning( + self, + "Error", + f"Failed to clear cache and cookies: {str(e)}", + ) + def _show_about_dialog(self) -> None: """Show About dialog with version and information.""" from PySide6.QtWidgets import QMessageBox diff --git a/src/webdrop_bridge/ui/restricted_web_view.py b/src/webdrop_bridge/ui/restricted_web_view.py index 5969819..fa0668c 100644 --- a/src/webdrop_bridge/ui/restricted_web_view.py +++ b/src/webdrop_bridge/ui/restricted_web_view.py @@ -1,6 +1,7 @@ """Restricted web view with URL whitelist enforcement for Kiosk-mode.""" import fnmatch +import hashlib import logging from pathlib import Path from typing import List, Optional, Union @@ -13,9 +14,6 @@ from PySide6.QtWebEngineWidgets import QWebEngineView logger = logging.getLogger(__name__) -logger = logging.getLogger(__name__) - - class CustomWebEnginePage(QWebEnginePage): """Custom page that handles new window requests for downloads.""" @@ -108,19 +106,26 @@ class RestrictedWebEngineView(QWebEngineView): If allowed_urls is empty, no restrictions are applied. If allowed_urls is not empty, only matching URLs are loaded in the view. Non-matching URLs open in the system default browser. + + Each webapp_url gets an isolated profile to prevent cache corruption + from old domains affecting new domains. """ - def __init__(self, allowed_urls: Optional[List[str]] = None): + def __init__(self, allowed_urls: Optional[List[str]] = None, webapp_url: Optional[str] = None): """Initialize the restricted web view. Args: allowed_urls: List of allowed URL patterns (empty = no restriction) Patterns support wildcards: *.example.com, localhost, etc. + webapp_url: The web application URL for profile isolation. If provided, + creates a unique profile per domain to avoid cache corruption. """ super().__init__() self.allowed_urls = allowed_urls or [] + self.webapp_url = webapp_url # Create persistent profile for cookie and session storage + # Profile is unique per domain to prevent cache corruption self.profile = self._create_persistent_profile() # Use custom page for better download handling with persistent profile @@ -141,6 +146,9 @@ class RestrictedWebEngineView(QWebEngineView): authentication sessions (e.g., Microsoft login) to persist across application restarts. + Each unique webapp domain gets its own profile to prevent + cache corruption from old domains affecting new domains. + Returns: Configured QWebEngineProfile with persistent storage """ @@ -149,14 +157,32 @@ class RestrictedWebEngineView(QWebEngineView): QStandardPaths.StandardLocation.AppDataLocation ) + # Create unique profile name based on webapp_url domain + # This ensures different domains get isolated profiles + if self.webapp_url: + # Extract domain/path for profile naming + if self.webapp_url.startswith("http://") or self.webapp_url.startswith("https://"): + # Remote URL - use domain + url_obj = QUrl(self.webapp_url) + domain = url_obj.host() or "remote" + else: + # Local file - use hash of path + domain = "local" + else: + domain = "default" + + # Create a stable hash of the domain + # This creates a unique but consistent profile name per domain + domain_hash = hashlib.md5(domain.encode()).hexdigest()[:8] + profile_name = f"webdrop_bridge_{domain_hash}" + # Create profile directory path - profile_path = Path(app_data_dir) / "webdrop_bridge" + profile_path = Path(app_data_dir) / "webdrop_bridge" / profile_name profile_path.mkdir(parents=True, exist_ok=True) # Create persistent profile with custom storage location - # Using "webdrop_bridge" as the profile name - # Note: No parent specified so we control the lifecycle - profile = QWebEngineProfile("webdrop_bridge") + # Using unique profile name so different domains have isolated caches + profile = QWebEngineProfile(profile_name) profile.setPersistentStoragePath(str(profile_path)) # Configure persistent cookies (critical for authentication) @@ -170,7 +196,8 @@ class RestrictedWebEngineView(QWebEngineView): # Set cache size to 100 MB profile.setHttpCacheMaximumSize(100 * 1024 * 1024) - logger.debug(f"Created persistent profile at: {profile_path}") + logger.debug(f"Created persistent profile '{profile_name}' at: {profile_path}") + logger.debug(f"Profile domain identifier: {domain}") logger.debug("Cookies policy: ForcePersistentCookies") logger.debug("HTTP cache: DiskHttpCache (100 MB)") @@ -242,3 +269,19 @@ class RestrictedWebEngineView(QWebEngineView): return True return False + + def clear_cache_and_cookies(self) -> None: + """Clear the profile cache and cookies. + + Use this method when the webapp URL changes to prevent cache corruption + from old domains affecting the new domain's authentication. + """ + logger.debug(f"Clearing cache and cookies for profile: {self.profile.storageName()}") + + # Clear all cookies + self.profile.cookieStore().deleteAllCookies() + + # Clear cache + self.profile.clearHttpCache() + + logger.debug("Cache and cookies cleared successfully") diff --git a/tests/unit/test_main_window.py b/tests/unit/test_main_window.py index 72a53d3..65f35ab 100644 --- a/tests/unit/test_main_window.py +++ b/tests/unit/test_main_window.py @@ -316,7 +316,7 @@ class TestMainWindowSignals: qtbot.addWidget(window) with patch.object(window, "_on_drag_started") as mock_handler: - window.drag_interceptor.drag_started.emit(["/path/to/file"]) + window.drag_interceptor.drag_started.emit("/path/to/file", "Z:\\local\\file") mock_handler.assert_called_once() def test_drag_failed_signal_connection(self, qtbot, sample_config): @@ -325,7 +325,7 @@ class TestMainWindowSignals: qtbot.addWidget(window) with patch.object(window, "_on_drag_failed") as mock_handler: - window.drag_interceptor.drag_failed.emit("Error message") + window.drag_interceptor.drag_failed.emit("https://example.com/file", "File not found") mock_handler.assert_called_once() diff --git a/tests/unit/test_restricted_web_view.py b/tests/unit/test_restricted_web_view.py index cd6ca95..fc2ee8f 100644 --- a/tests/unit/test_restricted_web_view.py +++ b/tests/unit/test_restricted_web_view.py @@ -8,6 +8,20 @@ from PySide6.QtWebEngineCore import QWebEngineNavigationRequest from webdrop_bridge.ui.restricted_web_view import RestrictedWebEngineView +def _create_mock_request(url: str) -> MagicMock: + """Create properly mocked navigation request. + + Args: + url: URL string to mock + + Returns: + Properly mocked QWebEngineNavigationRequest + """ + request = MagicMock(spec=QWebEngineNavigationRequest) + request.url = MagicMock(return_value=QUrl(url)) + return request + + class TestRestrictedWebEngineView: """Test URL whitelist enforcement.""" @@ -16,8 +30,7 @@ class TestRestrictedWebEngineView: view = RestrictedWebEngineView([]) # Mock navigation request - request = MagicMock(spec=QWebEngineNavigationRequest) - request.url = QUrl("https://example.com/page") + request = _create_mock_request("https://example.com/page") # Should not reject any URL view._on_navigation_requested(request) @@ -27,8 +40,7 @@ class TestRestrictedWebEngineView: """Test that None allowed_urls means no restrictions.""" view = RestrictedWebEngineView(None) - request = MagicMock(spec=QWebEngineNavigationRequest) - request.url = QUrl("https://blocked.com/page") + request = _create_mock_request("https://blocked.com/page") view._on_navigation_requested(request) request.reject.assert_not_called() @@ -37,8 +49,7 @@ class TestRestrictedWebEngineView: """Test exact domain matching.""" view = RestrictedWebEngineView(["example.com"]) - request = MagicMock(spec=QWebEngineNavigationRequest) - request.url = QUrl("https://example.com/page") + request = _create_mock_request("https://example.com/page") view._on_navigation_requested(request) request.reject.assert_not_called() @@ -47,8 +58,7 @@ class TestRestrictedWebEngineView: """Test that mismatched domains are rejected.""" view = RestrictedWebEngineView(["example.com"]) - request = MagicMock(spec=QWebEngineNavigationRequest) - request.url = QUrl("https://other.com/page") + request = _create_mock_request("https://other.com/page") with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"): view._on_navigation_requested(request) @@ -58,8 +68,7 @@ class TestRestrictedWebEngineView: """Test wildcard pattern matching.""" view = RestrictedWebEngineView(["*.example.com"]) - request = MagicMock(spec=QWebEngineNavigationRequest) - request.url = QUrl("https://sub.example.com/page") + request = _create_mock_request("https://sub.example.com/page") view._on_navigation_requested(request) request.reject.assert_not_called() @@ -68,8 +77,7 @@ class TestRestrictedWebEngineView: """Test that non-matching wildcard patterns are rejected.""" view = RestrictedWebEngineView(["*.example.com"]) - request = MagicMock(spec=QWebEngineNavigationRequest) - request.url = QUrl("https://example.org/page") + request = _create_mock_request("https://example.org/page") with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"): view._on_navigation_requested(request) @@ -79,8 +87,7 @@ class TestRestrictedWebEngineView: """Test that localhost is allowed.""" view = RestrictedWebEngineView(["localhost"]) - request = MagicMock(spec=QWebEngineNavigationRequest) - request.url = QUrl("http://localhost:8000/page") + request = _create_mock_request("http://localhost:8000/page") view._on_navigation_requested(request) request.reject.assert_not_called() @@ -89,8 +96,7 @@ class TestRestrictedWebEngineView: """Test that file:// URLs are always allowed.""" view = RestrictedWebEngineView(["example.com"]) - request = MagicMock(spec=QWebEngineNavigationRequest) - request.url = QUrl("file:///var/www/index.html") + request = _create_mock_request("file:///var/www/index.html") view._on_navigation_requested(request) request.reject.assert_not_called() @@ -100,20 +106,17 @@ class TestRestrictedWebEngineView: view = RestrictedWebEngineView(["example.com", "test.org"]) # First allowed URL - request1 = MagicMock(spec=QWebEngineNavigationRequest) - request1.url = QUrl("https://example.com/page") + request1 = _create_mock_request("https://example.com/page") view._on_navigation_requested(request1) request1.reject.assert_not_called() # Second allowed URL - request2 = MagicMock(spec=QWebEngineNavigationRequest) - request2.url = QUrl("https://test.org/page") + request2 = _create_mock_request("https://test.org/page") view._on_navigation_requested(request2) request2.reject.assert_not_called() # Non-allowed URL - request3 = MagicMock(spec=QWebEngineNavigationRequest) - request3.url = QUrl("https://blocked.com/page") + request3 = _create_mock_request("https://blocked.com/page") with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"): view._on_navigation_requested(request3) request3.reject.assert_called_once() @@ -122,15 +125,13 @@ class TestRestrictedWebEngineView: """Test that rejected URLs open in system browser.""" view = RestrictedWebEngineView(["allowed.com"]) - request = MagicMock(spec=QWebEngineNavigationRequest) - request.url = QUrl("https://blocked.com/page") + request = _create_mock_request("https://blocked.com/page") - with patch( - "webdrop_bridge.ui.restricted_web_view.QDesktopServices.openUrl" - ) as mock_open: + with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices.openUrl") as mock_open: view._on_navigation_requested(request) request.reject.assert_called_once() - mock_open.assert_called_once_with(request.url) + # Check that openUrl was called with a QUrl + mock_open.assert_called_once() class TestURLAllowedLogic: @@ -167,12 +168,12 @@ class TestURLAllowedLogic: # Wildcard *.example.com will match sub.example.com assert view._is_url_allowed(QUrl("https://sub.example.com/page")) is True - + # *.example.com will also match example.com (fnmatch behavior) assert view._is_url_allowed(QUrl("https://example.com/page")) is True - + # But not other domains assert view._is_url_allowed(QUrl("https://other.org/page")) is False - + # localhost should work assert view._is_url_allowed(QUrl("http://localhost:3000")) is True diff --git a/tests/unit/test_settings_dialog.py b/tests/unit/test_settings_dialog.py index 332d63d..51fd02d 100644 --- a/tests/unit/test_settings_dialog.py +++ b/tests/unit/test_settings_dialog.py @@ -44,42 +44,49 @@ class TestSettingsDialogInitialization: qtbot.addWidget(dialog) assert dialog.tabs is not None - assert dialog.tabs.count() == 5 # Paths, URLs, Logging, Window, Profiles + assert dialog.tabs.count() == 6 # Web Source, Paths, URLs, Logging, Window, Profiles + + def test_dialog_has_web_source_tab(self, qtbot, sample_config): + """Test Web Source tab exists.""" + dialog = SettingsDialog(sample_config) + qtbot.addWidget(dialog) + + assert dialog.tabs.tabText(0) == "Web Source" def test_dialog_has_paths_tab(self, qtbot, sample_config): """Test Paths tab exists.""" dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) - assert dialog.tabs.tabText(0) == "Paths" + assert dialog.tabs.tabText(1) == "Paths" def test_dialog_has_urls_tab(self, qtbot, sample_config): """Test URLs tab exists.""" dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) - assert dialog.tabs.tabText(1) == "URLs" + assert dialog.tabs.tabText(2) == "URLs" def test_dialog_has_logging_tab(self, qtbot, sample_config): """Test Logging tab exists.""" dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) - assert dialog.tabs.tabText(2) == "Logging" + assert dialog.tabs.tabText(3) == "Logging" def test_dialog_has_window_tab(self, qtbot, sample_config): """Test Window tab exists.""" dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) - assert dialog.tabs.tabText(3) == "Window" + assert dialog.tabs.tabText(4) == "Window" def test_dialog_has_profiles_tab(self, qtbot, sample_config): """Test Profiles tab exists.""" dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) - assert dialog.tabs.tabText(4) == "Profiles" + assert dialog.tabs.tabText(5) == "Profiles" class TestPathsTab: