Compare commits
7 commits
705969cdba
...
810baf65d9
| Author | SHA1 | Date | |
|---|---|---|---|
| 810baf65d9 | |||
| c612072dc8 | |||
| 1e848e84b2 | |||
| 3f7623f11c | |||
| 695182c44f | |||
| 308f77f84e | |||
| ced50dd1f6 |
8 changed files with 1049 additions and 526 deletions
194
docs/HOVER_EFFECTS_ANALYSIS.md
Normal file
194
docs/HOVER_EFFECTS_ANALYSIS.md
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
# 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)
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from PySide6.QtCore import QMimeData, Qt, QUrl, Signal
|
||||
from PySide6.QtGui import QDrag
|
||||
|
|
@ -21,14 +21,18 @@ 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) # (url_or_path, local_path)
|
||||
drag_failed = Signal(str, str) # (url_or_path, error_message)
|
||||
drag_started = Signal(str, str) # (source_urls_or_paths, local_paths)
|
||||
drag_failed = Signal(str, str) # (source_urls_or_paths, error_message)
|
||||
|
||||
def __init__(self, config: Config, parent: Optional[QWidget] = None):
|
||||
"""Initialize the drag interceptor.
|
||||
|
|
@ -40,83 +44,123 @@ 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: str) -> bool:
|
||||
"""Handle drag event from web view.
|
||||
def handle_drag(self, text_or_list: Union[str, List[str]]) -> bool:
|
||||
"""Handle drag event from web view (single or multiple files).
|
||||
|
||||
Determines if the text is an Azure URL or file path, converts if needed,
|
||||
Determines if the text/list contains Azure URLs or file paths, converts if needed,
|
||||
validates, and initiates native drag operation.
|
||||
|
||||
Supports:
|
||||
- Single string (backward compatible)
|
||||
- List of strings (multiple drag support)
|
||||
|
||||
Args:
|
||||
text: Azure Blob Storage URL or file path from web drag
|
||||
text_or_list: Azure URL/file path (str) or list of URLs/paths (List[str])
|
||||
|
||||
Returns:
|
||||
True if native drag was initiated, False otherwise
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
# 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):
|
||||
error_msg = "Empty drag text"
|
||||
logger.warning(error_msg)
|
||||
self.drag_failed.emit("", error_msg)
|
||||
return False
|
||||
|
||||
text = text.strip()
|
||||
logger.debug(f"Handling drag for text: {text}")
|
||||
# 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)")
|
||||
|
||||
# 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}")
|
||||
# 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}")
|
||||
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
|
||||
|
||||
# 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
|
||||
local_paths.append(local_path)
|
||||
|
||||
logger.info(f"Initiating drag for: {local_path}")
|
||||
logger.info(
|
||||
f"Initiating drag for {len(local_paths)} file(s): {[str(p) for p in local_paths]}"
|
||||
)
|
||||
|
||||
# Create native file drag
|
||||
success = self._create_native_drag(local_path)
|
||||
# Create native file drag with all paths
|
||||
success = self._create_native_drag(local_paths)
|
||||
|
||||
if success:
|
||||
self.drag_started.emit(source_text, str(local_path))
|
||||
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)
|
||||
else:
|
||||
error_msg = "Failed to create native drag operation"
|
||||
logger.error(error_msg)
|
||||
self.drag_failed.emit(source_text, error_msg)
|
||||
source_str = " | ".join(source_texts) if len(source_texts) > 1 else source_texts[0]
|
||||
self.drag_failed.emit(source_str, error_msg)
|
||||
|
||||
return success
|
||||
|
||||
def _create_native_drag(self, file_path: Path) -> bool:
|
||||
def _create_native_drag(self, file_paths: Union[Path, List[Path]]) -> bool:
|
||||
"""Create a native file system drag operation.
|
||||
|
||||
Args:
|
||||
file_path: Local file path to drag
|
||||
file_paths: Single local file path or list of local file paths
|
||||
|
||||
Returns:
|
||||
True if drag was created successfully
|
||||
"""
|
||||
try:
|
||||
# Create MIME data with file URL
|
||||
# Normalize to list
|
||||
if isinstance(file_paths, Path):
|
||||
paths_list = [file_paths]
|
||||
else:
|
||||
paths_list = list(file_paths)
|
||||
|
||||
# Create MIME data with file URLs
|
||||
mime_data = QMimeData()
|
||||
file_url = QUrl.fromLocalFile(str(file_path))
|
||||
mime_data.setUrls([file_url])
|
||||
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)")
|
||||
|
||||
# Create and execute drag
|
||||
drag = QDrag(self)
|
||||
|
|
|
|||
|
|
@ -11,40 +11,45 @@
|
|||
|
||||
console.log('%c[WebDrop Intercept] Script loaded - INTERCEPT_ENABLED=' + INTERCEPT_ENABLED, 'background: #2196F3; color: white; font-weight: bold; padding: 4px 8px;');
|
||||
|
||||
var currentDragUrl = null;
|
||||
var currentDragUrls = []; // Array to support multiple URLs
|
||||
var angularDragHandlers = [];
|
||||
var originalAddEventListener = EventTarget.prototype.addEventListener;
|
||||
var listenerPatchActive = true;
|
||||
var dragHandlerInstalled = false;
|
||||
|
||||
// Capture Authorization token from XHR requests
|
||||
// Capture Authorization token from XHR requests (only if checkout is enabled)
|
||||
window.capturedAuthToken = null;
|
||||
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);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
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)');
|
||||
}
|
||||
// All other events: use original
|
||||
return originalAddEventListener.call(this, type, listener, options);
|
||||
};
|
||||
|
||||
// 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
|
||||
// This is critical to ensure mouseover/hover events work properly
|
||||
return originalAddEventListener.call(this, type, listener, options);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// PART 2: Intercept DataTransfer.setData to capture URL
|
||||
|
|
@ -54,8 +59,14 @@
|
|||
|
||||
DataTransfer.prototype.setData = function(format, data) {
|
||||
if (format === 'text/plain' || format === 'text/uri-list') {
|
||||
currentDragUrl = data;
|
||||
console.log('%c[Intercept] Captured URL:', 'color: #4CAF50; font-weight: bold;', data.substring(0, 80));
|
||||
// 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));
|
||||
}
|
||||
return originalSetData.call(this, format, data);
|
||||
};
|
||||
|
|
@ -75,16 +86,20 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Stop intercepting addEventListener
|
||||
listenerPatchActive = false;
|
||||
// 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
|
||||
|
||||
// Register OUR handler in capture phase
|
||||
originalAddEventListener.call(document, 'dragstart', function(e) {
|
||||
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);
|
||||
currentDragUrls = []; // Reset
|
||||
|
||||
// Call Angular's handlers first to let them set the data
|
||||
var handled = 0;
|
||||
|
|
@ -101,33 +116,41 @@
|
|||
}
|
||||
}
|
||||
|
||||
console.log('[Intercept] Called', handled, 'Angular handlers, URL:', currentDragUrl ? currentDragUrl.substring(0, 60) : 'none');
|
||||
console.log('[Intercept] Called', handled, 'Angular handlers, URLs:', currentDragUrls.length, 'URL(s)', currentDragUrls.length > 0 ? currentDragUrls[0].substring(0, 60) : 'none');
|
||||
|
||||
// NOW check if we should intercept
|
||||
// Intercept when: Alt key held (normal mode) OR native DnD disabled via URL param
|
||||
if ((e.altKey || disabledNativeDnD) && currentDragUrl) {
|
||||
// Intercept any drag with URLs that match our configured mappings
|
||||
if (currentDragUrls.length > 0) {
|
||||
var shouldIntercept = false;
|
||||
|
||||
// Check against configured URL mappings
|
||||
// Check each URL against configured URL mappings
|
||||
// Intercept if ANY URL matches
|
||||
if (window.webdropConfig && window.webdropConfig.urlMappings) {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (shouldIntercept) break;
|
||||
}
|
||||
} else {
|
||||
// Fallback: Check for legacy Z: drive pattern if no config available
|
||||
shouldIntercept = /^z:/i.test(currentDragUrl);
|
||||
if (shouldIntercept) {
|
||||
console.warn('[Intercept] Using fallback Z: drive pattern (no URL mappings configured)');
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldIntercept) {
|
||||
console.log('%c[Intercept] PREVENTING browser drag, using Qt',
|
||||
console.log('%c[Intercept] PREVENTING browser drag, using Qt for ' + currentDragUrls.length + ' file(s)',
|
||||
'background: #F44336; color: white; font-weight: bold; padding: 4px 8px;');
|
||||
|
||||
e.preventDefault();
|
||||
|
|
@ -135,14 +158,15 @@
|
|||
|
||||
ensureChannel(function() {
|
||||
if (window.bridge && typeof window.bridge.start_file_drag === 'function') {
|
||||
console.log('%c[Intercept] → Qt: start_file_drag', 'color: #9C27B0; font-weight: bold;');
|
||||
window.bridge.start_file_drag(currentDragUrl);
|
||||
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));
|
||||
} else {
|
||||
console.error('[Intercept] bridge.start_file_drag not available!');
|
||||
}
|
||||
});
|
||||
|
||||
currentDragUrl = null;
|
||||
currentDragUrls = [];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -154,8 +178,23 @@
|
|||
}
|
||||
|
||||
// Wait for Angular to register its listeners, then install our handler
|
||||
// Start checking after 2 seconds (give Angular time to load on first page load)
|
||||
setTimeout(installDragHandler, 2000);
|
||||
// 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);
|
||||
|
||||
// ============================================================================
|
||||
// PART 3: QWebChannel connection
|
||||
|
|
@ -191,7 +230,7 @@
|
|||
});
|
||||
}
|
||||
|
||||
console.log('%c[WebDrop Intercept] Ready! ALT-drag or ?disablednd=true will use Qt file drag.',
|
||||
console.log('%c[WebDrop Intercept] Ready! URL-mapped drags 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);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import re
|
|||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, Union
|
||||
|
||||
from PySide6.QtCore import (
|
||||
QEvent,
|
||||
|
|
@ -312,21 +312,34 @@ class _DragBridge(QObject):
|
|||
self.window = window
|
||||
|
||||
@Slot(str)
|
||||
def start_file_drag(self, path_text: str) -> None:
|
||||
"""Start a native file drag for the given path or Azure URL.
|
||||
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)
|
||||
|
||||
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:
|
||||
path_text: File path string or Azure URL to drag
|
||||
paths_text: String (single path/URL) or JSON array string (multiple paths/URLs)
|
||||
"""
|
||||
logger.debug(f"Bridge: start_file_drag called for {path_text}")
|
||||
logger.debug(f"Bridge: start_file_drag called with {len(paths_text)} chars")
|
||||
|
||||
# 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))
|
||||
# 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))
|
||||
|
||||
@Slot(str)
|
||||
def debug_log(self, message: str) -> None:
|
||||
|
|
@ -521,8 +534,8 @@ class MainWindow(QMainWindow):
|
|||
def _install_bridge_script(self) -> None:
|
||||
"""Install the drag bridge JavaScript via QWebEngineScript.
|
||||
|
||||
Uses DocumentCreation injection point to ensure script runs as early as possible,
|
||||
before any page scripts that might interfere with drag events.
|
||||
Uses Deferred injection point to ensure script runs after the DOM is ready,
|
||||
allowing proper event listener registration without race conditions.
|
||||
|
||||
Embeds qwebchannel.js inline to avoid CSP issues with qrc:// URLs.
|
||||
Injects configuration that bridge script uses for dynamic URL pattern matching.
|
||||
|
|
@ -531,7 +544,9 @@ class MainWindow(QMainWindow):
|
|||
|
||||
script = QWebEngineScript()
|
||||
script.setName("webdrop-bridge")
|
||||
script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
|
||||
# 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.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
|
||||
script.setRunsOnSubFrames(False)
|
||||
|
||||
|
|
@ -620,18 +635,47 @@ class MainWindow(QMainWindow):
|
|||
else:
|
||||
logger.debug("Download interceptor not found (optional)")
|
||||
|
||||
# Combine: qwebchannel.js + config + bridge script + download interceptor
|
||||
# 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
|
||||
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"interceptor: {len(download_interceptor_code)}, "
|
||||
f"mouse_emulator: {len(mouse_emulator_code)})"
|
||||
)
|
||||
logger.debug(f"URL mappings in config: {len(self.config.url_mappings)}")
|
||||
for i, mapping in enumerate(self.config.url_mappings):
|
||||
|
|
@ -667,10 +711,11 @@ class MainWindow(QMainWindow):
|
|||
logger.debug(f" [{i+1}] {m['url_prefix']} -> {m['local_path']}")
|
||||
|
||||
# Generate config object as JSON
|
||||
config_obj = {"urlMappings": mappings}
|
||||
config_obj = {"urlMappings": mappings, "enableCheckout": self.config.enable_checkout}
|
||||
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"""
|
||||
|
|
@ -680,6 +725,7 @@ 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');
|
||||
|
|
@ -1384,12 +1430,146 @@ 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."""
|
||||
"""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
|
||||
|
||||
# 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
|
||||
|
|
@ -1983,3 +2163,4 @@ class UpdateDownloadWorker(QObject):
|
|||
except Exception as e:
|
||||
logger.warning(f"Error closing event loop: {e}")
|
||||
self.finished.emit()
|
||||
self.finished.emit()
|
||||
|
|
|
|||
186
src/webdrop_bridge/ui/mouse_event_emulator.js
Normal file
186
src/webdrop_bridge/ui/mouse_event_emulator.js
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
|
@ -8,7 +8,12 @@ from typing import List, Optional, Union
|
|||
|
||||
from PySide6.QtCore import QStandardPaths, QUrl
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWebEngineCore import QWebEngineNavigationRequest, QWebEnginePage, QWebEngineProfile
|
||||
from PySide6.QtWebEngineCore import (
|
||||
QWebEngineNavigationRequest,
|
||||
QWebEnginePage,
|
||||
QWebEngineProfile,
|
||||
QWebEngineSettings,
|
||||
)
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -128,6 +133,31 @@ 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)
|
||||
|
|
@ -136,6 +166,23 @@ 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ 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
|
||||
|
||||
|
|
@ -136,7 +137,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,
|
||||
|
|
@ -150,6 +151,7 @@ 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
|
||||
|
||||
|
|
@ -196,6 +198,7 @@ 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
|
||||
|
|
@ -235,3 +238,234 @@ 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]
|
||||
|
|
|
|||
|
|
@ -82,136 +82,6 @@ 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."""
|
||||
|
||||
|
|
@ -231,9 +101,7 @@ 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
|
||||
|
||||
|
|
@ -244,9 +112,7 @@ 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))
|
||||
|
|
@ -276,278 +142,10 @@ 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("<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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue