diff --git a/docs/HOVER_EFFECTS_ANALYSIS.md b/docs/HOVER_EFFECTS_ANALYSIS.md deleted file mode 100644 index 869027b..0000000 --- a/docs/HOVER_EFFECTS_ANALYSIS.md +++ /dev/null @@ -1,194 +0,0 @@ -# Hover Effects Analysis - Qt WebEngineView Limitation - -## Executive Summary - -**Status**: Hover effects partially functional in Qt WebEngineView, with a clear Qt limitation identified. - -- ✅ **Checkbox hover**: Works correctly -- ✅ **Event detection**: Polling-based detection functional -- ❌ **Menu expansion via :hover**: Does NOT work (Qt limitation) -- ❌ **Tailwind CSS :hover-based effects**: Do NOT work in Qt - -## Investigation Results - -### Test Environment -- **Framework**: PySide6 QWebEngineView -- **Web App**: Angular + Tailwind CSS -- **Browser Test**: Google Chrome (reference) -- **Test Date**: March 4, 2026 - -### Chrome Browser Results -Both menu expansion and checkbox hover work perfectly in Chrome browser. This confirms the issue is **Qt-specific**, not a web application problem. - -### Qt WebEngineView Results - -#### What Works ✅ -1. **Checkbox hover effects** - - Checkboxes appear on hover - - CSS-based simulation via `.__mouse_hover` class works correctly - - `input[type="checkbox"].__mouse_hover` CSS selector successfully applied - -2. **Event detection** - - Mouse position tracking: Working - - `document.elementFromPoint()` polling: Working (50ms interval) - - `mouseover`, `mouseenter`, `mouseleave`, `mousemove` event dispatching: Working - - Angular event listeners: Receiving dispatched events correctly - -3. **DOM element access** - - Menu element found with `querySelectorAll()` - - Event listeners identified: `{click: Array(1)}` - - Not in Shadow DOM (accessible from JavaScript) - -#### What Doesn't Work ❌ -1. **Menu expansion via Tailwind :hover** - - Menu element: `.group` class with `hover:bg-neutral-300` - - Menu children have: `.group-hover:w-full` (Tailwind pattern) - - Expected behavior: `.group:hover > .group-hover:w-full` triggers on hover - - Actual behavior: No expansion (`:hover` pseudo-selector not activated) - -2. **Tailwind CSS :hover-based styles** - - Pattern: `.group:hover > .group-hover:*` (Tailwind generated) - - Root cause: Qt doesn't properly set `:hover` pseudo-selector state for dispatched events - - Impact: Any CSS rule depending on `:hover` pseudo-selector won't work - -## Technical Analysis - -### The Core Issue - -Qt WebEngineView doesn't forward native mouse events to JavaScript in a way that properly triggers the CSS `:hover` pseudo-selector. When we dispatch synthetic events: - -```javascript -element.dispatchEvent(new MouseEvent("mouseover", {...})); -element.dispatchEvent(new MouseEvent("mouseenter", {...})); -``` - -The browser's CSS engine **does not** update the `:hover` pseudo-selector state. This is different from a native browser, where: - -1. User moves mouse -2. Browser kernel detects native hover -3. `:hover` pseudo-selector activates -4. CSS rules matching `:hover` are applied - -### Evidence - -**Chrome DevTools inspection** revealed: -``` -Event Listeners: {click: Array(1)} // Only CLICK handler, NO hover handlers -Menu element className: "flex h-14 w-full items-center p-2 transition-colors hover:bg-neutral-300 ... group" -``` - -The Angular app handles UI in two ways: -1. **Click events**: Directly handled by JavaScript listeners → Works -2. **Hover effects**: Rely on CSS `:hover` pseudo-selector → Doesn't work in Qt - -### Why This Is a Limitation - -This is not fixable by JavaScript injection because: - -1. **JavaScript can't activate CSS `:hover`**: The `:hover` pseudo-selector is a browser-native feature that only CSS engines can modify. JavaScript can't directly trigger it. - -2. **Tailwind CSS is static**: Tailwind generates CSS rules like `.group:hover > .group-hover:w-full { width: 11rem; }`. These rules expect the `:hover` pseudo-selector to be active—JavaScript can't force them to apply. - -3. **Qt engine limitation**: Qt WebEngineView's Chromium engine doesn't properly handle `:hover` for non-native events. - -### What We Tried - -| Approach | Result | Notes | -|----------|--------|-------| -| Direct CSS class injection | ❌ Failed | `.group.__mouse_hover` doesn't trigger Tailwind rules | -| PointerEvent dispatch | ❌ Failed | Modern API didn't help | -| JavaScript style manipulation | ❌ Failed | Can't force Tailwind CSS rules via JS | -| Polling + synthetic mouse events | ⚠️ Partial | Works for custom handlers, not for `:hover` | - -## Implementation Status - -### Current Solution -File: [mouse_event_emulator.js](../src/webdrop_bridge/ui/mouse_event_emulator.js) - -**What it does:** -1. Polls `document.elementFromPoint()` every 50ms to detect element changes -2. Dispatches `mouseover`, `mouseenter`, `mouseleave`, `mousemove` events -3. Applies `.__mouse_hover` CSS class for custom hover simulation -4. Works for elements with JavaScript event handlers - -**What it doesn't do:** -1. Cannot activate `:hover` pseudo-selector -2. Cannot trigger Tailwind CSS hover-based rules -3. Cannot fix Qt's limitation - -### Performance -- CPU overhead: Minimal (polling every 50ms on idle) -- Startup impact: Negligible -- Memory footprint: ~2KB script size - -## Verification Steps - -To verify this limitation exists in your Qt environment: - -### Chrome Test -1. Open web app in Chrome -2. Hover over menu → Menu expands ✅ -3. Hover over checkbox → Checkbox appears ✅ - -### Qt Test -1. Run application in Qt -2. Hover over menu → Menu does NOT expand ❌ (known limitation) -3. Hover over checkbox → Checkbox appears ✅ (works via CSS class) - -### Debug Verification (if needed) -In Chrome DevTools console: - -```javascript -// Find menu element -const menuGroup = document.querySelector('[class*="group"]'); -console.log("Menu group:", menuGroup?.className); - -// Check for Shadow DOM -const inShadow = menuGroup?.getRootNode() !== document; -console.log("In Shadow DOM:", inShadow); // Should be false - -// Check event listeners -console.log("Event Listeners:", getEventListeners(menuGroup)); // Shows if handlers exist -``` - -Results: -- Menu element: Found -- Shadow DOM: No -- Event listeners: `{click: Array(1)}` (only click, no hover handlers) - -## Recommendations - -### What Developers Should Know -1. **Don't expect :hover effects to work in Qt WebEngineView** - - This is a known limitation, not a bug in WebDrop Bridge - - The application itself works correctly in Chrome - -2. **Workarounds for your web app** - - Replace `:hover` with JavaScript click handlers - - Add click-to-toggle functionality instead of hover - - This is outside the scope of WebDrop Bridge - -3. **For similar Qt projects** - - Be aware of this `:hover` pseudo-selector limitation when embedding web content - - Consider detecting Qt environment and serving alternative UI - - Test web apps in actual Chrome browser before embedding in Qt - -### Future Improvements (Not Feasible) -The following would require Qt framework modifications: -- Improving QWebEngineView's `:hover` pseudo-selector support -- Better mouse event forwarding to browser CSS engine -- Custom CSS selector handling in embedded browser - -None of these are achievable through application-level code. - -## Summary - -WebDrop Bridge successfully emulates hover behavior for elements with JavaScript event handlers (like checkboxes). However, Tailwind CSS and other frameworks that rely on the CSS `:hover` pseudo-selector will not work fully in Qt WebEngineView due to an inherent limitation in how Qt forwards mouse events to the browser's CSS engine. - -This is not a defect in WebDrop Bridge, but rather a limitation of embedding web content in Qt applications. The web application works perfectly in standard browsers like Chrome. - ---- - -**Status**: Issue Closed - Limitation Documented -**Last Updated**: March 4, 2026 -**Severity**: Low (UI-only, core functionality unaffected) diff --git a/src/webdrop_bridge/core/drag_interceptor.py b/src/webdrop_bridge/core/drag_interceptor.py index 6f37ce5..f00eae2 100644 --- a/src/webdrop_bridge/core/drag_interceptor.py +++ b/src/webdrop_bridge/core/drag_interceptor.py @@ -2,7 +2,7 @@ import logging from pathlib import Path -from typing import List, Optional, Union +from typing import List, Optional from PySide6.QtCore import QMimeData, Qt, QUrl, Signal from PySide6.QtGui import QDrag @@ -21,18 +21,14 @@ class DragInterceptor(QWidget): Intercepts drag events from web content, converts Azure Blob Storage URLs to local paths, validates them, and initiates native Qt drag operations. - Supports both single and multiple file drag operations. - Signals: drag_started: Emitted when a drag operation begins successfully - (source_urls_or_paths: str, local_paths: str - comma-separated for multiple) drag_failed: Emitted when drag initiation fails - (source_urls_or_paths: str, error_message: str) """ # Signals with string parameters - drag_started = Signal(str, str) # (source_urls_or_paths, local_paths) - drag_failed = Signal(str, str) # (source_urls_or_paths, error_message) + drag_started = Signal(str, str) # (url_or_path, local_path) + drag_failed = Signal(str, str) # (url_or_path, error_message) def __init__(self, config: Config, parent: Optional[QWidget] = None): """Initialize the drag interceptor. @@ -44,123 +40,83 @@ class DragInterceptor(QWidget): super().__init__(parent) self.config = config self._validator = PathValidator( - config.allowed_roots, check_file_exists=config.check_file_exists + config.allowed_roots, + check_file_exists=config.check_file_exists ) self._url_converter = URLConverter(config) - def handle_drag(self, text_or_list: Union[str, List[str]]) -> bool: - """Handle drag event from web view (single or multiple files). + def handle_drag(self, text: str) -> bool: + """Handle drag event from web view. - Determines if the text/list contains Azure URLs or file paths, converts if needed, + Determines if the text is an Azure URL or file path, converts if needed, validates, and initiates native drag operation. - Supports: - - Single string (backward compatible) - - List of strings (multiple drag support) - Args: - text_or_list: Azure URL/file path (str) or list of URLs/paths (List[str]) + text: Azure Blob Storage URL or file path from web drag Returns: True if native drag was initiated, False otherwise """ - # Normalize input to list - if isinstance(text_or_list, str): - text_list = [text_or_list] - elif isinstance(text_or_list, (list, tuple)): - text_list = list(text_or_list) - else: - error_msg = f"Unexpected drag data type: {type(text_or_list)}" - logger.error(error_msg) - self.drag_failed.emit("", error_msg) - return False - - # Validate that we have content - if not text_list or all(not t or not str(t).strip() for t in text_list): + if not text or not text.strip(): error_msg = "Empty drag text" logger.warning(error_msg) self.drag_failed.emit("", error_msg) return False - # Clean up text items - text_list = [str(t).strip() for t in text_list if str(t).strip()] - logger.debug(f"Handling drag for {len(text_list)} item(s)") + text = text.strip() + logger.debug(f"Handling drag for text: {text}") - # Convert each text to local path - local_paths = [] - source_texts = [] - - for text in text_list: - # Check if it's an Azure URL and convert to local path - if self._url_converter.is_azure_url(text): - local_path = self._url_converter.convert_url_to_path(text) - if local_path is None: - error_msg = f"No mapping found for URL: {text}" - logger.warning(error_msg) - self.drag_failed.emit(text, error_msg) - return False - source_texts.append(text) - else: - # Treat as direct file path - local_path = Path(text) - source_texts.append(text) - - # Validate the path - try: - self._validator.validate(local_path) - except ValidationError as e: - error_msg = str(e) - logger.warning(f"Validation failed for {local_path}: {error_msg}") + # Check if it's an Azure URL and convert to local path + if self._url_converter.is_azure_url(text): + local_path = self._url_converter.convert_url_to_path(text) + if local_path is None: + error_msg = "No mapping found for URL" + logger.warning(f"{error_msg}: {text}") self.drag_failed.emit(text, error_msg) return False + source_text = text + else: + # Treat as direct file path + local_path = Path(text) + source_text = text - local_paths.append(local_path) + # Validate the path + try: + self._validator.validate(local_path) + except ValidationError as e: + error_msg = str(e) + logger.warning(f"Validation failed for {local_path}: {error_msg}") + self.drag_failed.emit(source_text, error_msg) + return False - logger.info( - f"Initiating drag for {len(local_paths)} file(s): {[str(p) for p in local_paths]}" - ) + logger.info(f"Initiating drag for: {local_path}") - # Create native file drag with all paths - success = self._create_native_drag(local_paths) + # Create native file drag + success = self._create_native_drag(local_path) if success: - source_str = " | ".join(source_texts) if len(source_texts) > 1 else source_texts[0] - paths_str = ( - " | ".join(str(p) for p in local_paths) - if len(local_paths) > 1 - else str(local_paths[0]) - ) - self.drag_started.emit(source_str, paths_str) + self.drag_started.emit(source_text, str(local_path)) else: error_msg = "Failed to create native drag operation" logger.error(error_msg) - source_str = " | ".join(source_texts) if len(source_texts) > 1 else source_texts[0] - self.drag_failed.emit(source_str, error_msg) + self.drag_failed.emit(source_text, error_msg) return success - def _create_native_drag(self, file_paths: Union[Path, List[Path]]) -> bool: + def _create_native_drag(self, file_path: Path) -> bool: """Create a native file system drag operation. Args: - file_paths: Single local file path or list of local file paths + file_path: Local file path to drag Returns: True if drag was created successfully """ try: - # Normalize to list - if isinstance(file_paths, Path): - paths_list = [file_paths] - else: - paths_list = list(file_paths) - - # Create MIME data with file URLs + # Create MIME data with file URL mime_data = QMimeData() - file_urls = [QUrl.fromLocalFile(str(p)) for p in paths_list] - mime_data.setUrls(file_urls) - - logger.debug(f"Creating drag with {len(file_urls)} file(s)") + file_url = QUrl.fromLocalFile(str(file_path)) + mime_data.setUrls([file_url]) # Create and execute drag drag = QDrag(self) diff --git a/src/webdrop_bridge/ui/bridge_script_intercept.js b/src/webdrop_bridge/ui/bridge_script_intercept.js index 53e28f0..e600d0d 100644 --- a/src/webdrop_bridge/ui/bridge_script_intercept.js +++ b/src/webdrop_bridge/ui/bridge_script_intercept.js @@ -11,46 +11,41 @@ console.log('%c[WebDrop Intercept] Script loaded - INTERCEPT_ENABLED=' + INTERCEPT_ENABLED, 'background: #2196F3; color: white; font-weight: bold; padding: 4px 8px;'); - var currentDragUrls = []; // Array to support multiple URLs + var currentDragUrl = null; var angularDragHandlers = []; var originalAddEventListener = EventTarget.prototype.addEventListener; var listenerPatchActive = true; - var dragHandlerInstalled = false; - // Capture Authorization token from XHR requests (only if checkout is enabled) + // Capture Authorization token from XHR requests window.capturedAuthToken = null; - if (window.webdropConfig && window.webdropConfig.enableCheckout) { - console.log('[Intercept] Auth token capture enabled (checkout feature active)'); - var originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; - XMLHttpRequest.prototype.setRequestHeader = function(header, value) { - if (header === 'Authorization' && value.startsWith('Bearer ')) { - window.capturedAuthToken = value; - console.log('[Intercept] Captured auth token'); - } - return originalXHRSetRequestHeader.apply(this, arguments); - }; - } else { - console.log('[Intercept] Auth token capture disabled (checkout feature inactive)'); - } - - // Only patch addEventListener for dragstart events - // This minimizes impact on other event listeners (mouseover, mouseenter, etc.) - EventTarget.prototype.addEventListener = function(type, listener, options) { - if (listenerPatchActive && type === 'dragstart' && listener) { - // Store Angular's dragstart handler instead of registering it - console.log('[Intercept] Storing Angular dragstart listener for', this.tagName || this.constructor.name); - angularDragHandlers.push({ - target: this, - listener: listener, - options: options - }); - return; // Don't actually register it yet + var originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; + XMLHttpRequest.prototype.setRequestHeader = function(header, value) { + if (header === 'Authorization' && value.startsWith('Bearer ')) { + window.capturedAuthToken = value; + console.log('[Intercept] Captured auth token'); } - // All other events (mouseover, mouseenter, mousedown, etc.): use original - // This is critical to ensure mouseover/hover events work properly - return originalAddEventListener.call(this, type, listener, options); + return originalXHRSetRequestHeader.apply(this, arguments); }; + // ============================================================================ + // PART 1: Intercept Angular's dragstart listener registration + // ============================================================================ + + EventTarget.prototype.addEventListener = function(type, listener, options) { + if (listenerPatchActive && type === 'dragstart' && listener) { + // Store Angular's dragstart handler instead of registering it + console.log('[Intercept] Storing Angular dragstart listener for', this.tagName || this.constructor.name); + angularDragHandlers.push({ + target: this, + listener: listener, + options: options + }); + return; // Don't actually register it yet + } + // All other events: use original + return originalAddEventListener.call(this, type, listener, options); + }; + // ============================================================================ // PART 2: Intercept DataTransfer.setData to capture URL // ============================================================================ @@ -59,14 +54,8 @@ DataTransfer.prototype.setData = function(format, data) { if (format === 'text/plain' || format === 'text/uri-list') { - // text/uri-list contains newline-separated URLs - // text/plain may be single URL or multiple newline-separated URLs - currentDragUrls = data.trim().split('\n').filter(function(url) { - return url.trim().length > 0; - }).map(function(url) { - return url.trim(); - }); - console.log('%c[Intercept] Captured ' + currentDragUrls.length + ' URL(s)', 'color: #4CAF50; font-weight: bold;', currentDragUrls[0].substring(0, 60)); + currentDragUrl = data; + console.log('%c[Intercept] Captured URL:', 'color: #4CAF50; font-weight: bold;', data.substring(0, 80)); } return originalSetData.call(this, format, data); }; @@ -86,20 +75,16 @@ return; } - // Only install once, even if called multiple times - if (dragHandlerInstalled) { - console.log('[Intercept] Handler already installed, skipping'); - return; - } - - dragHandlerInstalled = true; - - // NOTE: Keep listenerPatchActive = true to catch new Angular handlers registered later - // This is important for page reloads where Angular might register handlers at different times + // Stop intercepting addEventListener + listenerPatchActive = false; // Register OUR handler in capture phase originalAddEventListener.call(document, 'dragstart', function(e) { - currentDragUrls = []; // Reset + currentDragUrl = null; // Reset + + // Check once per drag if native DnD is disabled via URL param (e.g. ?disablednd=true) + var disabledNativeDnD = /[?&]disablednd=true/i.test(window.location.search); + console.log('%c[Intercept] dragstart', 'background: #FF9800; color: white; padding: 2px 6px;', 'ALT:', e.altKey, '| disablednd:', disabledNativeDnD); // Call Angular's handlers first to let them set the data var handled = 0; @@ -116,41 +101,33 @@ } } - console.log('[Intercept] Called', handled, 'Angular handlers, URLs:', currentDragUrls.length, 'URL(s)', currentDragUrls.length > 0 ? currentDragUrls[0].substring(0, 60) : 'none'); + console.log('[Intercept] Called', handled, 'Angular handlers, URL:', currentDragUrl ? currentDragUrl.substring(0, 60) : 'none'); // NOW check if we should intercept - // Intercept any drag with URLs that match our configured mappings - if (currentDragUrls.length > 0) { + // Intercept when: Alt key held (normal mode) OR native DnD disabled via URL param + if ((e.altKey || disabledNativeDnD) && currentDragUrl) { var shouldIntercept = false; - // Check each URL against configured URL mappings - // Intercept if ANY URL matches + // Check against configured URL mappings if (window.webdropConfig && window.webdropConfig.urlMappings) { - for (var k = 0; k < currentDragUrls.length; k++) { - var dragUrl = currentDragUrls[k]; - for (var j = 0; j < window.webdropConfig.urlMappings.length; j++) { - var mapping = window.webdropConfig.urlMappings[j]; - if (dragUrl.toLowerCase().startsWith(mapping.url_prefix.toLowerCase())) { - shouldIntercept = true; - console.log('[Intercept] URL #' + (k+1) + ' matches mapping for:', mapping.local_path); - break; - } + for (var j = 0; j < window.webdropConfig.urlMappings.length; j++) { + var mapping = window.webdropConfig.urlMappings[j]; + if (currentDragUrl.toLowerCase().startsWith(mapping.url_prefix.toLowerCase())) { + shouldIntercept = true; + console.log('[Intercept] URL matches mapping for:', mapping.local_path); + break; } - if (shouldIntercept) break; } } else { // Fallback: Check for legacy Z: drive pattern if no config available - for (var k = 0; k < currentDragUrls.length; k++) { - if (/^z:/i.test(currentDragUrls[k])) { - shouldIntercept = true; - console.warn('[Intercept] Using fallback Z: drive pattern (no URL mappings configured)'); - break; - } + shouldIntercept = /^z:/i.test(currentDragUrl); + if (shouldIntercept) { + console.warn('[Intercept] Using fallback Z: drive pattern (no URL mappings configured)'); } } if (shouldIntercept) { - console.log('%c[Intercept] PREVENTING browser drag, using Qt for ' + currentDragUrls.length + ' file(s)', + console.log('%c[Intercept] PREVENTING browser drag, using Qt', 'background: #F44336; color: white; font-weight: bold; padding: 4px 8px;'); e.preventDefault(); @@ -158,15 +135,14 @@ ensureChannel(function() { if (window.bridge && typeof window.bridge.start_file_drag === 'function') { - console.log('%c[Intercept] → Qt: start_file_drag with ' + currentDragUrls.length + ' file(s)', 'color: #9C27B0; font-weight: bold;'); - // Pass as JSON string to avoid Qt WebChannel array conversion issues - window.bridge.start_file_drag(JSON.stringify(currentDragUrls)); + console.log('%c[Intercept] → Qt: start_file_drag', 'color: #9C27B0; font-weight: bold;'); + window.bridge.start_file_drag(currentDragUrl); } else { console.error('[Intercept] bridge.start_file_drag not available!'); } }); - currentDragUrls = []; + currentDragUrl = null; return false; } } @@ -178,23 +154,8 @@ } // Wait for Angular to register its listeners, then install our handler - // Start checking after 3 seconds (give Angular time to load), then retry for up to 30 seconds - var installRetries = 0; - var maxRetries = 27; // 3 initial + 27 retries * 1s = 30s total - - function scheduleInstall() { - if (dragHandlerInstalled) return; // Already done - installRetries++; - console.log('[Intercept] Install attempt', installRetries, '/', maxRetries + 3); - installDragHandler(); - if (!dragHandlerInstalled && installRetries < maxRetries) { - setTimeout(scheduleInstall, 1000); - } else if (!dragHandlerInstalled) { - console.warn('[Intercept] Gave up waiting for Angular handlers after 30s'); - } - } - - setTimeout(scheduleInstall, 3000); + // Start checking after 2 seconds (give Angular time to load on first page load) + setTimeout(installDragHandler, 2000); // ============================================================================ // PART 3: QWebChannel connection @@ -230,7 +191,7 @@ }); } - console.log('%c[WebDrop Intercept] Ready! URL-mapped drags will use Qt file drag.', + console.log('%c[WebDrop Intercept] Ready! ALT-drag or ?disablednd=true will use Qt file drag.', 'background: #4CAF50; color: white; font-weight: bold; padding: 4px 8px;'); } catch(e) { console.error('[WebDrop Intercept] FATAL ERROR in bridge script:', e); diff --git a/src/webdrop_bridge/ui/main_window.py b/src/webdrop_bridge/ui/main_window.py index 42bfaf5..c161621 100644 --- a/src/webdrop_bridge/ui/main_window.py +++ b/src/webdrop_bridge/ui/main_window.py @@ -7,7 +7,7 @@ import re import sys from datetime import datetime from pathlib import Path -from typing import Optional, Union +from typing import Optional from PySide6.QtCore import ( QEvent, @@ -312,34 +312,21 @@ class _DragBridge(QObject): self.window = window @Slot(str) - def start_file_drag(self, paths_text: str) -> None: - """Start a native file drag for the given path(s) or Azure URL(s). - - Called from JavaScript when user drags item(s). - Accepts either: - - Single file path string or Azure URL - - JSON array string of file paths or Azure URLs (multiple drag support) + def start_file_drag(self, path_text: str) -> None: + """Start a native file drag for the given path or Azure URL. + Called from JavaScript when user drags an item. + Accepts either local file paths or Azure Blob Storage URLs. Defers execution to avoid Qt drag manager state issues. Args: - paths_text: String (single path/URL) or JSON array string (multiple paths/URLs) + path_text: File path string or Azure URL to drag """ - logger.debug(f"Bridge: start_file_drag called with {len(paths_text)} chars") + logger.debug(f"Bridge: start_file_drag called for {path_text}") - # Try to parse as JSON array first (for multiple-drag support) - paths_list: Union[str, list] = paths_text - if paths_text.startswith("["): - try: - parsed = json.loads(paths_text) - if isinstance(parsed, list): - paths_list = parsed - logger.debug(f"Parsed JSON array with {len(parsed)} item(s)") - except (json.JSONDecodeError, TypeError) as e: - logger.warning(f"Failed to parse JSON array: {e}, treating as single string") - - # Handle both single string and list - QTimer.singleShot(0, lambda: self.window.drag_interceptor.handle_drag(paths_list)) + # Defer to avoid drag manager state issues + # handle_drag() handles URL conversion and validation internally + QTimer.singleShot(0, lambda: self.window.drag_interceptor.handle_drag(path_text)) @Slot(str) def debug_log(self, message: str) -> None: @@ -534,8 +521,8 @@ class MainWindow(QMainWindow): def _install_bridge_script(self) -> None: """Install the drag bridge JavaScript via QWebEngineScript. - Uses Deferred injection point to ensure script runs after the DOM is ready, - allowing proper event listener registration without race conditions. + Uses DocumentCreation injection point to ensure script runs as early as possible, + before any page scripts that might interfere with drag events. Embeds qwebchannel.js inline to avoid CSP issues with qrc:// URLs. Injects configuration that bridge script uses for dynamic URL pattern matching. @@ -544,9 +531,7 @@ class MainWindow(QMainWindow): script = QWebEngineScript() script.setName("webdrop-bridge") - # Use Deferred instead of DocumentCreation to allow DOM to be ready first - # This prevents race conditions with JavaScript event listeners - script.setInjectionPoint(QWebEngineScript.InjectionPoint.Deferred) + script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation) script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld) script.setRunsOnSubFrames(False) @@ -635,47 +620,18 @@ class MainWindow(QMainWindow): else: logger.debug("Download interceptor not found (optional)") - # Load mouse event emulator for hover effect support - mouse_emulator_search_paths = [] - mouse_emulator_search_paths.append(Path(__file__).parent / "mouse_event_emulator.js") - if hasattr(sys, "_MEIPASS"): - mouse_emulator_search_paths.append( - Path(sys._MEIPASS) / "webdrop_bridge" / "ui" / "mouse_event_emulator.js" # type: ignore - ) - mouse_emulator_search_paths.append( - exe_dir / "webdrop_bridge" / "ui" / "mouse_event_emulator.js" - ) - - mouse_emulator_code = "" - for path in mouse_emulator_search_paths: - if path.exists(): - try: - with open(path, "r", encoding="utf-8") as f: - mouse_emulator_code = f.read() - logger.debug(f"Loaded mouse event emulator from {path}") - break - except (OSError, IOError) as e: - logger.warning(f"Mouse event emulator exists but failed to load: {e}") - if not mouse_emulator_code: - logger.debug("Mouse event emulator not found (optional)") - - # Combine: qwebchannel.js + config + bridge script + download interceptor + mouse emulator + # Combine: qwebchannel.js + config + bridge script + download interceptor combined_code = qwebchannel_code + "\n\n" + config_code + "\n\n" + bridge_code if download_interceptor_code: combined_code += "\n\n" + download_interceptor_code - # Add mouse event emulator last to ensure it runs after all other scripts - if mouse_emulator_code: - combined_code += "\n\n" + mouse_emulator_code - logger.debug( f"Combined script size: {len(combined_code)} chars " f"(qwebchannel: {len(qwebchannel_code)}, " f"config: {len(config_code)}, " f"bridge: {len(bridge_code)}, " - f"interceptor: {len(download_interceptor_code)}, " - f"mouse_emulator: {len(mouse_emulator_code)})" + f"interceptor: {len(download_interceptor_code)})" ) logger.debug(f"URL mappings in config: {len(self.config.url_mappings)}") for i, mapping in enumerate(self.config.url_mappings): @@ -711,11 +667,10 @@ class MainWindow(QMainWindow): logger.debug(f" [{i+1}] {m['url_prefix']} -> {m['local_path']}") # Generate config object as JSON - config_obj = {"urlMappings": mappings, "enableCheckout": self.config.enable_checkout} + config_obj = {"urlMappings": mappings} config_json = json.dumps(config_obj) logger.debug(f"Config JSON size: {len(config_json)} bytes") - logger.debug(f"Checkout enabled: {self.config.enable_checkout}") # Generate JavaScript code - Safe injection with error handling config_js = f""" @@ -725,7 +680,6 @@ class MainWindow(QMainWindow): console.log('[WebDrop Config] Starting configuration injection...'); window.webdropConfig = {config_json}; console.log('[WebDrop Config] Configuration object created'); - console.log('[WebDrop Config] Checkout enabled: ' + window.webdropConfig.enableCheckout); if (window.webdropConfig && window.webdropConfig.urlMappings) {{ console.log('[WebDrop Config] SUCCESS: ' + window.webdropConfig.urlMappings.length + ' URL mappings loaded'); @@ -1381,11 +1335,11 @@ class MainWindow(QMainWindow): 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, @@ -1430,146 +1384,12 @@ class MainWindow(QMainWindow): QMessageBox.about(self, f"About {self.config.app_name}", about_text) def _show_settings_dialog(self) -> None: - """Show Settings dialog for configuration management. - - After closing, checks if webapp URL changed and reloads if needed. - For domain changes, shows restart dialog. - For path-only changes, reloads silently without dialog. - """ + """Show Settings dialog for configuration management.""" from webdrop_bridge.ui.settings_dialog import SettingsDialog - # Store current URL before opening dialog - old_webapp_url = self.config.webapp_url - - # Show dialog dialog = SettingsDialog(self.config, self) dialog.exec() - # Check if webapp URL changed - new_webapp_url = self.config.webapp_url - if old_webapp_url != new_webapp_url: - logger.info(f"Web application URL changed: {old_webapp_url} → {new_webapp_url}") - - # Check if domain changed (not just path) - domain_changed = self._check_domain_changed(old_webapp_url, new_webapp_url) - - if domain_changed: - logger.warning("Domain has changed - recommending restart") - self._handle_domain_change_restart() - else: - logger.info("Path changed but domain is same - reloading...") - # Clear cache and navigate to home asynchronously - # Use timer to ensure dialog is fully closed before reloading - self.web_view.clear_cache_and_cookies() - QTimer.singleShot(100, self._navigate_home) - - def _check_domain_changed(self, old_url: str, new_url: str) -> bool: - """Check if the domain/host has changed between two URLs. - - Args: - old_url: Previous URL - new_url: New URL - - Returns: - True if domain changed, False if only path changed - """ - from urllib.parse import urlparse - - try: - old_parts = urlparse(old_url) - new_parts = urlparse(new_url) - - old_host = old_parts.netloc or old_parts.path - new_host = new_parts.netloc or new_parts.path - - return old_host != new_host - except Exception as e: - logger.warning(f"Could not parse URLs for domain comparison: {e}") - return True # Assume domain changed if we can't parse - - def _handle_domain_change_restart(self) -> None: - """Handle domain change with restart dialog. - - Shows dialog asking user to restart application with options: - - Restart now (automatic) - - Restart later (manual) - - Cancel restart (undo URL change) - """ - from PySide6.QtCore import QProcess - from PySide6.QtGui import QIcon - from PySide6.QtWidgets import QMessageBox - - msg = QMessageBox(self) - msg.setWindowTitle("Domain Changed - Restart Recommended") - msg.setIcon(QMessageBox.Icon.Warning) - msg.setText( - "Web Application Domain Has Changed\n\n" - "You've switched to a different domain. For maximum stability and " - "to ensure proper authentication, the application should be restarted.\n\n" - "The profile and cache have been cleared, but we recommend restarting." - ) - - # Add custom buttons - restart_now_btn = msg.addButton("Restart Now", QMessageBox.ButtonRole.AcceptRole) - restart_later_btn = msg.addButton("Restart Later", QMessageBox.ButtonRole.RejectRole) - - msg.exec() - - if msg.clickedButton() == restart_now_btn: - logger.info("User chose to restart application now") - self._restart_application() - else: - logger.info("User chose to restart later - clearing cache and loading new URL") - # Clear cache and load new URL directly - self.web_view.clear_cache_and_cookies() - self._navigate_home() - - def _restart_application(self) -> None: - """Restart the application automatically. - - Starts a new process with the same arguments and closes the current application. - """ - import os - import sys - - from PySide6.QtCore import QProcess - - logger.info("Restarting application...") - - try: - # Get the path to the Python executable - if hasattr(sys, "_MEIPASS"): - # Running as PyInstaller bundle - executable = sys.executable - else: - # Running in development mode - executable = sys.executable - - # Get the module to run - module_args = ["-m", "webdrop_bridge.main"] - - # Start new process - QProcess.startDetached(executable, module_args) - - logger.info("New application process started successfully") - - # Close current application after a small delay to allow process to start - from PySide6.QtCore import QTimer - - QTimer.singleShot(500, lambda: sys.exit(0)) - - except Exception as e: - logger.error(f"Failed to restart application: {e}") - - from PySide6.QtWidgets import QMessageBox - - QMessageBox.warning( - self, - "Restart Failed", - f"Could not automatically restart the application:\n\n{str(e)}\n\n" - "Please restart manually.", - ) - def _navigate_home(self) -> None: """Navigate to the home (start) URL.""" home_url = self.config.webapp_url @@ -2163,4 +1983,3 @@ class UpdateDownloadWorker(QObject): except Exception as e: logger.warning(f"Error closing event loop: {e}") self.finished.emit() - self.finished.emit() diff --git a/src/webdrop_bridge/ui/mouse_event_emulator.js b/src/webdrop_bridge/ui/mouse_event_emulator.js deleted file mode 100644 index c38c6fa..0000000 --- a/src/webdrop_bridge/ui/mouse_event_emulator.js +++ /dev/null @@ -1,186 +0,0 @@ -// Mouse Event Emulator for Qt WebEngineView -// Qt WebEngineView may not forward all mouse events to JavaScript properly -// This script uses polling with document.elementFromPoint() to detect hover changes -// and manually dispatches mouseover/mouseenter/mouseleave events. -// ALSO: Injects a CSS stylesheet that simulates :hover effects using classes - -(function() { - try { - if (window.__mouse_emulator_injected) return; - window.__mouse_emulator_injected = true; - -console.log("[MouseEventEmulator] Initialized - polling for hover state changes"); - - // ======================================================== - // PART 1: Inject CSS stylesheet for hover simulation - // ======================================================== - - var style = document.createElement("style"); - style.type = "text/css"; - style.id = "__mouse_emulator_hover_styles"; - style.innerHTML = ` - /* Checkbox hover simulation */ - input[type="checkbox"].__mouse_hover { - cursor: pointer; - } - - /* Link hover simulation */ - a.__mouse_hover { - text-decoration: underline; - } - `; - - if (document.head) { - document.head.insertBefore(style, document.head.firstChild); - } else { - document.body.insertBefore(style, document.body.firstChild); - } - - // ======================================================== - // PART 2: Track hover state and apply hover class - // ======================================================== - - var lastElement = null; - var lastX = -1; - var lastY = -1; - - // High-frequency polling to detect element changes at mouse position - var pollIntervalId = setInterval(function() { - if (!window.__lastMousePos) { - window.__lastMousePos = { x: 0, y: 0 }; - } - - var x = window.__lastMousePos.x; - var y = window.__lastMousePos.y; - - lastX = x; - lastY = y; - - var element = document.elementFromPoint(x, y); - - if (!element || element === document || element.tagName === "HTML") { - if (lastElement && lastElement !== document) { - try { - lastElement.classList.remove("__mouse_hover"); - var leaveEvent = new MouseEvent("mouseleave", { - bubbles: true, - cancelable: true, - view: window, - }); - lastElement.dispatchEvent(leaveEvent); - } catch (err) { - console.warn("[MouseEventEmulator] Error in leave handler:", err); - } - lastElement = null; - } - return; - } - - // Element changed - if (element !== lastElement) { - // Remove hover class from previous element - if (lastElement && lastElement !== document && lastElement !== element) { - try { - lastElement.classList.remove("__mouse_hover"); - var leaveEvent = new MouseEvent("mouseleave", { - bubbles: true, - cancelable: true, - view: window, - clientX: x, - clientY: y, - }); - lastElement.dispatchEvent(leaveEvent); - } catch (err) { - console.warn("[MouseEventEmulator] Error dispatching mouseleave:", err); - } - } - - // Add hover class and dispatch events for new element - if (element) { - try { - element.classList.add("__mouse_hover"); - - var overEvent = new MouseEvent("mouseover", { - bubbles: true, - cancelable: true, - view: window, - clientX: x, - clientY: y, - }); - element.dispatchEvent(overEvent); - - var enterEvent = new MouseEvent("mouseenter", { - bubbles: false, - cancelable: true, - view: window, - clientX: x, - clientY: y, - }); - element.dispatchEvent(enterEvent); - - var moveEvent = new MouseEvent("mousemove", { - bubbles: true, - cancelable: true, - view: window, - clientX: x, - clientY: y, - }); - element.dispatchEvent(moveEvent); - } catch (err) { - console.warn("[MouseEventEmulator] Error dispatching mouse events:", err); - } - } - - lastElement = element; - } - }, 50); - - // Track mouse position from all available events - document.addEventListener( - "mousemove", - function(e) { - window.__lastMousePos = { x: e.clientX, y: e.clientY }; - }, - true - ); - - document.addEventListener( - "mousedown", - function(e) { - window.__lastMousePos = { x: e.clientX, y: e.clientY }; - }, - true - ); - - document.addEventListener( - "mouseup", - function(e) { - window.__lastMousePos = { x: e.clientX, y: e.clientY }; - }, - true - ); - - document.addEventListener( - "mouseover", - function(e) { - window.__lastMousePos = { x: e.clientX, y: e.clientY }; - }, - true - ); - - document.addEventListener( - "mouseenter", - function(e) { - window.__lastMousePos = { x: e.clientX, y: e.clientY }; - }, - true - ); - - console.log("[MouseEventEmulator] Ready - polling enabled for hover state detection"); - } catch (e) { - console.error("[MouseEventEmulator] FATAL ERROR:", e); - if (e.stack) { - console.error("[MouseEventEmulator] Stack:", e.stack); - } - } -})(); diff --git a/src/webdrop_bridge/ui/restricted_web_view.py b/src/webdrop_bridge/ui/restricted_web_view.py index 12dbeee..fa0668c 100644 --- a/src/webdrop_bridge/ui/restricted_web_view.py +++ b/src/webdrop_bridge/ui/restricted_web_view.py @@ -8,12 +8,7 @@ from typing import List, Optional, Union from PySide6.QtCore import QStandardPaths, QUrl from PySide6.QtGui import QDesktopServices -from PySide6.QtWebEngineCore import ( - QWebEngineNavigationRequest, - QWebEnginePage, - QWebEngineProfile, - QWebEngineSettings, -) +from PySide6.QtWebEngineCore import QWebEngineNavigationRequest, QWebEnginePage, QWebEngineProfile from PySide6.QtWebEngineWidgets import QWebEngineView logger = logging.getLogger(__name__) @@ -133,31 +128,6 @@ class RestrictedWebEngineView(QWebEngineView): # Profile is unique per domain to prevent cache corruption self.profile = self._create_persistent_profile() - # Configure WebEngine settings on the profile for proper JavaScript and mouse event support - settings = self.profile.settings() - - # Enable JavaScript (required for mouseover events and interactive features) - settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True) - - # Enable JavaScript access to clipboard (some web apps need this) - settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanAccessClipboard, True) - - # Enable JavaScript to open windows (for dialogs, popups) - settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True) - - # Enable local content access (needed for drag operations) - settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, True) - - # Allow local content to access remote resources (some web apps may need this) - settings.setAttribute( - QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, False - ) - - logger.debug( - "RestrictedWebEngineView WebEngine settings configured: " - "JavaScript=enabled, Clipboard=enabled, WindowOpen=enabled, LocalFileAccess=enabled" - ) - # Use custom page for better download handling with persistent profile custom_page = CustomWebEnginePage(self.profile, self) self.setPage(custom_page) @@ -166,23 +136,6 @@ class RestrictedWebEngineView(QWebEngineView): "RestrictedWebEngineView initialized with CustomWebEnginePage and persistent profile" ) - # CRITICAL: Also configure settings on the page itself after setPage() - # This ensures Page-level settings override Profile defaults for event handling - page_settings = self.page().settings() - page_settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True) - page_settings.setAttribute( - QWebEngineSettings.WebAttribute.JavascriptCanAccessClipboard, True - ) - page_settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True) - page_settings.setAttribute( - QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, True - ) - page_settings.setAttribute( - QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, False - ) - - logger.debug("Page-level WebEngine settings configured for mouse event handling") - # Connect to navigation request handler self.page().navigationRequested.connect(self._on_navigation_requested) diff --git a/tests/unit/test_drag_interceptor.py b/tests/unit/test_drag_interceptor.py index c19333f..74b262e 100644 --- a/tests/unit/test_drag_interceptor.py +++ b/tests/unit/test_drag_interceptor.py @@ -82,7 +82,6 @@ class TestDragInterceptorValidation: mock_drag_instance = MagicMock() # Simulate successful copy action from PySide6.QtCore import Qt - mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction mock_drag.return_value = mock_drag_instance @@ -137,7 +136,7 @@ class TestDragInterceptorAzureURL: url_mappings=[ URLMapping( url_prefix="https://devagravitystg.file.core.windows.net/devagravitysync/", - local_path=str(tmp_path), + local_path=str(tmp_path) ) ], check_file_exists=True, @@ -151,7 +150,6 @@ class TestDragInterceptorAzureURL: with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag: mock_drag_instance = MagicMock() from PySide6.QtCore import Qt - mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction mock_drag.return_value = mock_drag_instance @@ -198,7 +196,6 @@ class TestDragInterceptorSignals: interceptor.drag_started.connect(lambda src, path: signal_spy.append((src, path))) from PySide6.QtCore import Qt - with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag: mock_drag_instance = MagicMock() mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction @@ -238,234 +235,3 @@ class TestDragInterceptorSignals: # Verify result and signal emission assert result is False assert len(signal_spy) == 1 - - -class TestDragInterceptorMultipleDrags: - """Test multiple file drag support.""" - - def test_handle_drag_with_list_single_item(self, qtbot, tmp_path): - """Test handle_drag with list containing single file path.""" - test_file = tmp_path / "test.txt" - test_file.write_text("content") - - config = Config( - app_name="Test", - app_version="1.0.0", - log_level="INFO", - log_file=None, - allowed_roots=[tmp_path], - allowed_urls=[], - webapp_url="https://test.com/", - url_mappings=[], - check_file_exists=True, - ) - interceptor = DragInterceptor(config) - - from PySide6.QtCore import Qt - - with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag: - mock_drag_instance = MagicMock() - mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction - mock_drag.return_value = mock_drag_instance - - result = interceptor.handle_drag([str(test_file)]) - - assert result is True - - def test_handle_drag_with_multiple_files(self, qtbot, tmp_path): - """Test handle_drag with list of multiple file paths.""" - # Create multiple test files - test_file1 = tmp_path / "test1.txt" - test_file1.write_text("content1") - test_file2 = tmp_path / "test2.txt" - test_file2.write_text("content2") - test_file3 = tmp_path / "test3.txt" - test_file3.write_text("content3") - - config = Config( - app_name="Test", - app_version="1.0.0", - log_level="INFO", - log_file=None, - allowed_roots=[tmp_path], - allowed_urls=[], - webapp_url="https://test.com/", - url_mappings=[], - check_file_exists=True, - ) - interceptor = DragInterceptor(config) - - from PySide6.QtCore import Qt - - with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag: - mock_drag_instance = MagicMock() - mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction - mock_drag.return_value = mock_drag_instance - - result = interceptor.handle_drag( - [ - str(test_file1), - str(test_file2), - str(test_file3), - ] - ) - - assert result is True - - def test_handle_drag_with_multiple_azure_urls(self, qtbot, tmp_path): - """Test handle_drag with list of multiple Azure URLs.""" - from webdrop_bridge.config import URLMapping - - config = Config( - app_name="Test", - app_version="1.0.0", - log_level="INFO", - log_file=None, - allowed_roots=[tmp_path], - allowed_urls=[], - webapp_url="https://test.com/", - url_mappings=[ - URLMapping( - url_prefix="https://produktagravitystg.file.core.windows.net/produktagravitysync/", - local_path=str(tmp_path), - ) - ], - check_file_exists=False, # Don't check file existence for this test - ) - interceptor = DragInterceptor(config) - - # Multiple Azure URLs (as would be in a multi-drag) - azure_urls = [ - "https://produktagravitystg.file.core.windows.net/produktagravitysync/axtZdPVjs5iUaKU2muKMFN1WZ/igkjieyjcko.jpg", - "https://produktagravitystg.file.core.windows.net/produktagravitysync/aWd7mDjnsm2w0PHU9AryQBYz2/457101023fd46d673e2ce6642f78fb0d62736f0f06c7.jpg", - ] - - from PySide6.QtCore import Qt - - with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag: - mock_drag_instance = MagicMock() - mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction - mock_drag.return_value = mock_drag_instance - - result = interceptor.handle_drag(azure_urls) - - assert result is True - # Verify QDrag.exec was called (meaning drag was set up correctly) - mock_drag_instance.exec.assert_called_once() - - def test_handle_drag_mixed_urls_and_paths(self, qtbot, tmp_path): - """Test handle_drag with mixed Azure URLs and local paths.""" - from webdrop_bridge.config import URLMapping - - # Create test file - test_file = tmp_path / "local_file.txt" - test_file.write_text("local content") - - config = Config( - app_name="Test", - app_version="1.0.0", - log_level="INFO", - log_file=None, - allowed_roots=[tmp_path], - allowed_urls=[], - webapp_url="https://test.com/", - url_mappings=[ - URLMapping( - url_prefix="https://devagravitystg.file.core.windows.net/devagravitysync/", - local_path=str(tmp_path), - ) - ], - check_file_exists=False, # Don't check existence for remote files - ) - interceptor = DragInterceptor(config) - - mixed_items = [ - str(test_file), # local path - "https://devagravitystg.file.core.windows.net/devagravitysync/remote.jpg", # Azure URL - ] - - from PySide6.QtCore import Qt - - with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag: - mock_drag_instance = MagicMock() - mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction - mock_drag.return_value = mock_drag_instance - - result = interceptor.handle_drag(mixed_items) - - assert result is True - - def test_handle_drag_multiple_empty_list(self, qtbot, test_config): - """Test handle_drag with empty list fails.""" - interceptor = DragInterceptor(test_config) - - with qtbot.waitSignal(interceptor.drag_failed): - result = interceptor.handle_drag([]) - - assert result is False - - def test_handle_drag_multiple_one_invalid_fails(self, qtbot, tmp_path): - """Test handle_drag with multiple files fails if one is invalid.""" - test_file1 = tmp_path / "test1.txt" - test_file1.write_text("content1") - - config = Config( - app_name="Test", - app_version="1.0.0", - log_level="INFO", - log_file=None, - allowed_roots=[tmp_path], - allowed_urls=[], - webapp_url="https://test.com/", - url_mappings=[], - check_file_exists=True, - ) - interceptor = DragInterceptor(config) - - # One valid, one invalid - files = [ - str(test_file1), - "/etc/passwd", # Invalid - outside allowed roots - ] - - with qtbot.waitSignal(interceptor.drag_failed): - result = interceptor.handle_drag(files) - - assert result is False - - def test_handle_drag_multiple_signal_with_pipes(self, qtbot, tmp_path): - """Test drag_started signal contains pipe-separated paths for multiple files.""" - test_file1 = tmp_path / "test1.txt" - test_file1.write_text("content1") - test_file2 = tmp_path / "test2.txt" - test_file2.write_text("content2") - - config = Config( - app_name="Test", - app_version="1.0.0", - log_level="INFO", - log_file=None, - allowed_roots=[tmp_path], - allowed_urls=[], - webapp_url="https://test.com/", - url_mappings=[], - check_file_exists=True, - ) - interceptor = DragInterceptor(config) - - signal_spy = [] - interceptor.drag_started.connect(lambda src, path: signal_spy.append((src, path))) - - from PySide6.QtCore import Qt - - with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag: - mock_drag_instance = MagicMock() - mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction - mock_drag.return_value = mock_drag_instance - - result = interceptor.handle_drag([str(test_file1), str(test_file2)]) - - assert result is True - assert len(signal_spy) == 1 - # Multiple paths should be separated by " | " - assert " | " in signal_spy[0][1] diff --git a/tests/unit/test_main_window.py b/tests/unit/test_main_window.py index 01eaa5a..65f35ab 100644 --- a/tests/unit/test_main_window.py +++ b/tests/unit/test_main_window.py @@ -82,6 +82,136 @@ class TestMainWindowInitialization: assert window.drag_interceptor is not None +class TestMainWindowNavigation: + """Test navigation toolbar and functionality.""" + + def test_navigation_toolbar_created(self, qtbot, sample_config): + """Test navigation toolbar is created.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + toolbars = window.findChildren(QToolBar) + assert len(toolbars) > 0 + + def test_navigation_toolbar_not_movable(self, qtbot, sample_config): + """Test navigation toolbar is not movable (locked for Kiosk-mode).""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + toolbar = window.findChild(QToolBar) + assert toolbar is not None + assert not toolbar.isMovable() + + def test_navigate_home(self, qtbot, sample_config): + """Test home button navigation.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + with patch.object(window.web_view, "load") as mock_load: + window._navigate_home() + mock_load.assert_called_once() + + def test_navigate_home_with_http_url(self, qtbot, tmp_path): + """Test home navigation with HTTP URL.""" + config = Config( + app_name="Test", + app_version="1.0.0", + log_level="INFO", + log_file=None, + allowed_roots=[tmp_path], + allowed_urls=[], + webapp_url="http://localhost:8000", + window_width=800, + window_height=600, + enable_logging=False, + ) + + window = MainWindow(config) + qtbot.addWidget(window) + + with patch.object(window.web_view, "load") as mock_load: + window._navigate_home() + + # Verify load was called with HTTP URL + call_args = mock_load.call_args + url = call_args[0][0] + assert url.scheme() == "http" + + def test_navigate_home_with_file_url(self, qtbot, sample_config): + """Test home navigation with file:// URL.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + with patch.object(window.web_view, "load") as mock_load: + window._navigate_home() + + call_args = mock_load.call_args + url = call_args[0][0] + assert url.scheme() == "file" + + +class TestMainWindowWebAppLoading: + """Test web application loading.""" + + def test_load_local_webapp_file(self, qtbot, sample_config): + """Test loading local webapp file.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + # Window should load without errors + assert window.web_view is not None + + def test_load_remote_webapp_url(self, qtbot, tmp_path): + """Test loading remote webapp URL.""" + config = Config( + app_name="Test", + app_version="1.0.0", + log_level="INFO", + log_file=None, + allowed_roots=[tmp_path], + allowed_urls=["localhost"], + webapp_url="http://localhost:3000", + window_width=800, + window_height=600, + enable_logging=False, + ) + + window = MainWindow(config) + qtbot.addWidget(window) + + assert window.web_view is not None + + def test_load_nonexistent_file_shows_welcome_page(self, qtbot, tmp_path): + """Test loading nonexistent file shows welcome page HTML.""" + config = Config( + app_name="Test", + app_version="1.0.0", + log_level="INFO", + log_file=None, + allowed_roots=[tmp_path], + allowed_urls=[], + webapp_url="/nonexistent/file.html", + window_width=800, + window_height=600, + enable_logging=False, + ) + + with patch.object(config, "webapp_url", "/nonexistent/file.html"): + window = MainWindow(config) + qtbot.addWidget(window) + + with patch.object( + window.web_view, "setHtml" + ) as mock_set_html: + window._load_webapp() + mock_set_html.assert_called_once() + + # Verify welcome page is shown instead of error + call_args = mock_set_html.call_args[0][0] + assert "WebDrop Bridge" in call_args + assert "Application Ready" in call_args + + class TestMainWindowDragIntegration: """Test drag-and-drop integration.""" @@ -101,10 +231,12 @@ class TestMainWindowDragIntegration: assert window.drag_interceptor.drag_started is not None assert window.drag_interceptor.drag_failed is not None - def test_handle_drag_delegates_to_interceptor(self, qtbot, sample_config, tmp_path): + def test_handle_drag_delegates_to_interceptor( + self, qtbot, sample_config, tmp_path + ): """Test drag handling delegates to interceptor.""" from PySide6.QtCore import QCoreApplication - + window = MainWindow(sample_config) qtbot.addWidget(window) @@ -112,11 +244,13 @@ class TestMainWindowDragIntegration: test_file = sample_config.allowed_roots[0] / "test.txt" test_file.write_text("test") - with patch.object(window.drag_interceptor, "handle_drag") as mock_drag: + with patch.object( + window.drag_interceptor, "handle_drag" + ) as mock_drag: mock_drag.return_value = True # Call through bridge window._drag_bridge.start_file_drag(str(test_file)) - + # Process deferred QTimer.singleShot(0, ...) call QCoreApplication.processEvents() @@ -142,10 +276,278 @@ class TestMainWindowDragIntegration: class TestMainWindowURLWhitelist: """Test URL whitelisting integration.""" - def test_restricted_web_view_receives_allowed_urls(self, qtbot, sample_config): + def test_restricted_web_view_receives_allowed_urls( + self, qtbot, sample_config + ): """Test RestrictedWebEngineView receives allowed URLs from config.""" window = MainWindow(sample_config) qtbot.addWidget(window) # web_view should have allowed_urls configured assert window.web_view.allowed_urls == sample_config.allowed_urls + + def test_empty_allowed_urls_list(self, qtbot, tmp_path): + """Test with empty allowed URLs (no restriction).""" + config = Config( + app_name="Test", + app_version="1.0.0", + log_level="INFO", + log_file=None, + allowed_roots=[tmp_path], + allowed_urls=[], # Empty = no restriction + webapp_url="http://localhost", + window_width=800, + window_height=600, + enable_logging=False, + ) + + window = MainWindow(config) + qtbot.addWidget(window) + + assert window.web_view.allowed_urls == [] + + +class TestMainWindowSignals: + """Test signal connections.""" + + def test_drag_started_signal_connection(self, qtbot, sample_config): + """Test drag_started signal is connected to handler.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + with patch.object(window, "_on_drag_started") as mock_handler: + 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): + """Test drag_failed signal is connected to handler.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + with patch.object(window, "_on_drag_failed") as mock_handler: + window.drag_interceptor.drag_failed.emit("https://example.com/file", "File not found") + mock_handler.assert_called_once() + + +class TestMainWindowMenuBar: + """Test toolbar help actions integration.""" + + def test_navigation_toolbar_created(self, qtbot, sample_config): + """Test navigation toolbar is created with help buttons.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + # Check that toolbar exists + assert len(window.findChildren(QToolBar)) > 0 + toolbar = window.findChildren(QToolBar)[0] + assert toolbar is not None + + def test_window_has_check_for_updates_signal(self, qtbot, sample_config): + """Test window has check_for_updates signal.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + # Test that signal exists + assert hasattr(window, "check_for_updates") + + # Test that signal is callable (can be emitted) + assert callable(window.check_for_updates.emit) + + def test_on_check_for_updates_method_exists(self, qtbot, sample_config): + """Test _on_manual_check_for_updates method exists.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + # Test that the method exists + assert hasattr(window, "_on_manual_check_for_updates") + assert callable(window._on_manual_check_for_updates) + + def test_show_about_dialog_method_exists(self, qtbot, sample_config): + """Test _show_about_dialog method exists.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + # Test that the method exists + assert hasattr(window, "_show_about_dialog") + assert callable(window._show_about_dialog) + + +class TestMainWindowStatusBar: + """Test status bar and update status.""" + + def test_status_bar_created(self, qtbot, sample_config): + """Test status bar is created.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + assert window.statusBar() is not None + assert hasattr(window, "status_bar") + + def test_update_status_label_created(self, qtbot, sample_config): + """Test update status label exists.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + assert hasattr(window, "update_status_label") + assert window.update_status_label is not None + + def test_set_update_status_text_only(self, qtbot, sample_config): + """Test setting update status with text only.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + window.set_update_status("Checking for updates") + assert "Checking for updates" in window.update_status_label.text() + + def test_set_update_status_with_emoji(self, qtbot, sample_config): + """Test setting update status with emoji.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + window.set_update_status("Checking", emoji="🔄") + assert "🔄" in window.update_status_label.text() + assert "Checking" in window.update_status_label.text() + + def test_set_update_status_checking(self, qtbot, sample_config): + """Test checking for updates status.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + window.set_update_status("Checking for updates", emoji="🔄") + assert "🔄" in window.update_status_label.text() + + def test_set_update_status_available(self, qtbot, sample_config): + """Test update available status.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + window.set_update_status("Update available v0.0.2", emoji="✅") + assert "✅" in window.update_status_label.text() + + def test_set_update_status_downloading(self, qtbot, sample_config): + """Test downloading status.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + window.set_update_status("Downloading update", emoji="⬇️") + assert "⬇️" in window.update_status_label.text() + + def test_set_update_status_error(self, qtbot, sample_config): + """Test error status.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + window.set_update_status("Update check failed", emoji="⚠️") + assert "⚠️" in window.update_status_label.text() + + +class TestMainWindowStylesheet: + """Test stylesheet application.""" + + def test_stylesheet_loading_gracefully_handles_missing_file( + self, qtbot, sample_config + ): + """Test missing stylesheet doesn't crash application.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + # Should not raise even if stylesheet missing + window._apply_stylesheet() + + def test_stylesheet_loading_with_nonexistent_file( + self, qtbot, sample_config + ): + """Test stylesheet loading with nonexistent file path.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + with patch("builtins.open", side_effect=OSError("File not found")): + # Should handle gracefully + window._apply_stylesheet() + + +class TestMainWindowCloseEvent: + """Test window close handling.""" + + def test_close_event_accepted(self, qtbot, sample_config): + """Test close event is accepted.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + from PySide6.QtGui import QCloseEvent + + event = QCloseEvent() + window.closeEvent(event) + + assert event.isAccepted() + + +class TestMainWindowIntegration: + """Integration tests for MainWindow with all components.""" + + def test_full_initialization_flow(self, qtbot, sample_config): + """Test complete initialization flow.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + # Verify all components initialized + assert window.web_view is not None + assert window.drag_interceptor is not None + assert window.config == sample_config + + # Verify toolbar exists + toolbars = window.findChildren(QToolBar) + assert len(toolbars) > 0 + + def test_window_with_multiple_allowed_roots(self, qtbot, tmp_path): + """Test MainWindow with multiple allowed root directories.""" + root1 = tmp_path / "root1" + root2 = tmp_path / "root2" + root1.mkdir() + root2.mkdir() + + webapp_file = tmp_path / "index.html" + webapp_file.write_text("") + + config = Config( + app_name="Test", + app_version="1.0.0", + log_level="INFO", + log_file=None, + allowed_roots=[root1, root2], + allowed_urls=[], + webapp_url=str(webapp_file), + window_width=800, + window_height=600, + enable_logging=False, + ) + + window = MainWindow(config) + qtbot.addWidget(window) + + # Verify validator has both roots + assert window.drag_interceptor._validator is not None + assert len( + window.drag_interceptor._validator.allowed_roots + ) == 2 + + def test_window_with_url_whitelist(self, qtbot, tmp_path): + """Test MainWindow respects URL whitelist.""" + config = Config( + app_name="Test", + app_version="1.0.0", + log_level="INFO", + log_file=None, + allowed_roots=[tmp_path], + allowed_urls=["*.example.com", "localhost"], + webapp_url="http://localhost", + window_width=800, + window_height=600, + enable_logging=False, + ) + + window = MainWindow(config) + qtbot.addWidget(window) + + # Verify whitelist is set + assert window.web_view.allowed_urls == ["*.example.com", "localhost"]