Refactor drag handling and update tests
- Renamed `initiate_drag` to `handle_drag` in MainWindow and updated related tests. - Improved drag handling logic to utilize a bridge for starting file drags. - Updated `_on_drag_started` and `_on_drag_failed` methods to match new signatures. - Modified test cases to reflect changes in drag handling and assertions. Enhance path validation and logging - Updated `PathValidator` to log warnings for nonexistent roots instead of raising errors. - Adjusted tests to verify the new behavior of skipping nonexistent roots. Update web application UI and functionality - Changed displayed text for drag items to reflect local paths and Azure Blob Storage URLs. - Added debug logging for drag operations in the web application. - Improved instructions for testing drag and drop functionality. Add configuration documentation and example files - Created `CONFIG_README.md` to provide detailed configuration instructions for WebDrop Bridge. - Added `config.example.json` and `config_test.json` for reference and testing purposes. Implement URL conversion logic - Introduced `URLConverter` class to handle conversion of Azure Blob Storage URLs to local paths. - Added unit tests for URL conversion to ensure correct functionality. Develop download interceptor script - Created `download_interceptor.js` to intercept download-related actions in the web application. - Implemented logging for fetch calls, XMLHttpRequests, and Blob URL creations. Add download test page and related tests - Created `test_download.html` for testing various download scenarios. - Implemented `test_download.py` to verify download path resolution and file construction. - Added `test_url_mappings.py` to ensure URL mappings are loaded correctly. Add unit tests for URL converter - Created `test_url_converter.py` to validate URL conversion logic and mapping behavior.
This commit is contained in:
parent
c9704efc8d
commit
88dc358894
21 changed files with 1870 additions and 432 deletions
|
|
@ -5,6 +5,58 @@
|
|||
if (window.__webdrop_bridge_injected) return;
|
||||
window.__webdrop_bridge_injected = true;
|
||||
|
||||
console.log('[WebDrop Bridge] Script loaded');
|
||||
|
||||
// Store web app's dragstart handlers by intercepting addEventListener
|
||||
var webAppDragHandlers = [];
|
||||
var originalAddEventListener = EventTarget.prototype.addEventListener;
|
||||
var listenerPatchActive = true;
|
||||
|
||||
// Patch addEventListener to intercept dragstart registrations
|
||||
EventTarget.prototype.addEventListener = function(type, listener, options) {
|
||||
if (listenerPatchActive && type === 'dragstart' && listener) {
|
||||
// Store the web app's dragstart handler instead of registering it
|
||||
console.log('[WebDrop Bridge] Intercepted dragstart listener registration on', this.tagName || this.constructor.name);
|
||||
webAppDragHandlers.push({
|
||||
target: this,
|
||||
listener: listener,
|
||||
options: options
|
||||
});
|
||||
return;
|
||||
}
|
||||
// All other events: use original
|
||||
return originalAddEventListener.call(this, type, listener, options);
|
||||
};
|
||||
|
||||
// Patch DataTransfer.setData to intercept URL setting by the web app
|
||||
var originalSetData = null;
|
||||
var currentDragData = null;
|
||||
|
||||
try {
|
||||
if (DataTransfer.prototype.setData) {
|
||||
originalSetData = DataTransfer.prototype.setData;
|
||||
|
||||
DataTransfer.prototype.setData = function(format, data) {
|
||||
// Store the data for our analysis
|
||||
if (format === 'text/plain' || format === 'text/uri-list') {
|
||||
currentDragData = data;
|
||||
console.log('[WebDrop Bridge] DataTransfer.setData intercepted:', format, '=', data.substring(0, 80));
|
||||
|
||||
// Log via bridge if available
|
||||
if (window.bridge && typeof window.bridge.debug_log === 'function') {
|
||||
window.bridge.debug_log('setData intercepted: ' + format + ' = ' + data.substring(0, 60));
|
||||
}
|
||||
}
|
||||
// Call original to maintain web app functionality
|
||||
return originalSetData.call(this, format, data);
|
||||
};
|
||||
|
||||
console.log('[WebDrop Bridge] DataTransfer.setData patched');
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('[WebDrop Bridge] Failed to patch DataTransfer:', e);
|
||||
}
|
||||
|
||||
function ensureChannel(cb) {
|
||||
if (window.bridge) { cb(); return; }
|
||||
|
||||
|
|
@ -12,62 +64,151 @@
|
|||
if (window.QWebChannel && window.qt && window.qt.webChannelTransport) {
|
||||
new QWebChannel(window.qt.webChannelTransport, function(channel) {
|
||||
window.bridge = channel.objects.bridge;
|
||||
console.log('[WebDrop Bridge] QWebChannel connected');
|
||||
cb();
|
||||
});
|
||||
} else {
|
||||
// If QWebChannel is not available, log error
|
||||
console.error('[WebDrop Bridge] QWebChannel not available! Check if qwebchannel.js was loaded.');
|
||||
}
|
||||
}
|
||||
|
||||
// QWebChannel should already be loaded inline (no need to load from qrc://)
|
||||
if (window.QWebChannel) {
|
||||
init();
|
||||
return;
|
||||
} else {
|
||||
console.error('[WebDrop Bridge] QWebChannel not found! Cannot initialize bridge.');
|
||||
}
|
||||
|
||||
var s = document.createElement('script');
|
||||
s.src = 'qrc:///qtwebchannel/qwebchannel.js';
|
||||
s.onload = init;
|
||||
document.documentElement.appendChild(s);
|
||||
}
|
||||
|
||||
function hook() {
|
||||
document.addEventListener('dragstart', function(e) {
|
||||
var dt = e.dataTransfer;
|
||||
if (!dt) return;
|
||||
|
||||
// Get path from existing payload or from the card markup.
|
||||
var path = dt.getData('text/plain');
|
||||
if (!path) {
|
||||
var card = e.target.closest && e.target.closest('.drag-item');
|
||||
if (card) {
|
||||
var pathEl = card.querySelector('p');
|
||||
if (pathEl) {
|
||||
path = (pathEl.textContent || '').trim();
|
||||
console.log('[WebDrop Bridge] Installing hook, have ' + webAppDragHandlers.length + ' intercepted handlers');
|
||||
|
||||
if (window.bridge && typeof window.bridge.debug_log === 'function') {
|
||||
window.bridge.debug_log('Installing drag interceptor with ' + webAppDragHandlers.length + ' intercepted handlers');
|
||||
}
|
||||
|
||||
// Stop intercepting addEventListener - from now on, listeners register normally
|
||||
listenerPatchActive = false;
|
||||
|
||||
// Register OUR dragstart handler using capture phase on document
|
||||
originalAddEventListener.call(document, 'dragstart', function(e) {
|
||||
try {
|
||||
console.log('[WebDrop Bridge] >>> DRAGSTART fired on:', e.target.tagName, 'altKey:', e.altKey, 'currentDragData:', currentDragData);
|
||||
|
||||
if (window.bridge && typeof window.bridge.debug_log === 'function') {
|
||||
window.bridge.debug_log('dragstart fired on ' + e.target.tagName + ' altKey=' + e.altKey);
|
||||
}
|
||||
|
||||
// Only intercept if ALT key is pressed (web app's text drag mode)
|
||||
if (!e.altKey) {
|
||||
console.log('[WebDrop Bridge] ALT not pressed, ignoring drag (normal web app drag)');
|
||||
return; // Let web app handle normal drags
|
||||
}
|
||||
|
||||
console.log('[WebDrop Bridge] ALT pressed - processing for file drag conversion');
|
||||
|
||||
// Manually invoke all the web app's dragstart handlers
|
||||
var handlersInvoked = 0;
|
||||
console.log('[WebDrop Bridge] About to invoke', webAppDragHandlers.length, 'stored handlers');
|
||||
|
||||
for (var i = 0; i < webAppDragHandlers.length; i++) {
|
||||
try {
|
||||
var handler = webAppDragHandlers[i];
|
||||
// Check if this handler should be called for this target
|
||||
if (handler.target === document ||
|
||||
handler.target === e.target ||
|
||||
(handler.target.contains && handler.target.contains(e.target))) {
|
||||
|
||||
console.log('[WebDrop Bridge] Calling stored handler #' + i);
|
||||
handler.listener.call(e.target, e);
|
||||
handlersInvoked++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WebDrop Bridge] Error calling web app handler #' + i + ':', err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[WebDrop Bridge] Invoked', handlersInvoked, 'handlers, currentDragData:', currentDragData ? currentDragData.substring(0, 60) : 'null');
|
||||
|
||||
// NOW check if we have a convertible URL
|
||||
if (currentDragData) {
|
||||
console.log('[WebDrop Bridge] Checking currentDragData:', currentDragData.substring(0, 80));
|
||||
var path = currentDragData;
|
||||
var isZDrive = /^z:/i.test(path);
|
||||
var isAzureUrl = /^https?:\/\/.+\.file\.core\.windows\.net\//i.test(path);
|
||||
|
||||
console.log('[WebDrop Bridge] isZDrive:', isZDrive, 'isAzureUrl:', isAzureUrl);
|
||||
|
||||
if (isZDrive || isAzureUrl) {
|
||||
console.log('[WebDrop Bridge] >>> CONVERTING URL TO NATIVE DRAG');
|
||||
|
||||
if (window.bridge && typeof window.bridge.debug_log === 'function') {
|
||||
window.bridge.debug_log('Convertible URL detected - preventing browser drag');
|
||||
}
|
||||
|
||||
// Prevent the browser's drag operation
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Start native file drag via Qt
|
||||
ensureChannel(function() {
|
||||
if (window.bridge && typeof window.bridge.start_file_drag === 'function') {
|
||||
console.log('[WebDrop Bridge] Calling start_file_drag:', path.substring(0, 60));
|
||||
window.bridge.start_file_drag(path);
|
||||
currentDragData = null;
|
||||
} else {
|
||||
console.error('[WebDrop Bridge] bridge.start_file_drag not available!');
|
||||
}
|
||||
});
|
||||
|
||||
return false;
|
||||
} else {
|
||||
console.log('[WebDrop Bridge] URL not convertible:', path.substring(0, 60));
|
||||
}
|
||||
} else {
|
||||
console.log('[WebDrop Bridge] No currentDragData set');
|
||||
}
|
||||
} catch (mainError) {
|
||||
console.error('[WebDrop Bridge] CRITICAL ERROR in dragstart handler:', mainError);
|
||||
if (window.bridge && typeof window.bridge.debug_log === 'function') {
|
||||
window.bridge.debug_log('ERROR in dragstart: ' + mainError.message);
|
||||
}
|
||||
}
|
||||
if (!path) return;
|
||||
}, true); // CAPTURE PHASE - intercept early
|
||||
|
||||
// Reset state on dragend
|
||||
originalAddEventListener.call(document, 'dragend', function(e) {
|
||||
currentDragData = null;
|
||||
}, false);
|
||||
|
||||
console.log('[WebDrop Bridge] Drag listener registered on document (capture phase)');
|
||||
}
|
||||
|
||||
// Ensure text payload exists for non-file drags and downstream targets.
|
||||
if (!dt.getData('text/plain')) {
|
||||
dt.setData('text/plain', path);
|
||||
}
|
||||
|
||||
// Check if path is Z:\ — if yes, trigger native file drag. Otherwise, stay as text.
|
||||
var isZDrive = /^z:/i.test(path);
|
||||
if (!isZDrive) return;
|
||||
|
||||
// Z:\ detected — prevent default browser drag and convert to native file drag
|
||||
e.preventDefault();
|
||||
// Wait for DOMContentLoaded and then a bit more before installing hook
|
||||
// This gives the web app time to register its handlers
|
||||
function installHook() {
|
||||
console.log('[WebDrop Bridge] DOM ready, waiting 2 seconds for web app to register handlers...');
|
||||
console.log('[WebDrop Bridge] Currently have', webAppDragHandlers.length, 'intercepted handlers');
|
||||
|
||||
setTimeout(function() {
|
||||
console.log('[WebDrop Bridge] Installing hook now, have', webAppDragHandlers.length, 'intercepted handlers');
|
||||
hook();
|
||||
|
||||
ensureChannel(function() {
|
||||
if (window.bridge && typeof window.bridge.start_file_drag === 'function') {
|
||||
window.bridge.start_file_drag(path);
|
||||
if (window.bridge && typeof window.bridge.debug_log === 'function') {
|
||||
window.bridge.debug_log('Hook installed with ' + webAppDragHandlers.length + ' captured handlers');
|
||||
}
|
||||
});
|
||||
}, false);
|
||||
}, 2000); // Wait 2 seconds after DOM ready
|
||||
}
|
||||
|
||||
|
||||
// Install after DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', hook);
|
||||
console.log('[WebDrop Bridge] Waiting for DOMContentLoaded...');
|
||||
originalAddEventListener.call(document, 'DOMContentLoaded', installHook);
|
||||
} else {
|
||||
hook();
|
||||
console.log('[WebDrop Bridge] DOM already ready, installing hook...');
|
||||
installHook();
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
72
src/webdrop_bridge/ui/download_interceptor.js
Normal file
72
src/webdrop_bridge/ui/download_interceptor.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// Download Interceptor Script
|
||||
// Intercepts JavaScript-based downloads (fetch, XMLHttpRequest, Blob URLs)
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
console.log('🔍 Download interceptor script loaded');
|
||||
|
||||
// Intercept fetch() calls
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(...args) {
|
||||
const url = args[0];
|
||||
console.log('🌐 Fetch called:', url);
|
||||
|
||||
// Check if this looks like a download
|
||||
if (typeof url === 'string') {
|
||||
const urlLower = url.toLowerCase();
|
||||
const downloadPatterns = [
|
||||
'/download', '/export', '/file',
|
||||
'.pdf', '.zip', '.xlsx', '.docx',
|
||||
'attachment', 'content-disposition'
|
||||
];
|
||||
|
||||
if (downloadPatterns.some(pattern => urlLower.includes(pattern))) {
|
||||
console.log('📥 Potential download detected via fetch:', url);
|
||||
}
|
||||
}
|
||||
|
||||
return originalFetch.apply(this, args);
|
||||
};
|
||||
|
||||
// Intercept XMLHttpRequest
|
||||
const originalXHROpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
|
||||
console.log('🌐 XHR opened:', method, url);
|
||||
this._url = url;
|
||||
return originalXHROpen.apply(this, [method, url, ...rest]);
|
||||
};
|
||||
|
||||
const originalXHRSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.send = function(...args) {
|
||||
console.log('📤 XHR send:', this._url);
|
||||
return originalXHRSend.apply(this, args);
|
||||
};
|
||||
|
||||
// Intercept Blob URL creation
|
||||
const originalCreateObjectURL = URL.createObjectURL;
|
||||
URL.createObjectURL = function(blob) {
|
||||
console.log('🔗 Blob URL created, size:', blob.size, 'type:', blob.type);
|
||||
return originalCreateObjectURL.apply(this, arguments);
|
||||
};
|
||||
|
||||
// Intercept anchor clicks that might be downloads
|
||||
document.addEventListener('click', function(e) {
|
||||
const target = e.target.closest('a');
|
||||
if (target && target.href) {
|
||||
const href = target.href;
|
||||
const download = target.getAttribute('download');
|
||||
|
||||
if (download !== null) {
|
||||
console.log('📥 Download link clicked:', href, 'filename:', download);
|
||||
}
|
||||
|
||||
// Check for blob URLs
|
||||
if (href.startsWith('blob:')) {
|
||||
console.log('📦 Blob download link clicked:', href);
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
console.log('✅ Download interceptor active');
|
||||
})();
|
||||
|
|
@ -6,10 +6,22 @@ from datetime import datetime
|
|||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QObject, QPoint, QSize, Qt, QThread, QTimer, QUrl, Signal, Slot
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtCore import (
|
||||
QEvent,
|
||||
QObject,
|
||||
QPoint,
|
||||
QSize,
|
||||
QStandardPaths,
|
||||
Qt,
|
||||
QThread,
|
||||
QTimer,
|
||||
QUrl,
|
||||
Signal,
|
||||
Slot,
|
||||
)
|
||||
from PySide6.QtGui import QIcon, QMouseEvent
|
||||
from PySide6.QtWebChannel import QWebChannel
|
||||
from PySide6.QtWebEngineCore import QWebEngineScript
|
||||
from PySide6.QtWebEngineCore import QWebEngineDownloadRequest, QWebEngineScript
|
||||
from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
|
|
@ -202,19 +214,29 @@ class _DragBridge(QObject):
|
|||
|
||||
@Slot(str)
|
||||
def start_file_drag(self, path_text: str) -> None:
|
||||
"""Start a native file drag for the given path.
|
||||
"""Start a native file drag for the given path or Azure URL.
|
||||
|
||||
Called from JavaScript when user drags a Z:\ path item.
|
||||
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 to drag
|
||||
path_text: File path string or Azure URL to drag
|
||||
"""
|
||||
logger.debug(f"Bridge: start_file_drag called for {path_text}")
|
||||
|
||||
# Defer to avoid drag manager state issues
|
||||
# initiate_drag() handles validation internally
|
||||
QTimer.singleShot(0, lambda: self.window.drag_interceptor.initiate_drag([path_text]))
|
||||
# handle_drag() handles URL conversion and validation internally
|
||||
QTimer.singleShot(0, lambda: self.window.drag_interceptor.handle_drag(path_text))
|
||||
|
||||
@Slot(str)
|
||||
def debug_log(self, message: str) -> None:
|
||||
"""Log debug message from JavaScript.
|
||||
|
||||
Args:
|
||||
message: Debug message from JavaScript
|
||||
"""
|
||||
logger.info(f"JS Debug: {message}")
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
|
|
@ -257,6 +279,16 @@ class MainWindow(QMainWindow):
|
|||
|
||||
# Create web engine view
|
||||
self.web_view = RestrictedWebEngineView(config.allowed_urls)
|
||||
|
||||
# Enable the main window and web view to receive drag events
|
||||
self.setAcceptDrops(True)
|
||||
self.web_view.setAcceptDrops(True)
|
||||
|
||||
# Track ongoing drags from web view
|
||||
self._current_drag_url = None
|
||||
|
||||
# Redirect JavaScript console messages to Python logger
|
||||
self.web_view.page().javaScriptConsoleMessage = self._on_js_console_message
|
||||
|
||||
# Create navigation toolbar (Kiosk-mode navigation)
|
||||
self._create_navigation_toolbar()
|
||||
|
|
@ -264,11 +296,8 @@ class MainWindow(QMainWindow):
|
|||
# Create status bar
|
||||
self._create_status_bar()
|
||||
|
||||
# Create drag interceptor
|
||||
self.drag_interceptor = DragInterceptor()
|
||||
# Set up path validator
|
||||
validator = PathValidator(config.allowed_roots)
|
||||
self.drag_interceptor.set_validator(validator)
|
||||
# Create drag interceptor with config (includes URL converter)
|
||||
self.drag_interceptor = DragInterceptor(config)
|
||||
|
||||
# Connect drag interceptor signals
|
||||
self.drag_interceptor.drag_started.connect(self._on_drag_started)
|
||||
|
|
@ -282,6 +311,26 @@ class MainWindow(QMainWindow):
|
|||
|
||||
# Install the drag bridge script
|
||||
self._install_bridge_script()
|
||||
|
||||
# Connect to loadFinished to verify script injection
|
||||
self.web_view.loadFinished.connect(self._on_page_loaded)
|
||||
|
||||
# Set up download handler
|
||||
profile = self.web_view.page().profile()
|
||||
logger.info(f"Connecting download handler to profile: {profile}")
|
||||
|
||||
# CRITICAL: Connect download handler BEFORE any page loads
|
||||
profile.downloadRequested.connect(self._on_download_requested)
|
||||
|
||||
# Enable downloads by setting download path
|
||||
downloads_path = QStandardPaths.writableLocation(
|
||||
QStandardPaths.StandardLocation.DownloadLocation
|
||||
)
|
||||
if downloads_path:
|
||||
profile.setDownloadPath(downloads_path)
|
||||
logger.info(f"Download path set to: {downloads_path}")
|
||||
|
||||
logger.info("Download handler connected successfully")
|
||||
|
||||
# Set up central widget with layout
|
||||
central_widget = QWidget()
|
||||
|
|
@ -353,19 +402,55 @@ class MainWindow(QMainWindow):
|
|||
def _install_bridge_script(self) -> None:
|
||||
"""Install the drag bridge JavaScript via QWebEngineScript.
|
||||
|
||||
Follows the POC pattern for proper script injection and QWebChannel setup.
|
||||
Uses DocumentCreation injection point to ensure script runs as early as possible,
|
||||
before any page scripts that might interfere with drag events.
|
||||
|
||||
Embeds qwebchannel.js inline to avoid CSP issues with qrc:// URLs.
|
||||
"""
|
||||
from PySide6.QtCore import QFile, QIODevice
|
||||
|
||||
script = QWebEngineScript()
|
||||
script.setName("webdrop-bridge")
|
||||
script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady)
|
||||
script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
|
||||
script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
|
||||
script.setRunsOnSubFrames(False)
|
||||
|
||||
# Load qwebchannel.js from Qt resources (avoids CSP blocking qrc:// URLs)
|
||||
qwebchannel_code = ""
|
||||
qwebchannel_file = QFile(":/qtwebchannel/qwebchannel.js")
|
||||
if qwebchannel_file.open(QIODevice.OpenModeFlag.ReadOnly | QIODevice.OpenModeFlag.Text):
|
||||
qwebchannel_code = bytes(qwebchannel_file.readAll()).decode('utf-8')
|
||||
qwebchannel_file.close()
|
||||
logger.debug("Loaded qwebchannel.js inline to avoid CSP issues")
|
||||
else:
|
||||
logger.warning("Failed to load qwebchannel.js from resources")
|
||||
|
||||
# Load bridge script from file
|
||||
script_path = Path(__file__).parent / "bridge_script.js"
|
||||
try:
|
||||
with open(script_path, 'r', encoding='utf-8') as f:
|
||||
script.setSourceCode(f.read())
|
||||
bridge_code = f.read()
|
||||
|
||||
# Load download interceptor
|
||||
download_interceptor_path = Path(__file__).parent / "download_interceptor.js"
|
||||
download_interceptor_code = ""
|
||||
try:
|
||||
with open(download_interceptor_path, 'r', encoding='utf-8') as f:
|
||||
download_interceptor_code = f.read()
|
||||
logger.debug(f"Loaded download interceptor from {download_interceptor_path}")
|
||||
except (OSError, IOError) as e:
|
||||
logger.warning(f"Download interceptor not found: {e}")
|
||||
|
||||
# Combine qwebchannel.js + bridge script + download interceptor (inline to avoid CSP)
|
||||
if qwebchannel_code:
|
||||
combined_code = qwebchannel_code + "\n\n" + bridge_code
|
||||
else:
|
||||
combined_code = bridge_code
|
||||
|
||||
if download_interceptor_code:
|
||||
combined_code += "\n\n" + download_interceptor_code
|
||||
|
||||
script.setSourceCode(combined_code)
|
||||
self.web_view.page().scripts().insert(script)
|
||||
logger.debug(f"Installed bridge script from {script_path}")
|
||||
except (OSError, IOError) as e:
|
||||
|
|
@ -399,23 +484,248 @@ class MainWindow(QMainWindow):
|
|||
# Silently fail if stylesheet can't be read
|
||||
pass
|
||||
|
||||
def _on_drag_started(self, paths: list) -> None:
|
||||
def _on_drag_started(self, source: str, local_path: str) -> None:
|
||||
"""Handle successful drag initiation.
|
||||
|
||||
Args:
|
||||
paths: List of paths that were dragged
|
||||
source: Original URL or path from web content
|
||||
local_path: Local file path that is being dragged
|
||||
"""
|
||||
# Can be extended with logging or status bar updates
|
||||
pass
|
||||
logger.info(f"Drag started: {source} -> {local_path}")
|
||||
# Can be extended with status bar updates or user feedback
|
||||
|
||||
def _on_drag_failed(self, error: str) -> None:
|
||||
def _on_drag_failed(self, source: str, error: str) -> None:
|
||||
"""Handle drag operation failure.
|
||||
|
||||
Args:
|
||||
source: Original URL or path from web content
|
||||
error: Error message
|
||||
"""
|
||||
# Can be extended with logging or user notification
|
||||
pass
|
||||
logger.warning(f"Drag failed for {source}: {error}")
|
||||
# Can be extended with user notification or status bar message
|
||||
|
||||
def _on_download_requested(self, download: QWebEngineDownloadRequest) -> None:
|
||||
"""Handle download requests from the embedded web view.
|
||||
|
||||
Downloads are automatically saved to the user's Downloads folder.
|
||||
|
||||
Args:
|
||||
download: Download request from the web engine
|
||||
"""
|
||||
logger.info("=" * 60)
|
||||
logger.info("🔥 DOWNLOAD REQUESTED - Handler called!")
|
||||
logger.info("=" * 60)
|
||||
|
||||
try:
|
||||
# Log all download details for debugging
|
||||
logger.info(f"Download URL: {download.url().toString()}")
|
||||
logger.info(f"Download filename: {download.downloadFileName()}")
|
||||
logger.info(f"Download mime type: {download.mimeType()}")
|
||||
logger.info(f"Download suggested filename: {download.suggestedFileName()}")
|
||||
logger.info(f"Download state: {download.state()}")
|
||||
|
||||
# Get the system's Downloads folder
|
||||
downloads_path = QStandardPaths.writableLocation(
|
||||
QStandardPaths.StandardLocation.DownloadLocation
|
||||
)
|
||||
|
||||
if not downloads_path:
|
||||
# Fallback to user's home directory if Downloads folder not available
|
||||
downloads_path = str(Path.home())
|
||||
logger.warning("Downloads folder not found, using home directory")
|
||||
|
||||
# Use suggested filename if available, fallback to downloadFileName
|
||||
filename = download.suggestedFileName() or download.downloadFileName()
|
||||
if not filename:
|
||||
filename = "download"
|
||||
logger.warning("No filename suggested, using 'download'")
|
||||
|
||||
# Construct full download path
|
||||
download_file = Path(downloads_path) / filename
|
||||
logger.info(f"📁 Download will be saved to: {download_file}")
|
||||
|
||||
# Set download path and accept
|
||||
download.setDownloadDirectory(str(download_file.parent))
|
||||
download.setDownloadFileName(download_file.name)
|
||||
download.accept()
|
||||
|
||||
logger.info(f"✅ Download accepted and started: {download_file}")
|
||||
|
||||
# Update status bar (temporarily)
|
||||
self.status_bar.showMessage(
|
||||
f"📥 Download: {filename}", 3000
|
||||
)
|
||||
|
||||
# Connect to state changed for progress tracking
|
||||
download.stateChanged.connect(
|
||||
lambda state: logger.info(f"Download state changed to: {state}")
|
||||
)
|
||||
|
||||
# Connect to finished signal for completion feedback
|
||||
download.isFinishedChanged.connect(
|
||||
lambda: self._on_download_finished(download, download_file)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error handling download: {e}", exc_info=True)
|
||||
self.status_bar.showMessage(f"❌ Download-Fehler: {e}", 5000)
|
||||
|
||||
def _on_download_finished(self, download: QWebEngineDownloadRequest, file_path: Path) -> None:
|
||||
"""Handle download completion.
|
||||
|
||||
Args:
|
||||
download: The completed download request
|
||||
file_path: Path where file was saved
|
||||
"""
|
||||
try:
|
||||
if not download.isFinished():
|
||||
return
|
||||
|
||||
state = download.state()
|
||||
logger.info(f"Download finished with state: {state}")
|
||||
|
||||
if state == QWebEngineDownloadRequest.DownloadState.DownloadCompleted:
|
||||
logger.info(f"Download completed successfully: {file_path}")
|
||||
self.status_bar.showMessage(
|
||||
f"✅ Download abgeschlossen: {file_path.name}", 5000
|
||||
)
|
||||
elif state == QWebEngineDownloadRequest.DownloadState.DownloadCancelled:
|
||||
logger.info(f"Download cancelled: {file_path.name}")
|
||||
self.status_bar.showMessage(
|
||||
f"⚠️ Download abgebrochen: {file_path.name}", 3000
|
||||
)
|
||||
elif state == QWebEngineDownloadRequest.DownloadState.DownloadInterrupted:
|
||||
logger.warning(f"Download interrupted: {file_path.name}")
|
||||
self.status_bar.showMessage(
|
||||
f"❌ Download fehlgeschlagen: {file_path.name}", 5000
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in download finished handler: {e}", exc_info=True)
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
"""Handle drag entering the main window (from WebView or external).
|
||||
|
||||
When a drag from the WebView enters the MainWindow area, we can read
|
||||
the drag data and potentially convert Azure URLs to file drags.
|
||||
|
||||
Args:
|
||||
event: QDragEnterEvent
|
||||
"""
|
||||
from PySide6.QtCore import QMimeData
|
||||
|
||||
mime_data = event.mimeData()
|
||||
|
||||
# Check if we have text data (URL from web app)
|
||||
if mime_data.hasText():
|
||||
url_text = mime_data.text()
|
||||
logger.debug(f"Drag entered main window with text: {url_text[:100]}")
|
||||
|
||||
# Store for potential conversion
|
||||
self._current_drag_url = url_text
|
||||
|
||||
# Check if it's convertible
|
||||
is_azure = url_text.startswith('https://') and 'file.core.windows.net' in url_text
|
||||
is_z_drive = url_text.lower().startswith('z:')
|
||||
|
||||
if is_azure or is_z_drive:
|
||||
logger.info(f"Convertible URL detected in drag: {url_text[:60]}")
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
|
||||
event.ignore()
|
||||
|
||||
def dragMoveEvent(self, event):
|
||||
"""Handle drag moving over the main window.
|
||||
|
||||
Args:
|
||||
event: QDragMoveEvent
|
||||
"""
|
||||
if self._current_drag_url:
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def dragLeaveEvent(self, event):
|
||||
"""Handle drag leaving the main window.
|
||||
|
||||
Args:
|
||||
event: QDragLeaveEvent
|
||||
"""
|
||||
logger.debug("Drag left main window")
|
||||
# Reset tracking
|
||||
self._current_drag_url = None
|
||||
|
||||
def dropEvent(self, event):
|
||||
"""Handle drop on the main window.
|
||||
|
||||
This captures drops on the MainWindow area (outside WebView).
|
||||
If the user drops an Azure URL here, we convert it to a file operation.
|
||||
|
||||
Args:
|
||||
event: QDropEvent
|
||||
"""
|
||||
if self._current_drag_url:
|
||||
logger.info(f"Drop on main window with URL: {self._current_drag_url[:60]}")
|
||||
|
||||
# Handle via drag interceptor (converts Azure URL to local path)
|
||||
success = self.drag_interceptor.handle_drag(self._current_drag_url)
|
||||
|
||||
if success:
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
self._current_drag_url = None
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def _on_js_console_message(self, level, message, line_number, source_id):
|
||||
"""Redirect JavaScript console messages to Python logger.
|
||||
|
||||
Args:
|
||||
level: Console message level (JavaScriptConsoleMessageLevel enum)
|
||||
message: The console message
|
||||
line_number: Line number where the message originated
|
||||
source_id: Source file/URL where the message originated
|
||||
"""
|
||||
from PySide6.QtWebEngineCore import QWebEnginePage
|
||||
|
||||
# Map JS log levels to Python log levels using enum
|
||||
if level == QWebEnginePage.JavaScriptConsoleMessageLevel.InfoMessageLevel:
|
||||
logger.info(f"JS Console: {message}")
|
||||
elif level == QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel:
|
||||
logger.warning(f"JS Console: {message}")
|
||||
logger.debug(f" at {source_id}:{line_number}")
|
||||
else: # ErrorMessageLevel
|
||||
logger.error(f"JS Console: {message}")
|
||||
logger.debug(f" at {source_id}:{line_number}")
|
||||
|
||||
def _on_page_loaded(self, success: bool) -> None:
|
||||
"""Called when a page finishes loading.
|
||||
|
||||
Checks if the bridge script was successfully injected.
|
||||
|
||||
Args:
|
||||
success: True if page loaded successfully
|
||||
"""
|
||||
if not success:
|
||||
logger.warning("Page failed to load")
|
||||
return
|
||||
|
||||
# Check if bridge script is loaded
|
||||
def check_script(result):
|
||||
if result:
|
||||
logger.info("✓ WebDrop Bridge script is active")
|
||||
logger.info("✓ QWebChannel bridge is ready")
|
||||
else:
|
||||
logger.error("✗ WebDrop Bridge script NOT loaded!")
|
||||
logger.error(" Drag-and-drop conversion will NOT work")
|
||||
|
||||
# Execute JS to check if our script is loaded
|
||||
self.web_view.page().runJavaScript(
|
||||
"typeof window.__webdrop_bridge_injected !== 'undefined' && window.__webdrop_bridge_injected === true",
|
||||
check_script
|
||||
)
|
||||
|
||||
def _create_navigation_toolbar(self) -> None:
|
||||
"""Create navigation toolbar with Home, Back, Forward, Refresh buttons.
|
||||
|
|
@ -488,7 +798,7 @@ class MainWindow(QMainWindow):
|
|||
|
||||
Args:
|
||||
status: Status text to display
|
||||
emoji: Optional emoji prefix (🔄, ✅, ⬇️, ⚠️)
|
||||
emoji: Optional emoji prefix (rotating, checkmark, download, warning symbols)
|
||||
"""
|
||||
if emoji:
|
||||
self.update_status_label.setText(f"{emoji} {status}")
|
||||
|
|
@ -559,24 +869,11 @@ class MainWindow(QMainWindow):
|
|||
# Can be extended with save operations or cleanup
|
||||
event.accept()
|
||||
|
||||
def initiate_drag(self, file_paths: list) -> bool:
|
||||
"""Initiate a drag operation for the given files.
|
||||
|
||||
Called from web content via JavaScript bridge.
|
||||
|
||||
Args:
|
||||
file_paths: List of file paths to drag
|
||||
|
||||
Returns:
|
||||
True if drag was initiated successfully
|
||||
"""
|
||||
return self.drag_interceptor.initiate_drag(file_paths)
|
||||
|
||||
def check_for_updates_startup(self) -> None:
|
||||
"""Check for updates on application startup.
|
||||
|
||||
Runs asynchronously in background without blocking UI.
|
||||
Uses 24h cache so won't hammer the API.
|
||||
Uses 24-hour cache so will not hammer the API.
|
||||
"""
|
||||
from webdrop_bridge.core.updater import UpdateManager
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,106 @@
|
|||
"""Restricted web view with URL whitelist enforcement for Kiosk-mode."""
|
||||
|
||||
import fnmatch
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from PySide6.QtCore import QUrl
|
||||
from PySide6.QtCore import QStandardPaths, QUrl
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWebEngineCore import QWebEngineNavigationRequest
|
||||
from PySide6.QtWebEngineCore import QWebEngineNavigationRequest, QWebEnginePage, QWebEngineProfile
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomWebEnginePage(QWebEnginePage):
|
||||
"""Custom page that handles new window requests for downloads."""
|
||||
|
||||
def acceptNavigationRequest(
|
||||
self, url: Union[QUrl, str], nav_type: QWebEnginePage.NavigationType, is_main_frame: bool
|
||||
) -> bool:
|
||||
"""Handle navigation requests, including download links.
|
||||
|
||||
Args:
|
||||
url: Target URL (QUrl or string)
|
||||
nav_type: Type of navigation (link click, form submit, etc.)
|
||||
is_main_frame: Whether this is the main frame
|
||||
|
||||
Returns:
|
||||
True to accept navigation, False to reject
|
||||
"""
|
||||
# Convert to string if QUrl
|
||||
url_str = url.toString() if isinstance(url, QUrl) else url
|
||||
|
||||
# Log all navigation attempts for debugging
|
||||
logger.debug(f"Navigation request: {url_str} (type={nav_type}, main_frame={is_main_frame})")
|
||||
|
||||
# Check if this might be a download (common file extensions)
|
||||
download_extensions = [
|
||||
".pdf",
|
||||
".zip",
|
||||
".rar",
|
||||
".7z",
|
||||
".tar",
|
||||
".gz",
|
||||
".doc",
|
||||
".docx",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".bmp",
|
||||
".svg",
|
||||
".mp4",
|
||||
".mp3",
|
||||
".avi",
|
||||
".mov",
|
||||
".wav",
|
||||
".exe",
|
||||
".msi",
|
||||
".dmg",
|
||||
".pkg",
|
||||
".csv",
|
||||
".txt",
|
||||
".json",
|
||||
".xml",
|
||||
]
|
||||
|
||||
if any(url_str.lower().endswith(ext) for ext in download_extensions):
|
||||
logger.info(f"🔽 Detected potential download URL: {url_str}")
|
||||
# This will trigger downloadRequested if it's a download
|
||||
|
||||
return super().acceptNavigationRequest(url, nav_type, is_main_frame)
|
||||
|
||||
def createWindow(self, window_type: QWebEnginePage.WebWindowType) -> QWebEnginePage:
|
||||
"""Handle new window requests (target=_blank, window.open, etc.).
|
||||
|
||||
Many downloads are triggered via target="_blank" links.
|
||||
|
||||
Args:
|
||||
window_type: Type of window being created
|
||||
|
||||
Returns:
|
||||
New page instance for the window
|
||||
"""
|
||||
logger.info(f"🪟 New window requested, type: {window_type}")
|
||||
|
||||
# Create a temporary page to handle the download
|
||||
# This page will never be displayed but allows downloads to work
|
||||
download_page = QWebEnginePage(self.profile(), self)
|
||||
|
||||
logger.info("✅ Created temporary page for download/popup")
|
||||
|
||||
# Return the temporary page - it will trigger downloadRequested if it's a download
|
||||
return download_page
|
||||
|
||||
|
||||
class RestrictedWebEngineView(QWebEngineView):
|
||||
"""Web view that enforces URL whitelist for Kiosk-mode security.
|
||||
|
|
@ -27,31 +120,81 @@ class RestrictedWebEngineView(QWebEngineView):
|
|||
super().__init__()
|
||||
self.allowed_urls = allowed_urls or []
|
||||
|
||||
# Create persistent profile for cookie and session storage
|
||||
self.profile = self._create_persistent_profile()
|
||||
|
||||
# Use custom page for better download handling with persistent profile
|
||||
custom_page = CustomWebEnginePage(self.profile, self)
|
||||
self.setPage(custom_page)
|
||||
|
||||
logger.info(
|
||||
"RestrictedWebEngineView initialized with CustomWebEnginePage and persistent profile"
|
||||
)
|
||||
|
||||
# Connect to navigation request handler
|
||||
self.page().navigationRequested.connect(self._on_navigation_requested)
|
||||
|
||||
def _on_navigation_requested(
|
||||
self, request: QWebEngineNavigationRequest
|
||||
) -> None:
|
||||
def _create_persistent_profile(self) -> QWebEngineProfile:
|
||||
"""Create and configure a persistent web engine profile.
|
||||
|
||||
This enables persistent cookies and cache storage, allowing
|
||||
authentication sessions (e.g., Microsoft login) to persist
|
||||
across application restarts.
|
||||
|
||||
Returns:
|
||||
Configured QWebEngineProfile with persistent storage
|
||||
"""
|
||||
# Get application data directory
|
||||
app_data_dir = QStandardPaths.writableLocation(
|
||||
QStandardPaths.StandardLocation.AppDataLocation
|
||||
)
|
||||
|
||||
# Create profile directory path
|
||||
profile_path = Path(app_data_dir) / "WebEngineProfile"
|
||||
profile_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create persistent profile with custom storage location
|
||||
# Using "WebDropBridge" as the profile name
|
||||
profile = QWebEngineProfile("WebDropBridge", self)
|
||||
profile.setPersistentStoragePath(str(profile_path))
|
||||
|
||||
# Configure persistent cookies (critical for authentication)
|
||||
profile.setPersistentCookiesPolicy(
|
||||
QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies
|
||||
)
|
||||
|
||||
# Enable HTTP cache for better performance
|
||||
profile.setHttpCacheType(QWebEngineProfile.HttpCacheType.DiskHttpCache)
|
||||
|
||||
# Set cache size to 100 MB
|
||||
profile.setHttpCacheMaximumSize(100 * 1024 * 1024)
|
||||
|
||||
logger.info(f"Created persistent profile at: {profile_path}")
|
||||
logger.info("Cookies policy: ForcePersistentCookies")
|
||||
logger.info("HTTP cache: DiskHttpCache (100 MB)")
|
||||
|
||||
return profile
|
||||
|
||||
def _on_navigation_requested(self, request: QWebEngineNavigationRequest) -> None:
|
||||
"""Handle navigation requests and enforce URL whitelist.
|
||||
|
||||
Args:
|
||||
request: Navigation request to process
|
||||
"""
|
||||
url = request.url
|
||||
url = request.url()
|
||||
|
||||
# If no restrictions, allow all URLs
|
||||
if not self.allowed_urls:
|
||||
return
|
||||
|
||||
# Check if URL matches whitelist
|
||||
if self._is_url_allowed(url): # type: ignore[operator]
|
||||
if self._is_url_allowed(url):
|
||||
# Allow the navigation (default behavior)
|
||||
return
|
||||
|
||||
# URL not whitelisted - open in system browser
|
||||
request.reject()
|
||||
QDesktopServices.openUrl(url) # type: ignore[operator]
|
||||
QDesktopServices.openUrl(url)
|
||||
|
||||
def _is_url_allowed(self, url: QUrl) -> bool:
|
||||
"""Check if a URL matches the whitelist.
|
||||
|
|
@ -98,4 +241,3 @@ class RestrictedWebEngineView(QWebEngineView):
|
|||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue