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