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
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue