feat: Implement runtime branding management and add branding settings to UI

This commit is contained in:
claudi 2026-04-15 11:01:49 +02:00
parent f022d984b6
commit ca7105a6bc
8 changed files with 493 additions and 64 deletions

View file

@ -427,7 +427,9 @@ class MainWindow(QMainWindow):
self._background_threads = [] # Keep references to background threads
self._background_workers = {} # Keep references to background workers
self._bridge_script_source = "" # Cache combined bridge source for recovery injection
self._bridge_script_re_registered = False # Flag to prevent duplicate re-registration on same load
self._bridge_script_re_registered = (
False # Flag to prevent duplicate re-registration on same load
)
self._is_page_loading = False # Track if a page load is currently in progress
self._pending_reload = False # Coalesce multiple rapid reload requests into one
self._load_sequence = 0 # Monotonic counter to ignore stale async recovery callbacks
@ -444,22 +446,13 @@ class MainWindow(QMainWindow):
config.window_height,
)
# Set window icon
# Support both development mode and PyInstaller bundle
if hasattr(sys, "_MEIPASS"):
# Running as PyInstaller bundle
icon_path = Path(sys._MEIPASS) / "resources" / "icons" / "app.ico" # type: ignore
else:
# Running in development mode
icon_path = (
Path(__file__).parent.parent.parent.parent / "resources" / "icons" / "app.ico"
)
if icon_path.exists():
# Set window icon from the active runtime branding
icon_path = self._resolve_toolbar_icon_path(config.app_icon_path)
if icon_path is not None:
self.setWindowIcon(QIcon(str(icon_path)))
logger.debug(f"Window icon set from {icon_path}")
else:
logger.warning(f"Window icon not found at {icon_path}")
logger.warning(f"Window icon not found for configured path: {config.app_icon_path}")
# Create web engine view with URL for profile isolation
self.web_view = RestrictedWebEngineView(
@ -1189,7 +1182,9 @@ class MainWindow(QMainWindow):
# This more reliably opens files with chosen applications.
# Use a simple, more direct approach
# Get the chosen app via AppleScript, then use open command
get_app_script = '''choose application with title "Select an application to open the file"'''
get_app_script = (
'''choose application with title "Select an application to open the file"'''
)
try:
# Get the chosen application
app_result = subprocess.run(
@ -1199,19 +1194,21 @@ class MainWindow(QMainWindow):
text=True,
timeout=30,
)
if app_result.returncode != 0:
logger.warning(f"User cancelled app chooser or error occurred: {app_result.stderr}")
logger.warning(
f"User cancelled app chooser or error occurred: {app_result.stderr}"
)
return False
# Get the application name (strip whitespace)
chosen_app = app_result.stdout.strip()
if not chosen_app:
logger.warning("No application was selected")
return False
logger.info(f"User selected app: {chosen_app}")
# Now open the file with the chosen app using the 'open' command
open_result = subprocess.run(
["open", "-a", chosen_app, normalized_path],
@ -1220,14 +1217,16 @@ class MainWindow(QMainWindow):
text=True,
timeout=10,
)
if open_result.returncode == 0:
logger.info(f"Opened '{normalized_path}' with '{chosen_app}'")
return True
else:
logger.warning(f"Failed to open file with '{chosen_app}': {open_result.stderr}")
logger.warning(
f"Failed to open file with '{chosen_app}': {open_result.stderr}"
)
return False
except subprocess.TimeoutExpired:
logger.warning("App chooser timed out")
return False
@ -1393,7 +1392,7 @@ class MainWindow(QMainWindow):
Re-registers the bridge script to ensure it will be injected on reload,
page navigation, or any load event.
Uses a flag to prevent duplicate re-registrations if loadStarted fires multiple times.
"""
self._is_page_loading = True
@ -1412,7 +1411,7 @@ class MainWindow(QMainWindow):
Checks if the bridge script was successfully injected, with automatic recovery
for page reloads and redirects.
Resets the re-registration flag for the next load cycle.
Args:
@ -1433,9 +1432,11 @@ class MainWindow(QMainWindow):
logger.warning("Page failed to load")
return
def _verify_bridge_loaded(stage: str, attempt: int = 1, sequence: int = finished_sequence) -> None:
def _verify_bridge_loaded(
stage: str, attempt: int = 1, sequence: int = finished_sequence
) -> None:
"""Check if bridge marker exists and optionally recover script injection.
Implements multi-attempt recovery strategy:
- initial: First check after page load (50ms delay)
- recovery_N: Recovery attempts with progressive delays
@ -1485,9 +1486,7 @@ class MainWindow(QMainWindow):
delay = int(100 * (1.5 ** (attempt - 1)))
QTimer.singleShot(
delay,
lambda: _verify_bridge_loaded(
"recovery", attempt + 1, sequence
),
lambda: _verify_bridge_loaded("recovery", attempt + 1, sequence),
)
self.web_view.page().runJavaScript(self._bridge_script_source, after_retry)
@ -1507,11 +1506,15 @@ class MainWindow(QMainWindow):
)
self._re_register_bridge_script()
self.web_view.page().runJavaScript(self._bridge_script_source, after_re_register)
self.web_view.page().runJavaScript(
self._bridge_script_source, after_re_register
)
return
# All recovery attempts exhausted
logger.error("❌ WebDrop Bridge script failed to inject after all recovery attempts!")
logger.error(
"❌ WebDrop Bridge script failed to inject after all recovery attempts!"
)
logger.error(" Drag-and-drop functionality is DISABLED")
logger.debug(f" Stage: {stage}, Attempt: {attempt}")
@ -1543,21 +1546,21 @@ class MainWindow(QMainWindow):
def _ensure_bridge_script_exists(self, verbose: bool = False) -> None:
"""Ensure bridge script exists in QWebEngineScript collection (idempotent).
Checks if the script already exists. If not, adds it.
Never removes/re-adds to avoid race conditions with Qt's injection mechanism.
This is safer than removing+re-adding because:
- Avoids concurrent access conflicts with Qt's internal injection
- Prevents missing injections during rapid reloads
- Guarantees script is available without timing gaps
Args:
verbose: If True, use debug logging; otherwise use minimal logging
"""
try:
scripts = self.web_view.page().scripts()
# Check if script already exists
already_exists = False
for script in scripts.toList(): # type: ignore
@ -1566,7 +1569,7 @@ class MainWindow(QMainWindow):
if verbose:
logger.debug("Bridge script already exists in page().scripts()")
break
# If script doesn't exist, add it
if not already_exists and self._bridge_script_source:
new_script = QWebEngineScript()
@ -1582,16 +1585,18 @@ class MainWindow(QMainWindow):
new_script.setSourceCode(self._bridge_script_source)
scripts.insert(new_script)
logger.debug(f"✓ Added bridge script to collection ({len(self._bridge_script_source)} chars)")
logger.debug(
f"✓ Added bridge script to collection ({len(self._bridge_script_source)} chars)"
)
except Exception as e:
logger.error(f"Failed to ensure bridge script exists: {e}")
def _re_register_bridge_script(self, verbose: bool = False) -> None:
"""Force re-registration of bridge script in QWebEngineScript collection.
Removes old script and re-adds it to ensure it's injected on next page load.
This is a fallback for recovery mechanics when normal injection fails.
Args:
verbose: If True, use debug logging; otherwise use minimal logging
"""
@ -1622,7 +1627,9 @@ class MainWindow(QMainWindow):
scripts.insert(new_script)
if verbose or removed:
logger.debug(f"✓ Re-registered webdrop-bridge script ({len(self._bridge_script_source)} chars)")
logger.debug(
f"✓ Re-registered webdrop-bridge script ({len(self._bridge_script_source)} chars)"
)
except Exception as e:
logger.error(f"Failed to re-register bridge script: {e}")
@ -1649,9 +1656,7 @@ class MainWindow(QMainWindow):
toolbar.addSeparator()
# Home button
home_icon_path = self._resolve_toolbar_icon_path(
os.getenv("TOOLBAR_ICON_HOME", "resources/icons/home.ico")
)
home_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_home)
home_icon = (
QIcon(str(home_icon_path))
if home_icon_path is not None
@ -1663,9 +1668,7 @@ class MainWindow(QMainWindow):
# Refresh button
refresh_action = toolbar.addAction("")
reload_icon_path = self._resolve_toolbar_icon_path(
os.getenv("TOOLBAR_ICON_RELOAD", "resources/icons/reload.ico")
)
reload_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_reload)
if reload_icon_path is not None:
refresh_action.setIcon(QIcon(str(reload_icon_path)))
else:
@ -1677,9 +1680,7 @@ class MainWindow(QMainWindow):
# Open-with-default-app drop zone (right of Reload)
self._open_drop_zone = OpenDropZone()
open_icon_path = self._resolve_toolbar_icon_path(
os.getenv("TOOLBAR_ICON_OPEN", "resources/icons/open.ico")
)
open_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_open)
if open_icon_path is not None:
self._open_drop_zone.set_icon(QIcon(str(open_icon_path)))
self._open_drop_zone.file_opened.connect(self._on_file_opened_via_drop)
@ -1690,9 +1691,7 @@ class MainWindow(QMainWindow):
# Open-with chooser drop zone (right of Open-with-default-app)
self._open_with_drop_zone = OpenWithDropZone()
open_with_icon_path = self._resolve_toolbar_icon_path(
os.getenv("TOOLBAR_ICON_OPENWITH", "resources/icons/openwith.ico")
)
open_with_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_openwith)
if open_with_icon_path is not None:
self._open_with_drop_zone.set_icon(QIcon(str(open_with_icon_path)))
self._open_with_drop_zone.file_open_with_requested.connect(