feat: Implement runtime branding management and add branding settings to UI
This commit is contained in:
parent
f022d984b6
commit
ca7105a6bc
8 changed files with 493 additions and 64 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from PySide6.QtWidgets import (
|
|||
)
|
||||
|
||||
from webdrop_bridge.config import Config, ConfigurationError
|
||||
from webdrop_bridge.core.branding_manager import BrandingManager
|
||||
from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator
|
||||
from webdrop_bridge.utils.i18n import get_available_languages, tr
|
||||
from webdrop_bridge.utils.logging import reconfigure_logging
|
||||
|
|
@ -42,6 +43,7 @@ class SettingsDialog(QDialog):
|
|||
"""
|
||||
super().__init__(parent)
|
||||
self.config = config
|
||||
self.branding_manager = BrandingManager()
|
||||
self.profile_manager = ConfigProfile(config.config_dir_name)
|
||||
self.setWindowTitle(tr("settings.title"))
|
||||
self.setGeometry(100, 100, 600, 500)
|
||||
|
|
@ -54,6 +56,7 @@ class SettingsDialog(QDialog):
|
|||
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.addTab(self._create_general_tab(), tr("settings.tab.general"))
|
||||
self.tabs.addTab(self._create_branding_tab(), tr("settings.tab.branding"))
|
||||
self.tabs.addTab(self._create_web_source_tab(), tr("settings.tab.web_source"))
|
||||
self.tabs.addTab(self._create_paths_tab(), tr("settings.tab.paths"))
|
||||
self.tabs.addTab(self._create_urls_tab(), tr("settings.tab.urls"))
|
||||
|
|
@ -83,6 +86,14 @@ class SettingsDialog(QDialog):
|
|||
for m in config_data["url_mappings"]
|
||||
]
|
||||
|
||||
selected_branding_id = config_data.get(
|
||||
"active_branding_id", self.config.active_branding_id
|
||||
)
|
||||
old_branding_id = self.config.active_branding_id
|
||||
self.branding_manager.set_active_branding_id(selected_branding_id)
|
||||
self.config.active_branding_id = selected_branding_id
|
||||
self.branding_manager.apply_to_config(self.config)
|
||||
|
||||
old_log_level = self.config.log_level
|
||||
self.config.language = config_data["language"]
|
||||
self.config.log_level = config_data["log_level"]
|
||||
|
|
@ -102,6 +113,12 @@ class SettingsDialog(QDialog):
|
|||
logger.info(f"Configuration saved to {config_path}")
|
||||
logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})")
|
||||
logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}")
|
||||
if old_branding_id != self.config.active_branding_id:
|
||||
logger.info(
|
||||
" Active branding changed: %s -> %s",
|
||||
old_branding_id,
|
||||
self.config.active_branding_id,
|
||||
)
|
||||
|
||||
if old_log_level != self.config.log_level:
|
||||
reconfigure_logging(
|
||||
|
|
@ -151,6 +168,41 @@ class SettingsDialog(QDialog):
|
|||
widget.setLayout(layout)
|
||||
return widget
|
||||
|
||||
def _create_branding_tab(self) -> QWidget:
|
||||
"""Create runtime branding tab."""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
label = QLabel(tr("settings.branding.select_label"))
|
||||
label.setToolTip(tr("settings.branding.select_tooltip"))
|
||||
layout.addWidget(label)
|
||||
|
||||
help_label = QLabel(tr("settings.branding.help_text"))
|
||||
help_label.setWordWrap(True)
|
||||
help_label.setStyleSheet("color: gray; font-size: 11px;")
|
||||
layout.addWidget(help_label)
|
||||
|
||||
self.branding_combo = QComboBox()
|
||||
self.branding_combo.setToolTip(tr("settings.branding.select_tooltip"))
|
||||
for template in self.branding_manager.list_templates():
|
||||
self.branding_combo.addItem(template.display_name, template.template_id)
|
||||
|
||||
idx = self.branding_combo.findData(self.config.active_branding_id)
|
||||
if idx < 0:
|
||||
idx = self.branding_combo.findData("default")
|
||||
if idx >= 0:
|
||||
self.branding_combo.setCurrentIndex(idx)
|
||||
layout.addWidget(self.branding_combo)
|
||||
|
||||
note = QLabel(tr("settings.branding.restart_note"))
|
||||
note.setWordWrap(True)
|
||||
note.setStyleSheet("color: gray; font-size: 11px;")
|
||||
layout.addWidget(note)
|
||||
|
||||
layout.addStretch()
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
|
||||
def _create_web_source_tab(self) -> QWidget:
|
||||
"""Create web source configuration tab."""
|
||||
widget = QWidget()
|
||||
|
|
@ -623,6 +675,7 @@ class SettingsDialog(QDialog):
|
|||
"app_name": self.config.app_name,
|
||||
"app_version": self.config.app_version,
|
||||
"language": self.language_combo.currentData(),
|
||||
"active_branding_id": self.branding_combo.currentData(),
|
||||
"log_level": self.log_level_combo.currentText(),
|
||||
"log_file": self.log_file_input.text() or None,
|
||||
"allowed_roots": [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue