Compare commits

...

3 commits

Author SHA1 Message Date
856aec65de feat: ensure toolbar icons are included in MSI bundle during build process
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-10 13:17:35 +01:00
a261de3460 feat: implement Open With functionality for file chooser integration 2026-03-10 13:16:27 +01:00
939c2f896f Refactor code structure for improved readability and maintainability 2026-03-10 12:52:12 +01:00
7 changed files with 257 additions and 2 deletions

View file

@ -243,6 +243,10 @@ class WindowsBuilder:
if not self._create_wix_source(): if not self._create_wix_source():
return False 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 # Harvest application files using Heat
print(f" Harvesting application files...") print(f" Harvesting application files...")
dist_folder = self.dist_dir / "WebDropBridge" dist_folder = self.dist_dir / "WebDropBridge"
@ -352,6 +356,36 @@ class WindowsBuilder:
return True 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: def _create_wix_source(self) -> bool:
"""Create WiX source file for MSI generation. """Create WiX source file for MSI generation.

BIN
resources/icons/home.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

BIN
resources/icons/open.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

BIN
resources/icons/reload.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View file

@ -4,6 +4,7 @@ import asyncio
import json import json
import logging import logging
import re import re
import subprocess
import sys import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -250,6 +251,12 @@ class OpenDropZone(QWidget):
self.setMinimumSize(QSize(44, 44)) self.setMinimumSize(QSize(44, 44))
self.setMaximumSize(QSize(48, 48)) 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 # Drop handling
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -295,6 +302,46 @@ class OpenDropZone(QWidget):
logger.debug(f"OpenDropZone: skipping non-local URL {url.toString()}") 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): class _DragBridge(QObject):
"""JavaScript bridge for drag operations via QWebChannel. """JavaScript bridge for drag operations via QWebChannel.
@ -1071,6 +1118,78 @@ class MainWindow(QMainWindow):
f"File: {file_path}\nError: {error}", 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: def _on_download_requested(self, download: QWebEngineDownloadRequest) -> None:
"""Handle download requests from the embedded web view. """Handle download requests from the embedded web view.
@ -1263,25 +1382,52 @@ class MainWindow(QMainWindow):
# Separator # Separator
toolbar.addSeparator() 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 button
home_action = toolbar.addAction( home_icon_path = icons_dir / "home.ico"
self.style().standardIcon(self.style().StandardPixmap.SP_DirHomeIcon), "" 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.setToolTip("Home")
home_action.triggered.connect(self._navigate_home) home_action.triggered.connect(self._navigate_home)
# Refresh button # Refresh button
refresh_action = self.web_view.pageAction(self.web_view.page().WebAction.Reload) 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) toolbar.addAction(refresh_action)
# Open-with-default-app drop zone (right of Reload) # Open-with-default-app drop zone (right of Reload)
self._open_drop_zone = OpenDropZone() 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_opened.connect(self._on_file_opened_via_drop)
self._open_drop_zone.file_open_failed.connect(self._on_file_open_failed_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 = QWidgetAction(toolbar)
open_drop_action.setDefaultWidget(self._open_drop_zone) open_drop_action.setDefaultWidget(self._open_drop_zone)
toolbar.addAction(open_drop_action) 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 # Add stretch spacer to push help buttons to the right
spacer = QWidget() spacer = QWidget()
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) 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"Bridges web-based drag-and-drop workflows with native file operations "
f"for professional desktop applications.<br>" f"for professional desktop applications.<br>"
f"<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>Product of:</b><br>"
f"<b>hörl Information Management GmbH</b><br>" f"<b>hörl Information Management GmbH</b><br>"
f"Silberburgstraße 126<br>" f"Silberburgstraße 126<br>"

View file

@ -149,3 +149,74 @@ class TestMainWindowURLWhitelist:
# web_view should have allowed_urls configured # web_view should have allowed_urls configured
assert window.web_view.allowed_urls == sample_config.allowed_urls 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