diff --git a/src/webdrop_bridge/ui/main_window.py b/src/webdrop_bridge/ui/main_window.py index 2b05a07..b6d3b64 100644 --- a/src/webdrop_bridge/ui/main_window.py +++ b/src/webdrop_bridge/ui/main_window.py @@ -200,6 +200,101 @@ DEFAULT_WELCOME_PAGE = """ """ +class OpenDropZone(QWidget): + """Drop target widget that opens dragged files with their system default application. + + Displays an 'open folder' icon in the navigation toolbar. When a file is + dragged from the web view and dropped here, the file's URL is passed to + ``QDesktopServices.openUrl()`` so the OS opens it with the associated + programme — exactly the same way double-clicking a file in Explorer/Finder + would behave. + + Visual feedback is provided on drag-enter (green border highlight) so the + user can see the drop target is active. + + Signals: + file_opened (str): Emitted with the local file path when successfully opened. + file_open_failed (str, str): Emitted with (path, error_message) on failure. + """ + + file_opened = Signal(str) + file_open_failed = Signal(str, str) + + _NORMAL_STYLE = "QLabel { padding: 4px; border: 2px solid transparent; border-radius: 4px; }" + _HOVER_STYLE = ( + "QLabel { padding: 4px; border: 2px solid #4CAF50; border-radius: 4px;" + " background: #E8F5E9; }" + ) + + def __init__(self, parent: Optional[QWidget] = None) -> None: + """Initialize the OpenDropZone widget. + + Args: + parent: Parent widget. + """ + super().__init__(parent) + self.setAcceptDrops(True) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self._icon_label = QLabel(self) + icon = self.style().standardIcon(self.style().StandardPixmap.SP_DirOpenIcon) + pixmap = icon.pixmap(QSize(32, 32)) + self._icon_label.setPixmap(pixmap) + self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._icon_label.setStyleSheet(self._NORMAL_STYLE) + self._icon_label.setToolTip("Drop a file here to open it with its default application") + layout.addWidget(self._icon_label) + + self.setMinimumSize(QSize(44, 44)) + self.setMaximumSize(QSize(48, 48)) + + # ------------------------------------------------------------------ + # Drop handling + # ------------------------------------------------------------------ + + def dragEnterEvent(self, event) -> None: # type: ignore[override] + """Accept drag events that carry file URLs.""" + if event.mimeData().hasUrls(): + event.acceptProposedAction() + self._icon_label.setStyleSheet(self._HOVER_STYLE) + else: + event.ignore() + + def dragLeaveEvent(self, event) -> None: # type: ignore[override] + """Reset appearance when drag leaves the widget.""" + self._icon_label.setStyleSheet(self._NORMAL_STYLE) + super().dragLeaveEvent(event) + + def dropEvent(self, event) -> None: # type: ignore[override] + """Open each dropped file with the system default application. + + Accepts the drop action so that the originating ``QDrag`` reports + success (preserving normal drag-started accounting), then immediately + opens the file via ``QDesktopServices.openUrl()``. + """ + self._icon_label.setStyleSheet(self._NORMAL_STYLE) + mime = event.mimeData() + if not mime.hasUrls(): + event.ignore() + return + + event.acceptProposedAction() + for url in mime.urls(): + if url.isLocalFile(): + file_path = url.toLocalFile() + logger.info(f"OpenDropZone: opening '{file_path}' with system default app") + if QDesktopServices.openUrl(url): + self.file_opened.emit(file_path) + else: + msg = "OS could not open the file" + logger.warning(f"OpenDropZone: {msg}: {file_path}") + self.file_open_failed.emit(file_path, msg) + else: + logger.debug(f"OpenDropZone: skipping non-local URL {url.toString()}") + + class _DragBridge(QObject): """JavaScript bridge for drag operations via QWebChannel. @@ -899,6 +994,30 @@ class MainWindow(QMainWindow): f"Could not complete the drag-and-drop operation.\n\nError: {error}", ) + def _on_file_opened_via_drop(self, file_path: str) -> None: + """Handle a file successfully opened via the OpenDropZone. + + Args: + file_path: Local file path that was opened. + """ + logger.info(f"Opened via drop zone: {file_path}") + self.statusBar().showMessage(f"Opened: {Path(file_path).name}", 4000) + + def _on_file_open_failed_via_drop(self, file_path: str, error: str) -> None: + """Handle a failure to open a file dropped on the OpenDropZone. + + Args: + file_path: Local file path that could not be opened. + error: Error description. + """ + logger.warning(f"Failed to open via drop zone '{file_path}': {error}") + QMessageBox.warning( + self, + "Open File Error", + f"Could not open the file with its default application.\n\n" + f"File: {file_path}\nError: {error}", + ) + def _on_download_requested(self, download: QWebEngineDownloadRequest) -> None: """Handle download requests from the embedded web view. @@ -1102,6 +1221,14 @@ class MainWindow(QMainWindow): refresh_action = self.web_view.pageAction(self.web_view.page().WebAction.Reload) toolbar.addAction(refresh_action) + # Open-with-default-app drop zone (right of Reload) + self._open_drop_zone = OpenDropZone() + self._open_drop_zone.file_opened.connect(self._on_file_opened_via_drop) + self._open_drop_zone.file_open_failed.connect(self._on_file_open_failed_via_drop) + open_drop_action = QWidgetAction(toolbar) + open_drop_action.setDefaultWidget(self._open_drop_zone) + toolbar.addAction(open_drop_action) + # Add stretch spacer to push help buttons to the right spacer = QWidget() spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) diff --git a/src/webdrop_bridge/ui/restricted_web_view.py b/src/webdrop_bridge/ui/restricted_web_view.py index bd77741..a8359a4 100644 --- a/src/webdrop_bridge/ui/restricted_web_view.py +++ b/src/webdrop_bridge/ui/restricted_web_view.py @@ -204,7 +204,7 @@ class RestrictedWebEngineView(QWebEngineView): - Exact domain matches: example.com - Wildcard patterns: *.example.com - Localhost variations: localhost, 127.0.0.1 - - File URLs: file://... + - Internal/local URLs: file://, data:, about:, blob:, qrc: Args: url: QUrl to check @@ -216,8 +216,8 @@ class RestrictedWebEngineView(QWebEngineView): host = url.host() scheme = url.scheme() - # Allow file:// URLs (local webapp) - if scheme == "file": + # Allow internal browser/Qt schemes (never send these to the OS) + if scheme in ("file", "data", "about", "blob", "qrc"): return True # If no whitelist, allow all URLs