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:
claudi 2026-02-17 15:56:53 +01:00
parent c9704efc8d
commit 88dc358894
21 changed files with 1870 additions and 432 deletions

View file

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