feat: enhance web engine view with profile isolation and add cache clearing functionality
Some checks failed
Tests & Quality Checks / Test on Python 3.11 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.10 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-2 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-2 (push) Has been cancelled
Tests & Quality Checks / Build Artifacts (push) Has been cancelled
Tests & Quality Checks / Build Artifacts-1 (push) Has been cancelled

This commit is contained in:
claudi 2026-03-03 10:22:14 +01:00
parent ba0594c260
commit 705969cdba
5 changed files with 136 additions and 50 deletions

View file

@ -394,8 +394,10 @@ class MainWindow(QMainWindow):
else: else:
logger.warning(f"Window icon not found at {icon_path}") logger.warning(f"Window icon not found at {icon_path}")
# Create web engine view # Create web engine view with URL for profile isolation
self.web_view = RestrictedWebEngineView(config.allowed_urls) 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 # Enable the main window and web view to receive drag events
self.setAcceptDrops(True) self.setAcceptDrops(True)
@ -1249,6 +1251,11 @@ class MainWindow(QMainWindow):
check_updates_action.setToolTip("Check for Updates") check_updates_action.setToolTip("Check for Updates")
check_updates_action.triggered.connect(self._on_manual_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 file button on the right
log_action = toolbar.addAction("📋") log_action = toolbar.addAction("📋")
log_action.setToolTip("Open Log File") log_action.setToolTip("Open Log File")
@ -1321,6 +1328,34 @@ class MainWindow(QMainWindow):
# Show the dialog # Show the dialog
self.checking_dialog.show() 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: def _show_about_dialog(self) -> None:
"""Show About dialog with version and information.""" """Show About dialog with version and information."""
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox

View file

@ -1,6 +1,7 @@
"""Restricted web view with URL whitelist enforcement for Kiosk-mode.""" """Restricted web view with URL whitelist enforcement for Kiosk-mode."""
import fnmatch import fnmatch
import hashlib
import logging import logging
from pathlib import Path from pathlib import Path
from typing import List, Optional, Union from typing import List, Optional, Union
@ -13,9 +14,6 @@ from PySide6.QtWebEngineWidgets import QWebEngineView
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
class CustomWebEnginePage(QWebEnginePage): class CustomWebEnginePage(QWebEnginePage):
"""Custom page that handles new window requests for downloads.""" """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 empty, no restrictions are applied.
If allowed_urls is not empty, only matching URLs are loaded in the view. If allowed_urls is not empty, only matching URLs are loaded in the view.
Non-matching URLs open in the system default browser. 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. """Initialize the restricted web view.
Args: Args:
allowed_urls: List of allowed URL patterns (empty = no restriction) allowed_urls: List of allowed URL patterns (empty = no restriction)
Patterns support wildcards: *.example.com, localhost, etc. 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__() super().__init__()
self.allowed_urls = allowed_urls or [] self.allowed_urls = allowed_urls or []
self.webapp_url = webapp_url
# Create persistent profile for cookie and session storage # Create persistent profile for cookie and session storage
# Profile is unique per domain to prevent cache corruption
self.profile = self._create_persistent_profile() self.profile = self._create_persistent_profile()
# Use custom page for better download handling with 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 authentication sessions (e.g., Microsoft login) to persist
across application restarts. across application restarts.
Each unique webapp domain gets its own profile to prevent
cache corruption from old domains affecting new domains.
Returns: Returns:
Configured QWebEngineProfile with persistent storage Configured QWebEngineProfile with persistent storage
""" """
@ -149,14 +157,32 @@ class RestrictedWebEngineView(QWebEngineView):
QStandardPaths.StandardLocation.AppDataLocation 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 # 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) profile_path.mkdir(parents=True, exist_ok=True)
# Create persistent profile with custom storage location # Create persistent profile with custom storage location
# Using "webdrop_bridge" as the profile name # Using unique profile name so different domains have isolated caches
# Note: No parent specified so we control the lifecycle profile = QWebEngineProfile(profile_name)
profile = QWebEngineProfile("webdrop_bridge")
profile.setPersistentStoragePath(str(profile_path)) profile.setPersistentStoragePath(str(profile_path))
# Configure persistent cookies (critical for authentication) # Configure persistent cookies (critical for authentication)
@ -170,7 +196,8 @@ class RestrictedWebEngineView(QWebEngineView):
# Set cache size to 100 MB # Set cache size to 100 MB
profile.setHttpCacheMaximumSize(100 * 1024 * 1024) 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("Cookies policy: ForcePersistentCookies")
logger.debug("HTTP cache: DiskHttpCache (100 MB)") logger.debug("HTTP cache: DiskHttpCache (100 MB)")
@ -242,3 +269,19 @@ class RestrictedWebEngineView(QWebEngineView):
return True return True
return False 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")

View file

@ -316,7 +316,7 @@ class TestMainWindowSignals:
qtbot.addWidget(window) qtbot.addWidget(window)
with patch.object(window, "_on_drag_started") as mock_handler: 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() mock_handler.assert_called_once()
def test_drag_failed_signal_connection(self, qtbot, sample_config): def test_drag_failed_signal_connection(self, qtbot, sample_config):
@ -325,7 +325,7 @@ class TestMainWindowSignals:
qtbot.addWidget(window) qtbot.addWidget(window)
with patch.object(window, "_on_drag_failed") as mock_handler: 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() mock_handler.assert_called_once()

View file

@ -8,6 +8,20 @@ from PySide6.QtWebEngineCore import QWebEngineNavigationRequest
from webdrop_bridge.ui.restricted_web_view import RestrictedWebEngineView 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: class TestRestrictedWebEngineView:
"""Test URL whitelist enforcement.""" """Test URL whitelist enforcement."""
@ -16,8 +30,7 @@ class TestRestrictedWebEngineView:
view = RestrictedWebEngineView([]) view = RestrictedWebEngineView([])
# Mock navigation request # Mock navigation request
request = MagicMock(spec=QWebEngineNavigationRequest) request = _create_mock_request("https://example.com/page")
request.url = QUrl("https://example.com/page")
# Should not reject any URL # Should not reject any URL
view._on_navigation_requested(request) view._on_navigation_requested(request)
@ -27,8 +40,7 @@ class TestRestrictedWebEngineView:
"""Test that None allowed_urls means no restrictions.""" """Test that None allowed_urls means no restrictions."""
view = RestrictedWebEngineView(None) view = RestrictedWebEngineView(None)
request = MagicMock(spec=QWebEngineNavigationRequest) request = _create_mock_request("https://blocked.com/page")
request.url = QUrl("https://blocked.com/page")
view._on_navigation_requested(request) view._on_navigation_requested(request)
request.reject.assert_not_called() request.reject.assert_not_called()
@ -37,8 +49,7 @@ class TestRestrictedWebEngineView:
"""Test exact domain matching.""" """Test exact domain matching."""
view = RestrictedWebEngineView(["example.com"]) view = RestrictedWebEngineView(["example.com"])
request = MagicMock(spec=QWebEngineNavigationRequest) request = _create_mock_request("https://example.com/page")
request.url = QUrl("https://example.com/page")
view._on_navigation_requested(request) view._on_navigation_requested(request)
request.reject.assert_not_called() request.reject.assert_not_called()
@ -47,8 +58,7 @@ class TestRestrictedWebEngineView:
"""Test that mismatched domains are rejected.""" """Test that mismatched domains are rejected."""
view = RestrictedWebEngineView(["example.com"]) view = RestrictedWebEngineView(["example.com"])
request = MagicMock(spec=QWebEngineNavigationRequest) request = _create_mock_request("https://other.com/page")
request.url = QUrl("https://other.com/page")
with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"): with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"):
view._on_navigation_requested(request) view._on_navigation_requested(request)
@ -58,8 +68,7 @@ class TestRestrictedWebEngineView:
"""Test wildcard pattern matching.""" """Test wildcard pattern matching."""
view = RestrictedWebEngineView(["*.example.com"]) view = RestrictedWebEngineView(["*.example.com"])
request = MagicMock(spec=QWebEngineNavigationRequest) request = _create_mock_request("https://sub.example.com/page")
request.url = QUrl("https://sub.example.com/page")
view._on_navigation_requested(request) view._on_navigation_requested(request)
request.reject.assert_not_called() request.reject.assert_not_called()
@ -68,8 +77,7 @@ class TestRestrictedWebEngineView:
"""Test that non-matching wildcard patterns are rejected.""" """Test that non-matching wildcard patterns are rejected."""
view = RestrictedWebEngineView(["*.example.com"]) view = RestrictedWebEngineView(["*.example.com"])
request = MagicMock(spec=QWebEngineNavigationRequest) request = _create_mock_request("https://example.org/page")
request.url = QUrl("https://example.org/page")
with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"): with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"):
view._on_navigation_requested(request) view._on_navigation_requested(request)
@ -79,8 +87,7 @@ class TestRestrictedWebEngineView:
"""Test that localhost is allowed.""" """Test that localhost is allowed."""
view = RestrictedWebEngineView(["localhost"]) view = RestrictedWebEngineView(["localhost"])
request = MagicMock(spec=QWebEngineNavigationRequest) request = _create_mock_request("http://localhost:8000/page")
request.url = QUrl("http://localhost:8000/page")
view._on_navigation_requested(request) view._on_navigation_requested(request)
request.reject.assert_not_called() request.reject.assert_not_called()
@ -89,8 +96,7 @@ class TestRestrictedWebEngineView:
"""Test that file:// URLs are always allowed.""" """Test that file:// URLs are always allowed."""
view = RestrictedWebEngineView(["example.com"]) view = RestrictedWebEngineView(["example.com"])
request = MagicMock(spec=QWebEngineNavigationRequest) request = _create_mock_request("file:///var/www/index.html")
request.url = QUrl("file:///var/www/index.html")
view._on_navigation_requested(request) view._on_navigation_requested(request)
request.reject.assert_not_called() request.reject.assert_not_called()
@ -100,20 +106,17 @@ class TestRestrictedWebEngineView:
view = RestrictedWebEngineView(["example.com", "test.org"]) view = RestrictedWebEngineView(["example.com", "test.org"])
# First allowed URL # First allowed URL
request1 = MagicMock(spec=QWebEngineNavigationRequest) request1 = _create_mock_request("https://example.com/page")
request1.url = QUrl("https://example.com/page")
view._on_navigation_requested(request1) view._on_navigation_requested(request1)
request1.reject.assert_not_called() request1.reject.assert_not_called()
# Second allowed URL # Second allowed URL
request2 = MagicMock(spec=QWebEngineNavigationRequest) request2 = _create_mock_request("https://test.org/page")
request2.url = QUrl("https://test.org/page")
view._on_navigation_requested(request2) view._on_navigation_requested(request2)
request2.reject.assert_not_called() request2.reject.assert_not_called()
# Non-allowed URL # Non-allowed URL
request3 = MagicMock(spec=QWebEngineNavigationRequest) request3 = _create_mock_request("https://blocked.com/page")
request3.url = QUrl("https://blocked.com/page")
with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"): with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"):
view._on_navigation_requested(request3) view._on_navigation_requested(request3)
request3.reject.assert_called_once() request3.reject.assert_called_once()
@ -122,15 +125,13 @@ class TestRestrictedWebEngineView:
"""Test that rejected URLs open in system browser.""" """Test that rejected URLs open in system browser."""
view = RestrictedWebEngineView(["allowed.com"]) view = RestrictedWebEngineView(["allowed.com"])
request = MagicMock(spec=QWebEngineNavigationRequest) request = _create_mock_request("https://blocked.com/page")
request.url = QUrl("https://blocked.com/page")
with patch( with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices.openUrl") as mock_open:
"webdrop_bridge.ui.restricted_web_view.QDesktopServices.openUrl"
) as mock_open:
view._on_navigation_requested(request) view._on_navigation_requested(request)
request.reject.assert_called_once() 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: class TestURLAllowedLogic:

View file

@ -44,42 +44,49 @@ class TestSettingsDialogInitialization:
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs is not None 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): def test_dialog_has_paths_tab(self, qtbot, sample_config):
"""Test Paths tab exists.""" """Test Paths tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) 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): def test_dialog_has_urls_tab(self, qtbot, sample_config):
"""Test URLs tab exists.""" """Test URLs tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) 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): def test_dialog_has_logging_tab(self, qtbot, sample_config):
"""Test Logging tab exists.""" """Test Logging tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) 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): def test_dialog_has_window_tab(self, qtbot, sample_config):
"""Test Window tab exists.""" """Test Window tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) 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): def test_dialog_has_profiles_tab(self, qtbot, sample_config):
"""Test Profiles tab exists.""" """Test Profiles tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs.tabText(4) == "Profiles" assert dialog.tabs.tabText(5) == "Profiles"
class TestPathsTab: class TestPathsTab: