diff --git a/build/scripts/build_windows.py b/build/scripts/build_windows.py index 67b960e..42d68fa 100644 --- a/build/scripts/build_windows.py +++ b/build/scripts/build_windows.py @@ -243,6 +243,10 @@ class WindowsBuilder: if not self._create_wix_source(): return False + # Ensure toolbar icons are present in bundled resources before harvesting. + if not self._ensure_toolbar_icons_in_bundle(): + return False + # Harvest application files using Heat print(f" Harvesting application files...") dist_folder = self.dist_dir / "WebDropBridge" @@ -352,6 +356,36 @@ class WindowsBuilder: return True + def _ensure_toolbar_icons_in_bundle(self) -> bool: + """Ensure toolbar icon files exist in the bundled app folder. + + This guarantees WiX Heat harvest includes these icons in the MSI, + even if a previous PyInstaller run omitted them. + """ + src_icons_dir = self.project_root / "resources" / "icons" + bundle_icons_dir = self.dist_dir / "WebDropBridge" / "_internal" / "resources" / "icons" + required_icons = ["home.ico", "reload.ico", "open.ico", "openwith.ico"] + + try: + bundle_icons_dir.mkdir(parents=True, exist_ok=True) + + for icon_name in required_icons: + src = src_icons_dir / icon_name + dst = bundle_icons_dir / icon_name + + if not src.exists(): + print(f"❌ Required icon not found: {src}") + return False + + if not dst.exists() or src.stat().st_mtime > dst.stat().st_mtime: + shutil.copy2(src, dst) + print(f" ✓ Ensured toolbar icon in bundle: {icon_name}") + + return True + except Exception as e: + print(f"❌ Failed to ensure toolbar icons in bundle: {e}") + return False + def _create_wix_source(self) -> bool: """Create WiX source file for MSI generation. diff --git a/resources/icons/home.ico b/resources/icons/home.ico new file mode 100644 index 0000000..e080469 Binary files /dev/null and b/resources/icons/home.ico differ diff --git a/resources/icons/open.ico b/resources/icons/open.ico new file mode 100644 index 0000000..1b6fed8 Binary files /dev/null and b/resources/icons/open.ico differ diff --git a/resources/icons/openwith.ico b/resources/icons/openwith.ico new file mode 100644 index 0000000..3facabc Binary files /dev/null and b/resources/icons/openwith.ico differ diff --git a/resources/icons/reload.ico b/resources/icons/reload.ico new file mode 100644 index 0000000..1f1023d Binary files /dev/null and b/resources/icons/reload.ico differ diff --git a/src/webdrop_bridge/ui/main_window.py b/src/webdrop_bridge/ui/main_window.py index c6db37e..2bfc78e 100644 --- a/src/webdrop_bridge/ui/main_window.py +++ b/src/webdrop_bridge/ui/main_window.py @@ -4,6 +4,7 @@ import asyncio import json import logging import re +import subprocess import sys from datetime import datetime from pathlib import Path @@ -250,6 +251,12 @@ class OpenDropZone(QWidget): self.setMinimumSize(QSize(44, 44)) self.setMaximumSize(QSize(48, 48)) + def set_icon(self, icon: QIcon) -> None: + """Set the displayed icon for the drop zone widget.""" + if icon.isNull(): + return + self._icon_label.setPixmap(icon.pixmap(QSize(32, 32))) + # ------------------------------------------------------------------ # Drop handling # ------------------------------------------------------------------ @@ -295,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. @@ -1071,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. @@ -1263,25 +1382,52 @@ class MainWindow(QMainWindow): # Separator toolbar.addSeparator() + if hasattr(sys, "_MEIPASS"): + icons_dir = Path(sys._MEIPASS) / "resources" / "icons" # type: ignore[attr-defined] + else: + icons_dir = Path(__file__).parent.parent.parent.parent / "resources" / "icons" + # Home button - home_action = toolbar.addAction( - self.style().standardIcon(self.style().StandardPixmap.SP_DirHomeIcon), "" + home_icon_path = icons_dir / "home.ico" + home_icon = ( + QIcon(str(home_icon_path)) + if home_icon_path.exists() + else self.style().standardIcon(self.style().StandardPixmap.SP_DirHomeIcon) ) + home_action = toolbar.addAction(home_icon, "") home_action.setToolTip("Home") home_action.triggered.connect(self._navigate_home) # Refresh button refresh_action = self.web_view.pageAction(self.web_view.page().WebAction.Reload) + reload_icon_path = icons_dir / "reload.ico" + if reload_icon_path.exists(): + refresh_action.setIcon(QIcon(str(reload_icon_path))) toolbar.addAction(refresh_action) # Open-with-default-app drop zone (right of Reload) self._open_drop_zone = OpenDropZone() + open_icon_path = icons_dir / "open.ico" + if open_icon_path.exists(): + self._open_drop_zone.set_icon(QIcon(str(open_icon_path))) 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) + # 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) @@ -1467,6 +1613,10 @@ class MainWindow(QMainWindow): f"Bridges web-based drag-and-drop workflows with native file operations " f"for professional desktop applications.
" f"
" + f"Toolbar Drop Zones:
" + f"Open icon: Opens dropped files with the system default app.
" + f"Open-with icon: Shows an app chooser for dropped files.
" + f"
" f"Product of:
" f"hörl Information Management GmbH
" f"Silberburgstraße 126
" diff --git a/tests/unit/test_main_window.py b/tests/unit/test_main_window.py index 01eaa5a..161be8a 100644 --- a/tests/unit/test_main_window.py +++ b/tests/unit/test_main_window.py @@ -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