Compare commits

..

No commits in common. "810baf65d9ca5b1d0fbda91b01bea53d633da681" and "705969cdbaf3accc11c5e175159e4a92a4bc9b3f" have entirely different histories.

8 changed files with 525 additions and 1048 deletions

View file

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

View file

@ -2,7 +2,7 @@
import logging import logging
from pathlib import Path 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.QtCore import QMimeData, Qt, QUrl, Signal
from PySide6.QtGui import QDrag from PySide6.QtGui import QDrag
@ -21,18 +21,14 @@ class DragInterceptor(QWidget):
Intercepts drag events from web content, converts Azure Blob Storage URLs Intercepts drag events from web content, converts Azure Blob Storage URLs
to local paths, validates them, and initiates native Qt drag operations. to local paths, validates them, and initiates native Qt drag operations.
Supports both single and multiple file drag operations.
Signals: Signals:
drag_started: Emitted when a drag operation begins successfully 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 drag_failed: Emitted when drag initiation fails
(source_urls_or_paths: str, error_message: str)
""" """
# Signals with string parameters # Signals with string parameters
drag_started = Signal(str, str) # (source_urls_or_paths, local_paths) drag_started = Signal(str, str) # (url_or_path, local_path)
drag_failed = Signal(str, str) # (source_urls_or_paths, error_message) drag_failed = Signal(str, str) # (url_or_path, error_message)
def __init__(self, config: Config, parent: Optional[QWidget] = None): def __init__(self, config: Config, parent: Optional[QWidget] = None):
"""Initialize the drag interceptor. """Initialize the drag interceptor.
@ -44,123 +40,83 @@ class DragInterceptor(QWidget):
super().__init__(parent) super().__init__(parent)
self.config = config self.config = config
self._validator = PathValidator( 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) self._url_converter = URLConverter(config)
def handle_drag(self, text_or_list: Union[str, List[str]]) -> bool: def handle_drag(self, text: str) -> bool:
"""Handle drag event from web view (single or multiple files). """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. validates, and initiates native drag operation.
Supports:
- Single string (backward compatible)
- List of strings (multiple drag support)
Args: 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: Returns:
True if native drag was initiated, False otherwise True if native drag was initiated, False otherwise
""" """
# Normalize input to list if not text or not text.strip():
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):
error_msg = "Empty drag text" error_msg = "Empty drag text"
logger.warning(error_msg) logger.warning(error_msg)
self.drag_failed.emit("", error_msg) self.drag_failed.emit("", error_msg)
return False return False
# Clean up text items text = text.strip()
text_list = [str(t).strip() for t in text_list if str(t).strip()] logger.debug(f"Handling drag for text: {text}")
logger.debug(f"Handling drag for {len(text_list)} item(s)")
# Convert each text to local path # Check if it's an Azure URL and convert to local path
local_paths = [] if self._url_converter.is_azure_url(text):
source_texts = [] local_path = self._url_converter.convert_url_to_path(text)
if local_path is None:
for text in text_list: error_msg = "No mapping found for URL"
# Check if it's an Azure URL and convert to local path logger.warning(f"{error_msg}: {text}")
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}")
self.drag_failed.emit(text, error_msg) self.drag_failed.emit(text, error_msg)
return False 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( logger.info(f"Initiating drag for: {local_path}")
f"Initiating drag for {len(local_paths)} file(s): {[str(p) for p in local_paths]}"
)
# Create native file drag with all paths # Create native file drag
success = self._create_native_drag(local_paths) success = self._create_native_drag(local_path)
if success: if success:
source_str = " | ".join(source_texts) if len(source_texts) > 1 else source_texts[0] self.drag_started.emit(source_text, str(local_path))
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)
else: else:
error_msg = "Failed to create native drag operation" error_msg = "Failed to create native drag operation"
logger.error(error_msg) logger.error(error_msg)
source_str = " | ".join(source_texts) if len(source_texts) > 1 else source_texts[0] self.drag_failed.emit(source_text, error_msg)
self.drag_failed.emit(source_str, error_msg)
return success 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. """Create a native file system drag operation.
Args: Args:
file_paths: Single local file path or list of local file paths file_path: Local file path to drag
Returns: Returns:
True if drag was created successfully True if drag was created successfully
""" """
try: try:
# Normalize to list # Create MIME data with file URL
if isinstance(file_paths, Path):
paths_list = [file_paths]
else:
paths_list = list(file_paths)
# Create MIME data with file URLs
mime_data = QMimeData() mime_data = QMimeData()
file_urls = [QUrl.fromLocalFile(str(p)) for p in paths_list] file_url = QUrl.fromLocalFile(str(file_path))
mime_data.setUrls(file_urls) mime_data.setUrls([file_url])
logger.debug(f"Creating drag with {len(file_urls)} file(s)")
# Create and execute drag # Create and execute drag
drag = QDrag(self) drag = QDrag(self)

View file

@ -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;'); 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 angularDragHandlers = [];
var originalAddEventListener = EventTarget.prototype.addEventListener; var originalAddEventListener = EventTarget.prototype.addEventListener;
var listenerPatchActive = true; 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; window.capturedAuthToken = null;
if (window.webdropConfig && window.webdropConfig.enableCheckout) { var originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
console.log('[Intercept] Auth token capture enabled (checkout feature active)'); XMLHttpRequest.prototype.setRequestHeader = function(header, value) {
var originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; if (header === 'Authorization' && value.startsWith('Bearer ')) {
XMLHttpRequest.prototype.setRequestHeader = function(header, value) { window.capturedAuthToken = value;
if (header === 'Authorization' && value.startsWith('Bearer ')) { console.log('[Intercept] Captured auth token');
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
} }
// All other events (mouseover, mouseenter, mousedown, etc.): use original return originalXHRSetRequestHeader.apply(this, arguments);
// This is critical to ensure mouseover/hover events work properly
return originalAddEventListener.call(this, type, listener, options);
}; };
// ============================================================================
// 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 // PART 2: Intercept DataTransfer.setData to capture URL
// ============================================================================ // ============================================================================
@ -59,14 +54,8 @@
DataTransfer.prototype.setData = function(format, data) { DataTransfer.prototype.setData = function(format, data) {
if (format === 'text/plain' || format === 'text/uri-list') { if (format === 'text/plain' || format === 'text/uri-list') {
// text/uri-list contains newline-separated URLs currentDragUrl = data;
// text/plain may be single URL or multiple newline-separated URLs console.log('%c[Intercept] Captured URL:', 'color: #4CAF50; font-weight: bold;', data.substring(0, 80));
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));
} }
return originalSetData.call(this, format, data); return originalSetData.call(this, format, data);
}; };
@ -86,20 +75,16 @@
return; return;
} }
// Only install once, even if called multiple times // Stop intercepting addEventListener
if (dragHandlerInstalled) { listenerPatchActive = false;
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
// Register OUR handler in capture phase // Register OUR handler in capture phase
originalAddEventListener.call(document, 'dragstart', function(e) { 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 // Call Angular's handlers first to let them set the data
var handled = 0; 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 // NOW check if we should intercept
// Intercept any drag with URLs that match our configured mappings // Intercept when: Alt key held (normal mode) OR native DnD disabled via URL param
if (currentDragUrls.length > 0) { if ((e.altKey || disabledNativeDnD) && currentDragUrl) {
var shouldIntercept = false; var shouldIntercept = false;
// Check each URL against configured URL mappings // Check against configured URL mappings
// Intercept if ANY URL matches
if (window.webdropConfig && window.webdropConfig.urlMappings) { if (window.webdropConfig && window.webdropConfig.urlMappings) {
for (var k = 0; k < currentDragUrls.length; k++) { for (var j = 0; j < window.webdropConfig.urlMappings.length; j++) {
var dragUrl = currentDragUrls[k]; var mapping = window.webdropConfig.urlMappings[j];
for (var j = 0; j < window.webdropConfig.urlMappings.length; j++) { if (currentDragUrl.toLowerCase().startsWith(mapping.url_prefix.toLowerCase())) {
var mapping = window.webdropConfig.urlMappings[j]; shouldIntercept = true;
if (dragUrl.toLowerCase().startsWith(mapping.url_prefix.toLowerCase())) { console.log('[Intercept] URL matches mapping for:', mapping.local_path);
shouldIntercept = true; break;
console.log('[Intercept] URL #' + (k+1) + ' matches mapping for:', mapping.local_path);
break;
}
} }
if (shouldIntercept) break;
} }
} else { } else {
// Fallback: Check for legacy Z: drive pattern if no config available // Fallback: Check for legacy Z: drive pattern if no config available
for (var k = 0; k < currentDragUrls.length; k++) { shouldIntercept = /^z:/i.test(currentDragUrl);
if (/^z:/i.test(currentDragUrls[k])) { if (shouldIntercept) {
shouldIntercept = true; console.warn('[Intercept] Using fallback Z: drive pattern (no URL mappings configured)');
console.warn('[Intercept] Using fallback Z: drive pattern (no URL mappings configured)');
break;
}
} }
} }
if (shouldIntercept) { 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;'); 'background: #F44336; color: white; font-weight: bold; padding: 4px 8px;');
e.preventDefault(); e.preventDefault();
@ -158,15 +135,14 @@
ensureChannel(function() { ensureChannel(function() {
if (window.bridge && typeof window.bridge.start_file_drag === '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;'); console.log('%c[Intercept] → Qt: start_file_drag', 'color: #9C27B0; font-weight: bold;');
// Pass as JSON string to avoid Qt WebChannel array conversion issues window.bridge.start_file_drag(currentDragUrl);
window.bridge.start_file_drag(JSON.stringify(currentDragUrls));
} else { } else {
console.error('[Intercept] bridge.start_file_drag not available!'); console.error('[Intercept] bridge.start_file_drag not available!');
} }
}); });
currentDragUrls = []; currentDragUrl = null;
return false; return false;
} }
} }
@ -178,23 +154,8 @@
} }
// Wait for Angular to register its listeners, then install our handler // 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 // Start checking after 2 seconds (give Angular time to load on first page load)
var installRetries = 0; setTimeout(installDragHandler, 2000);
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);
// ============================================================================ // ============================================================================
// PART 3: QWebChannel connection // 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;'); 'background: #4CAF50; color: white; font-weight: bold; padding: 4px 8px;');
} catch(e) { } catch(e) {
console.error('[WebDrop Intercept] FATAL ERROR in bridge script:', e); console.error('[WebDrop Intercept] FATAL ERROR in bridge script:', e);

View file

@ -7,7 +7,7 @@ import re
import sys import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional, Union from typing import Optional
from PySide6.QtCore import ( from PySide6.QtCore import (
QEvent, QEvent,
@ -312,34 +312,21 @@ class _DragBridge(QObject):
self.window = window self.window = window
@Slot(str) @Slot(str)
def start_file_drag(self, paths_text: str) -> None: def start_file_drag(self, path_text: str) -> None:
"""Start a native file drag for the given path(s) or Azure URL(s). """Start a native file drag for the given path or Azure URL.
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)
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. Defers execution to avoid Qt drag manager state issues.
Args: 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) # Defer to avoid drag manager state issues
paths_list: Union[str, list] = paths_text # handle_drag() handles URL conversion and validation internally
if paths_text.startswith("["): QTimer.singleShot(0, lambda: self.window.drag_interceptor.handle_drag(path_text))
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))
@Slot(str) @Slot(str)
def debug_log(self, message: str) -> None: def debug_log(self, message: str) -> None:
@ -534,8 +521,8 @@ class MainWindow(QMainWindow):
def _install_bridge_script(self) -> None: def _install_bridge_script(self) -> None:
"""Install the drag bridge JavaScript via QWebEngineScript. """Install the drag bridge JavaScript via QWebEngineScript.
Uses Deferred injection point to ensure script runs after the DOM is ready, Uses DocumentCreation injection point to ensure script runs as early as possible,
allowing proper event listener registration without race conditions. before any page scripts that might interfere with drag events.
Embeds qwebchannel.js inline to avoid CSP issues with qrc:// URLs. Embeds qwebchannel.js inline to avoid CSP issues with qrc:// URLs.
Injects configuration that bridge script uses for dynamic URL pattern matching. Injects configuration that bridge script uses for dynamic URL pattern matching.
@ -544,9 +531,7 @@ class MainWindow(QMainWindow):
script = QWebEngineScript() script = QWebEngineScript()
script.setName("webdrop-bridge") script.setName("webdrop-bridge")
# Use Deferred instead of DocumentCreation to allow DOM to be ready first script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
# This prevents race conditions with JavaScript event listeners
script.setInjectionPoint(QWebEngineScript.InjectionPoint.Deferred)
script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld) script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
script.setRunsOnSubFrames(False) script.setRunsOnSubFrames(False)
@ -635,47 +620,18 @@ class MainWindow(QMainWindow):
else: else:
logger.debug("Download interceptor not found (optional)") logger.debug("Download interceptor not found (optional)")
# Load mouse event emulator for hover effect support # Combine: qwebchannel.js + config + bridge script + download interceptor
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
combined_code = qwebchannel_code + "\n\n" + config_code + "\n\n" + bridge_code combined_code = qwebchannel_code + "\n\n" + config_code + "\n\n" + bridge_code
if download_interceptor_code: if download_interceptor_code:
combined_code += "\n\n" + 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( logger.debug(
f"Combined script size: {len(combined_code)} chars " f"Combined script size: {len(combined_code)} chars "
f"(qwebchannel: {len(qwebchannel_code)}, " f"(qwebchannel: {len(qwebchannel_code)}, "
f"config: {len(config_code)}, " f"config: {len(config_code)}, "
f"bridge: {len(bridge_code)}, " f"bridge: {len(bridge_code)}, "
f"interceptor: {len(download_interceptor_code)}, " f"interceptor: {len(download_interceptor_code)})"
f"mouse_emulator: {len(mouse_emulator_code)})"
) )
logger.debug(f"URL mappings in config: {len(self.config.url_mappings)}") logger.debug(f"URL mappings in config: {len(self.config.url_mappings)}")
for i, mapping in enumerate(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']}") logger.debug(f" [{i+1}] {m['url_prefix']} -> {m['local_path']}")
# Generate config object as JSON # Generate config object as JSON
config_obj = {"urlMappings": mappings, "enableCheckout": self.config.enable_checkout} config_obj = {"urlMappings": mappings}
config_json = json.dumps(config_obj) config_json = json.dumps(config_obj)
logger.debug(f"Config JSON size: {len(config_json)} bytes") 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 # Generate JavaScript code - Safe injection with error handling
config_js = f""" config_js = f"""
@ -725,7 +680,6 @@ class MainWindow(QMainWindow):
console.log('[WebDrop Config] Starting configuration injection...'); console.log('[WebDrop Config] Starting configuration injection...');
window.webdropConfig = {config_json}; window.webdropConfig = {config_json};
console.log('[WebDrop Config] Configuration object created'); console.log('[WebDrop Config] Configuration object created');
console.log('[WebDrop Config] Checkout enabled: ' + window.webdropConfig.enableCheckout);
if (window.webdropConfig && window.webdropConfig.urlMappings) {{ if (window.webdropConfig && window.webdropConfig.urlMappings) {{
console.log('[WebDrop Config] SUCCESS: ' + window.webdropConfig.urlMappings.length + ' URL mappings loaded'); console.log('[WebDrop Config] SUCCESS: ' + window.webdropConfig.urlMappings.length + ' URL mappings loaded');
@ -1430,146 +1384,12 @@ class MainWindow(QMainWindow):
QMessageBox.about(self, f"About {self.config.app_name}", about_text) QMessageBox.about(self, f"About {self.config.app_name}", about_text)
def _show_settings_dialog(self) -> None: def _show_settings_dialog(self) -> None:
"""Show Settings dialog for configuration management. """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.
"""
from webdrop_bridge.ui.settings_dialog import SettingsDialog 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 = SettingsDialog(self.config, self)
dialog.exec() 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: def _navigate_home(self) -> None:
"""Navigate to the home (start) URL.""" """Navigate to the home (start) URL."""
home_url = self.config.webapp_url home_url = self.config.webapp_url
@ -2163,4 +1983,3 @@ class UpdateDownloadWorker(QObject):
except Exception as e: except Exception as e:
logger.warning(f"Error closing event loop: {e}") logger.warning(f"Error closing event loop: {e}")
self.finished.emit() self.finished.emit()
self.finished.emit()

View file

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

View file

@ -8,12 +8,7 @@ from typing import List, Optional, Union
from PySide6.QtCore import QStandardPaths, QUrl from PySide6.QtCore import QStandardPaths, QUrl
from PySide6.QtGui import QDesktopServices from PySide6.QtGui import QDesktopServices
from PySide6.QtWebEngineCore import ( from PySide6.QtWebEngineCore import QWebEngineNavigationRequest, QWebEnginePage, QWebEngineProfile
QWebEngineNavigationRequest,
QWebEnginePage,
QWebEngineProfile,
QWebEngineSettings,
)
from PySide6.QtWebEngineWidgets import QWebEngineView from PySide6.QtWebEngineWidgets import QWebEngineView
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -133,31 +128,6 @@ class RestrictedWebEngineView(QWebEngineView):
# Profile is unique per domain to prevent cache corruption # Profile is unique per domain to prevent cache corruption
self.profile = self._create_persistent_profile() 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 # Use custom page for better download handling with persistent profile
custom_page = CustomWebEnginePage(self.profile, self) custom_page = CustomWebEnginePage(self.profile, self)
self.setPage(custom_page) self.setPage(custom_page)
@ -166,23 +136,6 @@ class RestrictedWebEngineView(QWebEngineView):
"RestrictedWebEngineView initialized with CustomWebEnginePage and persistent profile" "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 # Connect to navigation request handler
self.page().navigationRequested.connect(self._on_navigation_requested) self.page().navigationRequested.connect(self._on_navigation_requested)

View file

@ -82,7 +82,6 @@ class TestDragInterceptorValidation:
mock_drag_instance = MagicMock() mock_drag_instance = MagicMock()
# Simulate successful copy action # Simulate successful copy action
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
mock_drag.return_value = mock_drag_instance mock_drag.return_value = mock_drag_instance
@ -137,7 +136,7 @@ class TestDragInterceptorAzureURL:
url_mappings=[ url_mappings=[
URLMapping( URLMapping(
url_prefix="https://devagravitystg.file.core.windows.net/devagravitysync/", url_prefix="https://devagravitystg.file.core.windows.net/devagravitysync/",
local_path=str(tmp_path), local_path=str(tmp_path)
) )
], ],
check_file_exists=True, check_file_exists=True,
@ -151,7 +150,6 @@ class TestDragInterceptorAzureURL:
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag: with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
mock_drag_instance = MagicMock() mock_drag_instance = MagicMock()
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
mock_drag.return_value = mock_drag_instance 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))) interceptor.drag_started.connect(lambda src, path: signal_spy.append((src, path)))
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag: with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
mock_drag_instance = MagicMock() mock_drag_instance = MagicMock()
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
@ -238,234 +235,3 @@ class TestDragInterceptorSignals:
# Verify result and signal emission # Verify result and signal emission
assert result is False assert result is False
assert len(signal_spy) == 1 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]

View file

@ -82,6 +82,136 @@ class TestMainWindowInitialization:
assert window.drag_interceptor is not None 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: class TestMainWindowDragIntegration:
"""Test drag-and-drop integration.""" """Test drag-and-drop integration."""
@ -101,7 +231,9 @@ class TestMainWindowDragIntegration:
assert window.drag_interceptor.drag_started is not None assert window.drag_interceptor.drag_started is not None
assert window.drag_interceptor.drag_failed 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.""" """Test drag handling delegates to interceptor."""
from PySide6.QtCore import QCoreApplication from PySide6.QtCore import QCoreApplication
@ -112,7 +244,9 @@ class TestMainWindowDragIntegration:
test_file = sample_config.allowed_roots[0] / "test.txt" test_file = sample_config.allowed_roots[0] / "test.txt"
test_file.write_text("test") 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 mock_drag.return_value = True
# Call through bridge # Call through bridge
window._drag_bridge.start_file_drag(str(test_file)) window._drag_bridge.start_file_drag(str(test_file))
@ -142,10 +276,278 @@ class TestMainWindowDragIntegration:
class TestMainWindowURLWhitelist: class TestMainWindowURLWhitelist:
"""Test URL whitelisting integration.""" """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.""" """Test RestrictedWebEngineView receives allowed URLs from config."""
window = MainWindow(sample_config) window = MainWindow(sample_config)
qtbot.addWidget(window) qtbot.addWidget(window)
# web_view should have allowed_urls configured # web_view should have allowed_urls configured
assert window.web_view.allowed_urls == sample_config.allowed_urls 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("<html></html>")
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"]