feat: implement Open With functionality for file chooser integration

This commit is contained in:
claudi 2026-03-10 13:16:27 +01:00
parent 939c2f896f
commit a261de3460
2 changed files with 200 additions and 0 deletions

View file

@ -4,6 +4,7 @@ import asyncio
import json
import logging
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
@ -301,6 +302,46 @@ class OpenDropZone(QWidget):
logger.debug(f"OpenDropZone: skipping non-local URL {url.toString()}")
class OpenWithDropZone(OpenDropZone):
"""Drop target widget that opens files via an app chooser dialog.
When a file is dropped, this widget emits ``file_open_with_requested`` for
each local file so the main window can invoke platform-specific
"Open With" behavior.
Signals:
file_open_with_requested (str): Emitted with the local file path.
"""
file_open_with_requested = Signal(str)
def __init__(self, parent: Optional[QWidget] = None) -> None:
"""Initialize the OpenWithDropZone widget.
Args:
parent: Parent widget.
"""
super().__init__(parent)
self._icon_label.setToolTip("Drop a file here to choose which app should open it")
def dropEvent(self, event) -> None: # type: ignore[override]
"""Emit dropped local files for app-chooser handling."""
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"OpenWithDropZone: request app chooser for '{file_path}'")
self.file_open_with_requested.emit(file_path)
else:
logger.debug(f"OpenWithDropZone: skipping non-local URL {url.toString()}")
class _DragBridge(QObject):
"""JavaScript bridge for drag operations via QWebChannel.
@ -1077,6 +1118,78 @@ class MainWindow(QMainWindow):
f"File: {file_path}\nError: {error}",
)
def _on_file_open_with_requested(self, file_path: str) -> None:
"""Handle a file dropped on the OpenWithDropZone.
Args:
file_path: Local file path to open using an app chooser.
"""
if self._open_with_app_chooser(file_path):
self.statusBar().showMessage(f"Choose app for: {Path(file_path).name}", 4000)
logger.info(f"Opened app chooser for '{file_path}'")
return
logger.warning(f"Could not open app chooser for '{file_path}'")
QMessageBox.warning(
self,
"Open With Error",
"Could not open an application chooser for this file on your platform.",
)
def _open_with_app_chooser(self, file_path: str) -> bool:
"""Open OS-specific app chooser for a local file.
Args:
file_path: Local file path.
Returns:
True if the chooser command was started successfully, False otherwise.
"""
try:
normalized_path = str(Path(file_path))
if not Path(normalized_path).exists():
logger.warning(f"Open-with target does not exist: {normalized_path}")
return False
if sys.platform.startswith("win"):
# First try the native shell "openas" verb.
import ctypes
result = ctypes.windll.shell32.ShellExecuteW(
None, "openas", normalized_path, None, None, 1
)
if result > 32:
return True
logger.warning(f"ShellExecuteW(openas) failed with code {result}; trying fallback")
# Fallback for systems where openas verb is not available/reliable.
subprocess.Popen(["rundll32.exe", "shell32.dll,OpenAs_RunDLL", normalized_path])
return True
if sys.platform == "darwin":
# Prompt for an app and open the file with the selected app.
script = (
"on run argv\n"
"set targetFile to POSIX file (item 1 of argv)\n"
"set chosenApp to choose application\n"
'tell application "Finder" to open targetFile using chosenApp\n'
"end run"
)
result = subprocess.run(
["osascript", "-e", script, file_path],
check=False,
capture_output=True,
text=True,
)
return result.returncode == 0
logger.warning(f"Open-with chooser not implemented for platform: {sys.platform}")
return False
except Exception as e:
logger.warning(f"Failed to open app chooser for '{file_path}': {e}")
return False
def _on_download_requested(self, download: QWebEngineDownloadRequest) -> None:
"""Handle download requests from the embedded web view.
@ -1303,6 +1416,18 @@ class MainWindow(QMainWindow):
open_drop_action.setDefaultWidget(self._open_drop_zone)
toolbar.addAction(open_drop_action)
# Open-with chooser drop zone (right of Open-with-default-app)
self._open_with_drop_zone = OpenWithDropZone()
open_with_icon_path = icons_dir / "openwith.ico"
if open_with_icon_path.exists():
self._open_with_drop_zone.set_icon(QIcon(str(open_with_icon_path)))
self._open_with_drop_zone.file_open_with_requested.connect(
self._on_file_open_with_requested
)
open_with_drop_action = QWidgetAction(toolbar)
open_with_drop_action.setDefaultWidget(self._open_with_drop_zone)
toolbar.addAction(open_with_drop_action)
# Add stretch spacer to push help buttons to the right
spacer = QWidget()
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
@ -1488,6 +1613,10 @@ class MainWindow(QMainWindow):
f"Bridges web-based drag-and-drop workflows with native file operations "
f"for professional desktop applications.<br>"
f"<br>"
f"<b>Toolbar Drop Zones:</b><br>"
f"Open icon: Opens dropped files with the system default app.<br>"
f"Open-with icon: Shows an app chooser for dropped files.<br>"
f"<br>"
f"<b>Product of:</b><br>"
f"<b>hörl Information Management GmbH</b><br>"
f"Silberburgstraße 126<br>"

View file

@ -149,3 +149,74 @@ class TestMainWindowURLWhitelist:
# web_view should have allowed_urls configured
assert window.web_view.allowed_urls == sample_config.allowed_urls
class TestMainWindowOpenWith:
"""Test Open With chooser behavior."""
def test_open_with_app_chooser_windows(self, qtbot, sample_config):
"""Windows should use ShellExecuteW with the openas verb."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
test_file = sample_config.allowed_roots[0] / "open_with_test.txt"
test_file.write_text("test")
with patch("webdrop_bridge.ui.main_window.sys.platform", "win32"):
with patch("ctypes.windll.shell32.ShellExecuteW", return_value=33) as mock_shell:
assert window._open_with_app_chooser(str(test_file)) is True
mock_shell.assert_called_once_with(
None,
"openas",
str(test_file),
None,
None,
1,
)
def test_open_with_app_chooser_windows_shellexecute_failure(self, qtbot, sample_config):
"""Windows should fall back to OpenAs_RunDLL when ShellExecuteW fails."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
test_file = sample_config.allowed_roots[0] / "open_with_fallback.txt"
test_file.write_text("test")
with patch("webdrop_bridge.ui.main_window.sys.platform", "win32"):
with patch("ctypes.windll.shell32.ShellExecuteW", return_value=31):
with patch("webdrop_bridge.ui.main_window.subprocess.Popen") as mock_popen:
assert window._open_with_app_chooser(str(test_file)) is True
mock_popen.assert_called_once_with(
["rundll32.exe", "shell32.dll,OpenAs_RunDLL", str(test_file)]
)
def test_open_with_app_chooser_missing_file(self, qtbot, sample_config):
"""Missing files should fail before platform-specific invocation."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
with patch("webdrop_bridge.ui.main_window.sys.platform", "win32"):
assert window._open_with_app_chooser("C:/tmp/does_not_exist.txt") is False
def test_open_with_app_chooser_macos_success(self, qtbot, sample_config):
"""macOS should return True when osascript exits successfully."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
test_file = sample_config.allowed_roots[0] / "open_with_macos.txt"
test_file.write_text("test")
class _Result:
returncode = 0
with patch("webdrop_bridge.ui.main_window.sys.platform", "darwin"):
with patch("webdrop_bridge.ui.main_window.subprocess.run", return_value=_Result()):
assert window._open_with_app_chooser(str(test_file)) is True
def test_open_with_app_chooser_unsupported_platform(self, qtbot, sample_config):
"""Unsupported platforms should return False."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
with patch("webdrop_bridge.ui.main_window.sys.platform", "linux"):
assert window._open_with_app_chooser("/tmp/test.txt") is False