Compare commits
3 commits
e84f7bbd66
...
856aec65de
| Author | SHA1 | Date | |
|---|---|---|---|
| 856aec65de | |||
| a261de3460 | |||
| 939c2f896f |
7 changed files with 257 additions and 2 deletions
|
|
@ -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
BIN
resources/icons/home.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
BIN
resources/icons/open.ico
Normal file
BIN
resources/icons/open.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
BIN
resources/icons/openwith.ico
Normal file
BIN
resources/icons/openwith.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
BIN
resources/icons/reload.ico
Normal file
BIN
resources/icons/reload.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
|
|
@ -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>"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue