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
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue