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

@ -86,6 +86,11 @@
"settings.tab.window": "Fenster", "settings.tab.window": "Fenster",
"settings.tab.profiles": "Setups", "settings.tab.profiles": "Setups",
"settings.tab.general": "Allgemein", "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.label": "Web-Anwendungs-URL:",
"settings.web_url.placeholder": "z.B. http://localhost:8080 oder file:///./webapp/index.html", "settings.web_url.placeholder": "z.B. http://localhost:8080 oder file:///./webapp/index.html",
"settings.web_url.open_btn": "\u00d6ffnen", "settings.web_url.open_btn": "\u00d6ffnen",

View file

@ -86,6 +86,11 @@
"settings.tab.window": "Window", "settings.tab.window": "Window",
"settings.tab.profiles": "Setups", "settings.tab.profiles": "Setups",
"settings.tab.general": "General", "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.label": "Web Application URL:",
"settings.web_url.placeholder": "e.g., http://localhost:8080 or file:///./webapp/index.html", "settings.web_url.placeholder": "e.g., http://localhost:8080 or file:///./webapp/index.html",
"settings.web_url.open_btn": "Open", "settings.web_url.open_btn": "Open",

View file

@ -18,6 +18,12 @@ DEFAULT_UPDATE_BASE_URL = "https://git.him-tools.de"
DEFAULT_UPDATE_REPO = "HIM-public/webdrop-bridge" DEFAULT_UPDATE_REPO = "HIM-public/webdrop-bridge"
DEFAULT_UPDATE_CHANNEL = "stable" DEFAULT_UPDATE_CHANNEL = "stable"
DEFAULT_UPDATE_MANIFEST_NAME = "release-manifest.json" 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): class ConfigurationError(Exception):
@ -96,6 +102,14 @@ class Config:
enable_logging: bool = True enable_logging: bool = True
enable_checkout: bool = False enable_checkout: bool = False
language: str = "auto" 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 brand_id: str = DEFAULT_BRAND_ID
config_dir_name: str = DEFAULT_CONFIG_DIR_NAME config_dir_name: str = DEFAULT_CONFIG_DIR_NAME
update_base_url: str = DEFAULT_UPDATE_BASE_URL update_base_url: str = DEFAULT_UPDATE_BASE_URL
@ -179,7 +193,7 @@ class Config:
# No window title specified, use default # No window title specified, use default
window_title = f"{app_name} v{__version__}" window_title = f"{app_name} v{__version__}"
return cls( config = cls(
app_name=app_name, app_name=app_name,
app_version=__version__, app_version=__version__,
log_level=data.get("log_level", "INFO").upper(), log_level=data.get("log_level", "INFO").upper(),
@ -197,6 +211,13 @@ class Config:
enable_logging=data.get("enable_logging", True), enable_logging=data.get("enable_logging", True),
enable_checkout=data.get("enable_checkout", False), enable_checkout=data.get("enable_checkout", False),
language=data.get("language", "auto"), 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, brand_id=brand_id,
config_dir_name=config_dir_name, config_dir_name=config_dir_name,
update_base_url=data.get("update_base_url", DEFAULT_UPDATE_BASE_URL), 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_channel=data.get("update_channel", DEFAULT_UPDATE_CHANNEL),
update_manifest_name=data.get("update_manifest_name", DEFAULT_UPDATE_MANIFEST_NAME), update_manifest_name=data.get("update_manifest_name", DEFAULT_UPDATE_MANIFEST_NAME),
) )
return cls._apply_runtime_branding(config)
@classmethod @classmethod
def from_env(cls, env_file: str | None = None) -> "Config": 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_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true" enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true"
language = os.getenv("LANGUAGE", "auto") 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_base_url = os.getenv("UPDATE_BASE_URL", DEFAULT_UPDATE_BASE_URL)
update_repo = os.getenv("UPDATE_REPO", DEFAULT_UPDATE_REPO) update_repo = os.getenv("UPDATE_REPO", DEFAULT_UPDATE_REPO)
update_channel = os.getenv("UPDATE_CHANNEL", DEFAULT_UPDATE_CHANNEL) update_channel = os.getenv("UPDATE_CHANNEL", DEFAULT_UPDATE_CHANNEL)
@ -328,7 +356,7 @@ class Config:
f"Invalid URL_MAPPINGS: {url_mappings_str}. Error: {e}" f"Invalid URL_MAPPINGS: {url_mappings_str}. Error: {e}"
) from e ) from e
return cls( config = cls(
app_name=app_name, app_name=app_name,
app_version=app_version, app_version=app_version,
log_level=log_level, log_level=log_level,
@ -343,6 +371,12 @@ class Config:
enable_logging=enable_logging, enable_logging=enable_logging,
enable_checkout=enable_checkout, enable_checkout=enable_checkout,
language=language, 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, brand_id=brand_id,
config_dir_name=config_dir_name, config_dir_name=config_dir_name,
update_base_url=update_base_url, update_base_url=update_base_url,
@ -350,6 +384,7 @@ class Config:
update_channel=update_channel, update_channel=update_channel,
update_manifest_name=update_manifest_name, update_manifest_name=update_manifest_name,
) )
return cls._apply_runtime_branding(config)
def to_file(self, config_path: Path) -> None: def to_file(self, config_path: Path) -> None:
"""Save configuration to JSON file. """Save configuration to JSON file.
@ -378,6 +413,7 @@ class Config:
"enable_logging": self.enable_logging, "enable_logging": self.enable_logging,
"enable_checkout": self.enable_checkout, "enable_checkout": self.enable_checkout,
"language": self.language, "language": self.language,
"active_branding_id": self.active_branding_id,
"brand_id": self.brand_id, "brand_id": self.brand_id,
"config_dir_name": self.config_dir_name, "config_dir_name": self.config_dir_name,
"update_base_url": self.update_base_url, "update_base_url": self.update_base_url,
@ -390,6 +426,17 @@ class Config:
with open(config_path, "w", encoding="utf-8") as f: with open(config_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2) 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 @staticmethod
def load_bootstrap_env(env_file: str | None = None) -> Path | None: def load_bootstrap_env(env_file: str | None = None) -> Path | None:
"""Load a bootstrap .env before configuration path lookup. """Load a bootstrap .env before configuration path lookup.

View file

@ -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

View file

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

View file

@ -23,6 +23,7 @@ from PySide6.QtWidgets import (
) )
from webdrop_bridge.config import Config, ConfigurationError 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.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator
from webdrop_bridge.utils.i18n import get_available_languages, tr from webdrop_bridge.utils.i18n import get_available_languages, tr
from webdrop_bridge.utils.logging import reconfigure_logging from webdrop_bridge.utils.logging import reconfigure_logging
@ -42,6 +43,7 @@ class SettingsDialog(QDialog):
""" """
super().__init__(parent) super().__init__(parent)
self.config = config self.config = config
self.branding_manager = BrandingManager()
self.profile_manager = ConfigProfile(config.config_dir_name) self.profile_manager = ConfigProfile(config.config_dir_name)
self.setWindowTitle(tr("settings.title")) self.setWindowTitle(tr("settings.title"))
self.setGeometry(100, 100, 600, 500) self.setGeometry(100, 100, 600, 500)
@ -54,6 +56,7 @@ class SettingsDialog(QDialog):
self.tabs = QTabWidget() self.tabs = QTabWidget()
self.tabs.addTab(self._create_general_tab(), tr("settings.tab.general")) 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_web_source_tab(), tr("settings.tab.web_source"))
self.tabs.addTab(self._create_paths_tab(), tr("settings.tab.paths")) self.tabs.addTab(self._create_paths_tab(), tr("settings.tab.paths"))
self.tabs.addTab(self._create_urls_tab(), tr("settings.tab.urls")) 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"] 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 old_log_level = self.config.log_level
self.config.language = config_data["language"] self.config.language = config_data["language"]
self.config.log_level = config_data["log_level"] 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"Configuration saved to {config_path}")
logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})") 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}") 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: if old_log_level != self.config.log_level:
reconfigure_logging( reconfigure_logging(
@ -151,6 +168,41 @@ class SettingsDialog(QDialog):
widget.setLayout(layout) widget.setLayout(layout)
return widget 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: def _create_web_source_tab(self) -> QWidget:
"""Create web source configuration tab.""" """Create web source configuration tab."""
widget = QWidget() widget = QWidget()
@ -623,6 +675,7 @@ class SettingsDialog(QDialog):
"app_name": self.config.app_name, "app_name": self.config.app_name,
"app_version": self.config.app_version, "app_version": self.config.app_version,
"language": self.language_combo.currentData(), "language": self.language_combo.currentData(),
"active_branding_id": self.branding_combo.currentData(),
"log_level": self.log_level_combo.currentText(), "log_level": self.log_level_combo.currentText(),
"log_file": self.log_file_input.text() or None, "log_file": self.log_file_input.text() or None,
"allowed_roots": [ "allowed_roots": [

View file

@ -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"

View file

@ -1,11 +1,10 @@
"""Tests for settings dialog.""" """Tests for settings dialog."""
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest import pytest
from webdrop_bridge.config import Config, ConfigurationError from webdrop_bridge.config import Config
from webdrop_bridge.ui.settings_dialog import SettingsDialog from webdrop_bridge.ui.settings_dialog import SettingsDialog
@ -44,7 +43,7 @@ class TestSettingsDialogInitialization:
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs is not None 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): def test_dialog_has_general_tab(self, qtbot, sample_config):
"""Test General tab exists.""" """Test General tab exists."""
@ -53,47 +52,54 @@ class TestSettingsDialogInitialization:
assert dialog.tabs.tabText(0) == "General" 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): def test_dialog_has_web_source_tab(self, qtbot, sample_config):
"""Test Web Source tab exists.""" """Test Web Source tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) 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): def test_dialog_has_paths_tab(self, qtbot, sample_config):
"""Test Paths tab exists.""" """Test Paths tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) 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): def test_dialog_has_urls_tab(self, qtbot, sample_config):
"""Test URLs tab exists.""" """Test URLs tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) 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): def test_dialog_has_logging_tab(self, qtbot, sample_config):
"""Test Logging tab exists.""" """Test Logging tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) 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): def test_dialog_has_window_tab(self, qtbot, sample_config):
"""Test Window tab exists.""" """Test Window tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) 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): def test_dialog_has_profiles_tab(self, qtbot, sample_config):
"""Test Setups tab exists with clearer wording.""" """Test Setups tab exists with clearer wording."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) 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): def test_profiles_actions_have_explanatory_tooltips(self, qtbot, sample_config):
"""Test profile/config actions expose helpful explanations.""" """Test profile/config actions expose helpful explanations."""