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:
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

View file

@ -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")

View file

@ -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()

View file

@ -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

View file

@ -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: