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
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
240
src/webdrop_bridge/core/branding_manager.py
Normal file
240
src/webdrop_bridge/core/branding_manager.py
Normal 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
|
||||||
|
|
@ -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(
|
||||||
|
|
@ -1201,7 +1196,9 @@ class MainWindow(QMainWindow):
|
||||||
)
|
)
|
||||||
|
|
||||||
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)
|
||||||
|
|
@ -1225,7 +1222,9 @@ class MainWindow(QMainWindow):
|
||||||
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:
|
||||||
|
|
@ -1433,7 +1432,9 @@ 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:
|
||||||
|
|
@ -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}")
|
||||||
|
|
||||||
|
|
@ -1582,7 +1585,9 @@ 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}")
|
||||||
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
||||||
|
|
|
||||||
74
tests/unit/test_branding_manager.py
Normal file
74
tests/unit/test_branding_manager.py
Normal 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"
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue