From ca7105a6bc9d7752b401b8adf50f3f61f9b02ebd Mon Sep 17 00:00:00 2001 From: claudi Date: Wed, 15 Apr 2026 11:01:49 +0200 Subject: [PATCH] feat: Implement runtime branding management and add branding settings to UI --- resources/translations/de.json | 5 + resources/translations/en.json | 5 + src/webdrop_bridge/config.py | 51 ++++- src/webdrop_bridge/core/branding_manager.py | 240 ++++++++++++++++++++ src/webdrop_bridge/ui/main_window.py | 105 +++++---- src/webdrop_bridge/ui/settings_dialog.py | 53 +++++ tests/unit/test_branding_manager.py | 74 ++++++ tests/unit/test_settings_dialog.py | 24 +- 8 files changed, 493 insertions(+), 64 deletions(-) create mode 100644 src/webdrop_bridge/core/branding_manager.py create mode 100644 tests/unit/test_branding_manager.py diff --git a/resources/translations/de.json b/resources/translations/de.json index 7be1ed0..1597ddd 100644 --- a/resources/translations/de.json +++ b/resources/translations/de.json @@ -86,6 +86,11 @@ "settings.tab.window": "Fenster", "settings.tab.profiles": "Setups", "settings.tab.general": "Allgemein", + "settings.tab.branding": "Branding", + "settings.branding.select_label": "Branding-Vorlage:", + "settings.branding.select_tooltip": "Wählen Sie die Branding-Vorlage, die beim Start automatisch geladen werden soll.", + "settings.branding.help_text": "Branding steuert die visuelle Identität der App, zum Beispiel Name und Icons. Es ist klar von den gespeicherten Setups getrennt.", + "settings.branding.restart_note": "Branding-Änderungen werden persistent gespeichert und nach einem Neustart vollständig angewendet.", "settings.web_url.label": "Web-Anwendungs-URL:", "settings.web_url.placeholder": "z.B. http://localhost:8080 oder file:///./webapp/index.html", "settings.web_url.open_btn": "\u00d6ffnen", diff --git a/resources/translations/en.json b/resources/translations/en.json index 57798d5..1638741 100644 --- a/resources/translations/en.json +++ b/resources/translations/en.json @@ -86,6 +86,11 @@ "settings.tab.window": "Window", "settings.tab.profiles": "Setups", "settings.tab.general": "General", + "settings.tab.branding": "Branding", + "settings.branding.select_label": "Branding template:", + "settings.branding.select_tooltip": "Choose the branding template that should be loaded automatically on startup.", + "settings.branding.help_text": "Branding controls the visual identity of the app, such as name and icons. It is kept separate from your saved setups.", + "settings.branding.restart_note": "Branding changes are persisted immediately and are fully applied after restarting the app.", "settings.web_url.label": "Web Application URL:", "settings.web_url.placeholder": "e.g., http://localhost:8080 or file:///./webapp/index.html", "settings.web_url.open_btn": "Open", diff --git a/src/webdrop_bridge/config.py b/src/webdrop_bridge/config.py index 12bdeb7..d8a5740 100644 --- a/src/webdrop_bridge/config.py +++ b/src/webdrop_bridge/config.py @@ -18,6 +18,12 @@ DEFAULT_UPDATE_BASE_URL = "https://git.him-tools.de" DEFAULT_UPDATE_REPO = "HIM-public/webdrop-bridge" DEFAULT_UPDATE_CHANNEL = "stable" DEFAULT_UPDATE_MANIFEST_NAME = "release-manifest.json" +DEFAULT_ACTIVE_BRANDING_ID = "default" +DEFAULT_APP_ICON_PATH = "resources/icons/app.ico" +DEFAULT_TOOLBAR_ICON_HOME = "resources/icons/home.ico" +DEFAULT_TOOLBAR_ICON_RELOAD = "resources/icons/reload.ico" +DEFAULT_TOOLBAR_ICON_OPEN = "resources/icons/open.ico" +DEFAULT_TOOLBAR_ICON_OPENWITH = "resources/icons/openwith.ico" class ConfigurationError(Exception): @@ -96,6 +102,14 @@ class Config: enable_logging: bool = True enable_checkout: bool = False language: str = "auto" + active_branding_id: str = DEFAULT_ACTIVE_BRANDING_ID + branding_display_name: str = "Default" + logo_path: str = "" + app_icon_path: str = DEFAULT_APP_ICON_PATH + toolbar_icon_home: str = DEFAULT_TOOLBAR_ICON_HOME + toolbar_icon_reload: str = DEFAULT_TOOLBAR_ICON_RELOAD + toolbar_icon_open: str = DEFAULT_TOOLBAR_ICON_OPEN + toolbar_icon_openwith: str = DEFAULT_TOOLBAR_ICON_OPENWITH brand_id: str = DEFAULT_BRAND_ID config_dir_name: str = DEFAULT_CONFIG_DIR_NAME update_base_url: str = DEFAULT_UPDATE_BASE_URL @@ -179,7 +193,7 @@ class Config: # No window title specified, use default window_title = f"{app_name} v{__version__}" - return cls( + config = cls( app_name=app_name, app_version=__version__, log_level=data.get("log_level", "INFO").upper(), @@ -197,6 +211,13 @@ class Config: enable_logging=data.get("enable_logging", True), enable_checkout=data.get("enable_checkout", False), language=data.get("language", "auto"), + active_branding_id=data.get("active_branding_id", DEFAULT_ACTIVE_BRANDING_ID), + logo_path=data.get("logo_path", ""), + app_icon_path=data.get("app_icon_path", DEFAULT_APP_ICON_PATH), + toolbar_icon_home=data.get("toolbar_icon_home", DEFAULT_TOOLBAR_ICON_HOME), + toolbar_icon_reload=data.get("toolbar_icon_reload", DEFAULT_TOOLBAR_ICON_RELOAD), + toolbar_icon_open=data.get("toolbar_icon_open", DEFAULT_TOOLBAR_ICON_OPEN), + toolbar_icon_openwith=data.get("toolbar_icon_openwith", DEFAULT_TOOLBAR_ICON_OPENWITH), brand_id=brand_id, config_dir_name=config_dir_name, update_base_url=data.get("update_base_url", DEFAULT_UPDATE_BASE_URL), @@ -204,6 +225,7 @@ class Config: update_channel=data.get("update_channel", DEFAULT_UPDATE_CHANNEL), update_manifest_name=data.get("update_manifest_name", DEFAULT_UPDATE_MANIFEST_NAME), ) + return cls._apply_runtime_branding(config) @classmethod def from_env(cls, env_file: str | None = None) -> "Config": @@ -246,6 +268,12 @@ class Config: enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true" enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true" language = os.getenv("LANGUAGE", "auto") + active_branding_id = os.getenv("BRAND_TEMPLATE", DEFAULT_ACTIVE_BRANDING_ID) + app_icon_path = os.getenv("APP_ICON_PATH", DEFAULT_APP_ICON_PATH) + toolbar_icon_home = os.getenv("TOOLBAR_ICON_HOME", DEFAULT_TOOLBAR_ICON_HOME) + toolbar_icon_reload = os.getenv("TOOLBAR_ICON_RELOAD", DEFAULT_TOOLBAR_ICON_RELOAD) + toolbar_icon_open = os.getenv("TOOLBAR_ICON_OPEN", DEFAULT_TOOLBAR_ICON_OPEN) + toolbar_icon_openwith = os.getenv("TOOLBAR_ICON_OPENWITH", DEFAULT_TOOLBAR_ICON_OPENWITH) update_base_url = os.getenv("UPDATE_BASE_URL", DEFAULT_UPDATE_BASE_URL) update_repo = os.getenv("UPDATE_REPO", DEFAULT_UPDATE_REPO) update_channel = os.getenv("UPDATE_CHANNEL", DEFAULT_UPDATE_CHANNEL) @@ -328,7 +356,7 @@ class Config: f"Invalid URL_MAPPINGS: {url_mappings_str}. Error: {e}" ) from e - return cls( + config = cls( app_name=app_name, app_version=app_version, log_level=log_level, @@ -343,6 +371,12 @@ class Config: enable_logging=enable_logging, enable_checkout=enable_checkout, language=language, + active_branding_id=active_branding_id, + app_icon_path=app_icon_path, + toolbar_icon_home=toolbar_icon_home, + toolbar_icon_reload=toolbar_icon_reload, + toolbar_icon_open=toolbar_icon_open, + toolbar_icon_openwith=toolbar_icon_openwith, brand_id=brand_id, config_dir_name=config_dir_name, update_base_url=update_base_url, @@ -350,6 +384,7 @@ class Config: update_channel=update_channel, update_manifest_name=update_manifest_name, ) + return cls._apply_runtime_branding(config) def to_file(self, config_path: Path) -> None: """Save configuration to JSON file. @@ -378,6 +413,7 @@ class Config: "enable_logging": self.enable_logging, "enable_checkout": self.enable_checkout, "language": self.language, + "active_branding_id": self.active_branding_id, "brand_id": self.brand_id, "config_dir_name": self.config_dir_name, "update_base_url": self.update_base_url, @@ -390,6 +426,17 @@ class Config: with open(config_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) + @staticmethod + def _apply_runtime_branding(config: "Config") -> "Config": + """Apply the persisted runtime branding template to cosmetic fields.""" + try: + from webdrop_bridge.core.branding_manager import BrandingManager + + BrandingManager().apply_to_config(config) + except Exception as e: + logger.warning(f"Failed to apply runtime branding: {e}") + return config + @staticmethod def load_bootstrap_env(env_file: str | None = None) -> Path | None: """Load a bootstrap .env before configuration path lookup. diff --git a/src/webdrop_bridge/core/branding_manager.py b/src/webdrop_bridge/core/branding_manager.py new file mode 100644 index 0000000..6fad03e --- /dev/null +++ b/src/webdrop_bridge/core/branding_manager.py @@ -0,0 +1,240 @@ +"""Runtime branding template management for the shared application.""" + +from __future__ import annotations + +import json +import logging +import os +import platform +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +from webdrop_bridge.config import DEFAULT_CONFIG_DIR_NAME, Config, ConfigurationError + +logger = logging.getLogger(__name__) + +DEFAULT_BRANDING_TEMPLATE_ID = "default" + + +@dataclass(frozen=True) +class BrandingTemplate: + """Serializable runtime branding template.""" + + template_id: str + display_name: str + app_name: str + window_title: str = "" + logo_path: str = "" + app_icon_path_windows: str = "resources/icons/app.ico" + app_icon_path_macos: str = "resources/icons/app.icns" + toolbar_icon_home: str = "resources/icons/home.ico" + toolbar_icon_reload: str = "resources/icons/reload.ico" + toolbar_icon_open: str = "resources/icons/open.ico" + toolbar_icon_openwith: str = "resources/icons/openwith.ico" + accent_color: str = "#667eea" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "BrandingTemplate": + """Create a template from a JSON-compatible dictionary.""" + template_id = str(data.get("template_id") or data.get("id") or "").strip() + display_name = str(data.get("display_name") or template_id or "").strip() + app_name = str(data.get("app_name") or display_name or "").strip() + + if not template_id: + raise ConfigurationError("Branding template requires a template_id") + if not display_name: + raise ConfigurationError("Branding template requires a display_name") + if not app_name: + raise ConfigurationError("Branding template requires an app_name") + + return cls( + template_id=template_id, + display_name=display_name, + app_name=app_name, + window_title=str(data.get("window_title", "")), + logo_path=str(data.get("logo_path", "")), + app_icon_path_windows=str(data.get("app_icon_path_windows", "resources/icons/app.ico")), + app_icon_path_macos=str(data.get("app_icon_path_macos", "resources/icons/app.icns")), + toolbar_icon_home=str(data.get("toolbar_icon_home", "resources/icons/home.ico")), + toolbar_icon_reload=str(data.get("toolbar_icon_reload", "resources/icons/reload.ico")), + toolbar_icon_open=str(data.get("toolbar_icon_open", "resources/icons/open.ico")), + toolbar_icon_openwith=str( + data.get("toolbar_icon_openwith", "resources/icons/openwith.ico") + ), + accent_color=str(data.get("accent_color", "#667eea")), + ) + + def to_dict(self) -> dict[str, Any]: + """Convert the template to a JSON-compatible dictionary.""" + return asdict(self) + + def get_app_icon_path(self) -> str: + """Return the best app icon path for the current platform.""" + if platform.system() == "Darwin": + return self.app_icon_path_macos + return self.app_icon_path_windows + + +BUILTIN_BRANDING_TEMPLATES: dict[str, BrandingTemplate] = { + "default": BrandingTemplate( + template_id="default", + display_name="Default", + app_name="WebDrop Bridge", + window_title="", + accent_color="#667eea", + ), + "agravity": BrandingTemplate( + template_id="agravity", + display_name="Agravity", + app_name="Agravity Bridge", + window_title="", + accent_color="#2d7d6e", + ), +} + + +class BrandingManager: + """Manage runtime branding templates independently from saved setups.""" + + def __init__(self, base_dir: Path | None = None) -> None: + env_dir = os.getenv("WEBDROP_BRANDING_DIR") + resolved_base = Path(env_dir).resolve() if env_dir and base_dir is None else base_dir + self.base_dir = resolved_base or self._default_base_dir() + self.templates_dir = self.base_dir / "templates" + self.active_branding_path = self.base_dir / "active_branding.json" + self.ensure_builtin_templates() + + @staticmethod + def _default_base_dir() -> Path: + """Return the shared branding storage directory.""" + return Config.get_default_config_path(DEFAULT_CONFIG_DIR_NAME).parent / "branding" + + def ensure_builtin_templates(self) -> None: + """Ensure built-in templates exist on disk for discovery and later editing.""" + self.templates_dir.mkdir(parents=True, exist_ok=True) + + for template in BUILTIN_BRANDING_TEMPLATES.values(): + template_path = self.templates_dir / f"{template.template_id}.json" + if not template_path.exists(): + template_path.write_text(json.dumps(template.to_dict(), indent=2), encoding="utf-8") + + def list_templates(self) -> list[BrandingTemplate]: + """List all available templates with built-ins guaranteed.""" + self.ensure_builtin_templates() + + templates: dict[str, BrandingTemplate] = {} + for template_path in self.templates_dir.glob("*.json"): + try: + data = json.loads(template_path.read_text(encoding="utf-8")) + template = BrandingTemplate.from_dict(data) + templates[template.template_id] = template + except (OSError, json.JSONDecodeError, ConfigurationError) as exc: + logger.warning("Skipping invalid branding template %s: %s", template_path, exc) + + for template_id, template in BUILTIN_BRANDING_TEMPLATES.items(): + templates.setdefault(template_id, template) + + ordered_templates = sorted( + templates.values(), + key=lambda template: ( + template.template_id != DEFAULT_BRANDING_TEMPLATE_ID, + template.display_name.lower(), + ), + ) + return ordered_templates + + def has_template(self, template_id: str) -> bool: + """Return whether a template with the given id exists.""" + return any(template.template_id == template_id for template in self.list_templates()) + + def load_template(self, template_id: str) -> BrandingTemplate: + """Load a template by id, falling back to the default template if missing.""" + for template in self.list_templates(): + if template.template_id == template_id: + return template + + logger.warning("Branding template '%s' not found. Falling back to default.", template_id) + return BUILTIN_BRANDING_TEMPLATES[DEFAULT_BRANDING_TEMPLATE_ID] + + def save_template(self, template: BrandingTemplate) -> Path: + """Save or update a branding template on disk.""" + if not template.template_id: + raise ConfigurationError("Branding template requires a template_id") + + self.templates_dir.mkdir(parents=True, exist_ok=True) + template_path = self.templates_dir / f"{template.template_id}.json" + template_path.write_text(json.dumps(template.to_dict(), indent=2), encoding="utf-8") + logger.info("Branding template saved: %s", template.template_id) + return template_path + + def delete_template(self, template_id: str) -> None: + """Delete a user template while protecting built-ins.""" + if template_id in BUILTIN_BRANDING_TEMPLATES: + raise ConfigurationError(f"Cannot delete built-in branding template: {template_id}") + + template_path = self.templates_dir / f"{template_id}.json" + if not template_path.exists(): + raise ConfigurationError(f"Branding template not found: {template_id}") + + template_path.unlink() + logger.info("Branding template deleted: %s", template_id) + + def get_active_branding_id(self) -> str: + """Return the persisted active branding selection.""" + if not self.active_branding_path.exists(): + return DEFAULT_BRANDING_TEMPLATE_ID + + try: + data = json.loads(self.active_branding_path.read_text(encoding="utf-8")) + template_id = str(data.get("active_branding_id", DEFAULT_BRANDING_TEMPLATE_ID)) + return template_id if self.has_template(template_id) else DEFAULT_BRANDING_TEMPLATE_ID + except (OSError, json.JSONDecodeError): + return DEFAULT_BRANDING_TEMPLATE_ID + + def set_active_branding_id(self, template_id: str) -> None: + """Persist the active branding selection.""" + if not self.has_template(template_id): + raise ConfigurationError(f"Branding template not found: {template_id}") + + self.base_dir.mkdir(parents=True, exist_ok=True) + self.active_branding_path.write_text( + json.dumps({"active_branding_id": template_id}, indent=2), + encoding="utf-8", + ) + logger.info("Active branding set to %s", template_id) + + def apply_to_config(self, config: Config) -> BrandingTemplate: + """Apply the active branding template to cosmetic config fields only.""" + requested_id = (getattr(config, "active_branding_id", "") or "").strip() + if not requested_id or requested_id == DEFAULT_BRANDING_TEMPLATE_ID: + requested_id = self.get_active_branding_id() + + template = self.load_template(requested_id) + is_non_default_branding = template.template_id != DEFAULT_BRANDING_TEMPLATE_ID + default_app_name = BUILTIN_BRANDING_TEMPLATES[DEFAULT_BRANDING_TEMPLATE_ID].app_name + + config.active_branding_id = template.template_id + config.branding_display_name = template.display_name + + if is_non_default_branding or not config.app_name or config.app_name == default_app_name: + config.app_name = template.app_name + + if ( + is_non_default_branding + or not config.window_title + or config.window_title.startswith(f"{default_app_name} v") + or config.window_title.startswith(f"{config.app_name} v") + ): + config.window_title = ( + template.window_title or f"{config.app_name} v{config.app_version}" + ) + + config.logo_path = template.logo_path + config.app_icon_path = template.get_app_icon_path() + config.toolbar_icon_home = template.toolbar_icon_home + config.toolbar_icon_reload = template.toolbar_icon_reload + config.toolbar_icon_open = template.toolbar_icon_open + config.toolbar_icon_openwith = template.toolbar_icon_openwith + + return template diff --git a/src/webdrop_bridge/ui/main_window.py b/src/webdrop_bridge/ui/main_window.py index f75d872..74ecb97 100644 --- a/src/webdrop_bridge/ui/main_window.py +++ b/src/webdrop_bridge/ui/main_window.py @@ -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( diff --git a/src/webdrop_bridge/ui/settings_dialog.py b/src/webdrop_bridge/ui/settings_dialog.py index 1da3eaa..c83d65a 100644 --- a/src/webdrop_bridge/ui/settings_dialog.py +++ b/src/webdrop_bridge/ui/settings_dialog.py @@ -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": [ diff --git a/tests/unit/test_branding_manager.py b/tests/unit/test_branding_manager.py new file mode 100644 index 0000000..891ce93 --- /dev/null +++ b/tests/unit/test_branding_manager.py @@ -0,0 +1,74 @@ +"""Tests for runtime branding template management.""" + +from webdrop_bridge.config import Config +from webdrop_bridge.core.branding_manager import BrandingManager + + +def test_builtin_brandings_are_available(tmp_path): + """Built-in default and Agravity templates should always be available.""" + manager = BrandingManager(base_dir=tmp_path) + + brandings = manager.list_templates() + template_ids = [template.template_id for template in brandings] + + assert "default" in template_ids + assert "agravity" in template_ids + + +def test_active_branding_persists_across_manager_instances(tmp_path): + """Selected active branding should persist on disk.""" + manager = BrandingManager(base_dir=tmp_path) + manager.set_active_branding_id("agravity") + + reloaded_manager = BrandingManager(base_dir=tmp_path) + + assert reloaded_manager.get_active_branding_id() == "agravity" + + +def test_apply_branding_updates_cosmetic_fields_only(tmp_path): + """Applying a branding template should not overwrite setup-specific values.""" + allowed_root = tmp_path / "allowed" + allowed_root.mkdir() + + config = Config( + app_name="WebDrop Bridge", + app_version="1.0.0", + log_level="INFO", + log_file=None, + allowed_roots=[allowed_root], + allowed_urls=["example.com"], + webapp_url="http://localhost:8080", + window_width=1024, + window_height=768, + enable_logging=True, + active_branding_id="agravity", + ) + + manager = BrandingManager(base_dir=tmp_path) + manager.apply_to_config(config) + + assert config.active_branding_id == "agravity" + assert config.app_name == "Agravity Bridge" + assert config.webapp_url == "http://localhost:8080" + assert config.allowed_roots == [allowed_root] + assert config.toolbar_icon_home.endswith("home.ico") + + +def test_config_from_env_uses_persisted_active_branding(tmp_path, monkeypatch): + """Config loading should apply the persisted active branding automatically.""" + branding_dir = tmp_path / "branding-state" + manager = BrandingManager(base_dir=branding_dir) + manager.set_active_branding_id("agravity") + + monkeypatch.setenv("WEBDROP_BRANDING_DIR", str(branding_dir)) + + root = tmp_path / "root" + root.mkdir() + env_file = tmp_path / ".env" + env_file.write_text(f"ALLOWED_ROOTS={root}\n", encoding="utf-8") + + config = Config.from_env(str(env_file)) + + assert config.active_branding_id == "agravity" + assert config.app_name == "Agravity Bridge" + assert config.get_config_path().name == "config.json" diff --git a/tests/unit/test_settings_dialog.py b/tests/unit/test_settings_dialog.py index 1ff1773..b63796e 100644 --- a/tests/unit/test_settings_dialog.py +++ b/tests/unit/test_settings_dialog.py @@ -1,11 +1,10 @@ """Tests for settings dialog.""" from pathlib import Path -from unittest.mock import MagicMock, patch import pytest -from webdrop_bridge.config import Config, ConfigurationError +from webdrop_bridge.config import Config from webdrop_bridge.ui.settings_dialog import SettingsDialog @@ -44,7 +43,7 @@ class TestSettingsDialogInitialization: qtbot.addWidget(dialog) assert dialog.tabs is not None - assert dialog.tabs.count() == 7 # General + previous 6 tabs + assert dialog.tabs.count() == 8 # General + Branding + previous 6 tabs def test_dialog_has_general_tab(self, qtbot, sample_config): """Test General tab exists.""" @@ -53,47 +52,54 @@ class TestSettingsDialogInitialization: assert dialog.tabs.tabText(0) == "General" + def test_dialog_has_branding_tab(self, qtbot, sample_config): + """Test Branding tab exists.""" + dialog = SettingsDialog(sample_config) + qtbot.addWidget(dialog) + + assert dialog.tabs.tabText(1) == "Branding" + def test_dialog_has_web_source_tab(self, qtbot, sample_config): """Test Web Source tab exists.""" dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) - assert dialog.tabs.tabText(1) == "Web Source" + assert dialog.tabs.tabText(2) == "Web Source" def test_dialog_has_paths_tab(self, qtbot, sample_config): """Test Paths tab exists.""" dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) - assert dialog.tabs.tabText(2) == "Paths" + assert dialog.tabs.tabText(3) == "Paths" def test_dialog_has_urls_tab(self, qtbot, sample_config): """Test URLs tab exists.""" dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) - assert dialog.tabs.tabText(3) == "URLs" + assert dialog.tabs.tabText(4) == "URLs" def test_dialog_has_logging_tab(self, qtbot, sample_config): """Test Logging tab exists.""" dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) - assert dialog.tabs.tabText(4) == "Logging" + assert dialog.tabs.tabText(5) == "Logging" def test_dialog_has_window_tab(self, qtbot, sample_config): """Test Window tab exists.""" dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) - assert dialog.tabs.tabText(5) == "Window" + assert dialog.tabs.tabText(6) == "Window" def test_dialog_has_profiles_tab(self, qtbot, sample_config): """Test Setups tab exists with clearer wording.""" dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) - assert dialog.tabs.tabText(6) == "Setups" + assert dialog.tabs.tabText(7) == "Setups" def test_profiles_actions_have_explanatory_tooltips(self, qtbot, sample_config): """Test profile/config actions expose helpful explanations."""