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
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:
parent
ba0594c260
commit
705969cdba
5 changed files with 136 additions and 50 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue