diff --git a/resources/translations/de.json b/resources/translations/de.json index 7be1ed0..5be5e9e 100644 --- a/resources/translations/de.json +++ b/resources/translations/de.json @@ -58,6 +58,10 @@ "dialog.language_changed.msg": "Die Spracheinstellung wurde aktualisiert. Starten Sie jetzt neu, um die ausgew\u00e4hlte Sprache \u00fcberall anzuwenden.", "dialog.language_changed.restart_now": "Jetzt neu starten", "dialog.language_changed.restart_later": "Sp\u00e4ter neu starten", + "dialog.branding_changed.title": "Branding ge\u00e4ndert", + "dialog.branding_changed.msg": "Das aktive Branding wurde geändert. Starten Sie jetzt neu, damit die aktualisierte visuelle Identität überall angewendet wird.", + "dialog.branding_changed.restart_now": "Jetzt neu starten", + "dialog.branding_changed.restart_later": "Sp\u00e4ter neu starten", "dialog.restart_failed.title": "Neustart fehlgeschlagen", "dialog.restart_failed.msg": "Die Anwendung konnte nicht automatisch neu gestartet werden:\n\n{error}\n\nBitte starten Sie manuell neu.", "dialog.update_timeout.title": "Zeitüberschreitung bei der Update-Pr\u00fcfung", @@ -86,6 +90,26 @@ "settings.tab.window": "Fenster", "settings.tab.profiles": "Setups", "settings.tab.general": "Allgemein", + "settings.tab.branding": "Branding", + "settings.branding.select_label": "Branding:", + "settings.branding.select_tooltip": "Wählen Sie das Branding, das beim Start automatisch geladen werden soll.", + "settings.branding.help_text": "Branding steuert Name sowie Logo/Icon der App. Änderungen sind klar von den gespeicherten Setups getrennt.", + "settings.branding.display_name_label": "Name:", + "settings.branding.app_name_label": "Anwendungsname:", + "settings.branding.window_title_label": "Fenstertitel (optional):", + "settings.branding.logo_path_label": "Logo/Icon-Datei (optional):", + "settings.branding.save_as_btn": "Branding speichern", + "settings.branding.export_btn": "Branding exportieren", + "settings.branding.import_btn": "Branding importieren", + "settings.branding.delete_btn": "Branding löschen", + "settings.branding.export_title": "Branding exportieren", + "settings.branding.import_title": "Branding importieren", + "settings.branding.preview_label": "Vorschau:", + "settings.branding.no_icon_selected": "Kein Icon ausgewählt", + "settings.branding.preview_default_name": "Default", + "settings.branding.save_as_title": "Branding speichern", + "settings.branding.save_as_prompt": "Name für das Branding eingeben:", + "settings.branding.restart_note": "Branding-Änderungen werden persistent gespeichert und nach einem Neustart vollständig angewendet.", "settings.web_url.label": "Web-Anwendungs-URL:", "settings.web_url.placeholder": "z.B. http://localhost:8080 oder file:///./webapp/index.html", "settings.web_url.open_btn": "\u00d6ffnen", diff --git a/resources/translations/en.json b/resources/translations/en.json index 57798d5..799c546 100644 --- a/resources/translations/en.json +++ b/resources/translations/en.json @@ -58,6 +58,10 @@ "dialog.language_changed.msg": "The language setting was updated. Restart now to apply the selected language everywhere.", "dialog.language_changed.restart_now": "Restart Now", "dialog.language_changed.restart_later": "Restart Later", + "dialog.branding_changed.title": "Branding Changed", + "dialog.branding_changed.msg": "The active branding was changed. Restart now so the updated visual identity is applied everywhere.", + "dialog.branding_changed.restart_now": "Restart Now", + "dialog.branding_changed.restart_later": "Restart Later", "dialog.restart_failed.title": "Restart Failed", "dialog.restart_failed.msg": "Could not automatically restart the application:\n\n{error}\n\nPlease restart manually.", "dialog.update_timeout.title": "Update Check Timeout", @@ -86,6 +90,26 @@ "settings.tab.window": "Window", "settings.tab.profiles": "Setups", "settings.tab.general": "General", + "settings.tab.branding": "Branding", + "settings.branding.select_label": "Branding:", + "settings.branding.select_tooltip": "Choose the branding that should be loaded automatically on startup.", + "settings.branding.help_text": "Branding controls the app name and logo/icon. It stays clearly separated from your saved setups.", + "settings.branding.display_name_label": "Name:", + "settings.branding.app_name_label": "Application name:", + "settings.branding.window_title_label": "Window title (optional):", + "settings.branding.logo_path_label": "Logo/Icon file (optional):", + "settings.branding.save_as_btn": "Save Branding", + "settings.branding.export_btn": "Export Branding", + "settings.branding.import_btn": "Import Branding", + "settings.branding.delete_btn": "Delete Branding", + "settings.branding.export_title": "Export Branding", + "settings.branding.import_title": "Import Branding", + "settings.branding.preview_label": "Preview:", + "settings.branding.no_icon_selected": "No icon selected", + "settings.branding.preview_default_name": "Default", + "settings.branding.save_as_title": "Save Branding", + "settings.branding.save_as_prompt": "Enter a name for the branding:", + "settings.branding.restart_note": "Branding changes are persisted immediately and are fully applied after restarting the app.", "settings.web_url.label": "Web Application URL:", "settings.web_url.placeholder": "e.g., http://localhost:8080 or file:///./webapp/index.html", "settings.web_url.open_btn": "Open", diff --git a/resources/translations/fr.json b/resources/translations/fr.json index d024e21..9369246 100644 --- a/resources/translations/fr.json +++ b/resources/translations/fr.json @@ -58,6 +58,10 @@ "dialog.language_changed.msg": "Le param\u00e8tre de langue a \u00e9t\u00e9 mis \u00e0 jour. Red\u00e9marrez maintenant pour appliquer la langue s\u00e9lectionn\u00e9e partout.", "dialog.language_changed.restart_now": "Red\u00e9marrer maintenant", "dialog.language_changed.restart_later": "Red\u00e9marrer plus tard", + "dialog.branding_changed.title": "Branding modifié", + "dialog.branding_changed.msg": "Le branding actif a été modifié. Redémarrez maintenant pour appliquer partout l’identité visuelle mise à jour.", + "dialog.branding_changed.restart_now": "Redémarrer maintenant", + "dialog.branding_changed.restart_later": "Redémarrer plus tard", "dialog.restart_failed.title": "\u00c9chec du red\u00e9marrage", "dialog.restart_failed.msg": "Impossible de red\u00e9marrer automatiquement l'application\u00a0:\n\n{error}\n\nVeuillez red\u00e9marrer manuellement.", "dialog.update_timeout.title": "D\u00e9lai de v\u00e9rification des mises \u00e0 jour d\u00e9pass\u00e9", @@ -86,6 +90,26 @@ "settings.tab.window": "Fen\u00eatre", "settings.tab.profiles": "Configs", "settings.tab.general": "G\u00e9n\u00e9ral", + "settings.tab.branding": "Branding", + "settings.branding.select_label": "Branding :", + "settings.branding.select_tooltip": "Choisissez le modèle de branding qui doit être chargé automatiquement au démarrage.", + "settings.branding.help_text": "Le branding contrôle l’identité visuelle de l’application, comme le nom et les icônes. Il reste séparé de vos configurations enregistrées.", + "settings.branding.display_name_label": "Nom d’affichage :", + "settings.branding.app_name_label": "Nom de l’application :", + "settings.branding.window_title_label": "Titre de la fenêtre (facultatif) :", + "settings.branding.logo_path_label": "Chemin du logo (facultatif) :", + "settings.branding.save_as_btn": "Enregistrer le branding", + "settings.branding.export_btn": "Exporter le branding", + "settings.branding.import_btn": "Importer le branding", + "settings.branding.delete_btn": "Supprimer le branding", + "settings.branding.export_title": "Exporter le branding", + "settings.branding.import_title": "Importer le branding", + "settings.branding.preview_label": "Aperçu :", + "settings.branding.no_icon_selected": "Aucune icône sélectionnée", + "settings.branding.preview_default_name": "Default", + "settings.branding.save_as_title": "Enregistrer le branding", + "settings.branding.save_as_prompt": "Entrez un nom pour le branding :", + "settings.branding.restart_note": "Les changements de branding sont enregistrés de façon persistante et seront entièrement appliqués après le redémarrage de l’application.", "settings.web_url.label": "URL de l'application web\u00a0:", "settings.web_url.placeholder": "p.ex. http://localhost:8080 ou file:///./webapp/index.html", "settings.web_url.open_btn": "Ouvrir", diff --git a/resources/translations/it.json b/resources/translations/it.json index 25e026b..0ad9c7d 100644 --- a/resources/translations/it.json +++ b/resources/translations/it.json @@ -58,6 +58,10 @@ "dialog.language_changed.msg": "La lingua è stata aggiornata. Riavvia ora per applicarla ovunque.", "dialog.language_changed.restart_now": "Riavvia ora", "dialog.language_changed.restart_later": "Riavvia più tardi", + "dialog.branding_changed.title": "Branding cambiato", + "dialog.branding_changed.msg": "Il branding attivo è stato modificato. Riavvia ora per applicare ovunque l’identità visiva aggiornata.", + "dialog.branding_changed.restart_now": "Riavvia ora", + "dialog.branding_changed.restart_later": "Riavvia più tardi", "dialog.restart_failed.title": "Riavvio non riuscito", "dialog.restart_failed.msg": "Impossibile riavviare automaticamente l'applicazione:\n\n{error}\n\nRiavvia manualmente.", "dialog.update_timeout.title": "Timeout controllo aggiornamenti", @@ -86,6 +90,26 @@ "settings.tab.window": "Finestra", "settings.tab.profiles": "Config", "settings.tab.general": "Generale", + "settings.tab.branding": "Branding", + "settings.branding.select_label": "Branding:", + "settings.branding.select_tooltip": "Scegli il modello di branding da caricare automaticamente all’avvio.", + "settings.branding.help_text": "Il branding controlla l’identità visiva dell’app, come nome e icone. Rimane separato dalle configurazioni salvate.", + "settings.branding.display_name_label": "Nome visualizzato:", + "settings.branding.app_name_label": "Nome applicazione:", + "settings.branding.window_title_label": "Titolo finestra (opzionale):", + "settings.branding.logo_path_label": "Percorso logo (opzionale):", + "settings.branding.save_as_btn": "Salva branding", + "settings.branding.export_btn": "Esporta branding", + "settings.branding.import_btn": "Importa branding", + "settings.branding.delete_btn": "Elimina branding", + "settings.branding.export_title": "Esporta branding", + "settings.branding.import_title": "Importa branding", + "settings.branding.preview_label": "Anteprima:", + "settings.branding.no_icon_selected": "Nessuna icona selezionata", + "settings.branding.preview_default_name": "Default", + "settings.branding.save_as_title": "Salva branding", + "settings.branding.save_as_prompt": "Inserisci un nome per il branding:", + "settings.branding.restart_note": "Le modifiche al branding vengono salvate in modo persistente e saranno applicate completamente dopo il riavvio dell’applicazione.", "settings.web_url.label": "URL applicazione web:", "settings.web_url.placeholder": "es. http://localhost:8080 o file:///./webapp/index.html", "settings.web_url.open_btn": "Apri", diff --git a/resources/translations/ru.json b/resources/translations/ru.json index a7abdec..da0b804 100644 --- a/resources/translations/ru.json +++ b/resources/translations/ru.json @@ -58,6 +58,10 @@ "dialog.language_changed.msg": "Настройка языка обновлена. Перезапустите сейчас, чтобы применить язык везде.", "dialog.language_changed.restart_now": "Перезапустить сейчас", "dialog.language_changed.restart_later": "Перезапустить позже", + "dialog.branding_changed.title": "Брендинг изменен", + "dialog.branding_changed.msg": "Активный брендинг был изменен. Перезапустите приложение сейчас, чтобы обновленная визуальная идентичность применялась везде.", + "dialog.branding_changed.restart_now": "Перезапустить сейчас", + "dialog.branding_changed.restart_later": "Перезапустить позже", "dialog.restart_failed.title": "Сбой перезапуска", "dialog.restart_failed.msg": "Не удалось автоматически перезапустить приложение:\n\n{error}\n\nПерезапустите вручную.", "dialog.update_timeout.title": "Таймаут проверки обновлений", @@ -86,6 +90,26 @@ "settings.tab.window": "Окно", "settings.tab.profiles": "Наборы", "settings.tab.general": "Общие настройки", + "settings.tab.branding": "Брендинг", + "settings.branding.select_label": "Брендинг:", + "settings.branding.select_tooltip": "Выберите шаблон брендинга, который должен автоматически загружаться при запуске.", + "settings.branding.help_text": "Брендинг управляет визуальной идентичностью приложения, например названием и иконками. Он отделен от сохраненных наборов настроек.", + "settings.branding.display_name_label": "Отображаемое имя:", + "settings.branding.app_name_label": "Имя приложения:", + "settings.branding.window_title_label": "Заголовок окна (необязательно):", + "settings.branding.logo_path_label": "Путь к логотипу (необязательно):", + "settings.branding.save_as_btn": "Сохранить брендинг", + "settings.branding.export_btn": "Экспортировать брендинг", + "settings.branding.import_btn": "Импортировать брендинг", + "settings.branding.delete_btn": "Удалить брендинг", + "settings.branding.export_title": "Экспортировать брендинг", + "settings.branding.import_title": "Импортировать брендинг", + "settings.branding.preview_label": "Предпросмотр:", + "settings.branding.no_icon_selected": "Значок не выбран", + "settings.branding.preview_default_name": "Default", + "settings.branding.save_as_title": "Сохранить брендинг", + "settings.branding.save_as_prompt": "Введите имя для брендинга:", + "settings.branding.restart_note": "Изменения брендинга сохраняются постоянно и будут полностью применены после перезапуска приложения.", "settings.web_url.label": "URL веб-приложения:", "settings.web_url.placeholder": "например, http://localhost:8080 или file:///./webapp/index.html", "settings.web_url.open_btn": "Открыть", diff --git a/resources/translations/zh.json b/resources/translations/zh.json index ea2686b..f3e61fe 100644 --- a/resources/translations/zh.json +++ b/resources/translations/zh.json @@ -58,6 +58,10 @@ "dialog.language_changed.msg": "语言设置已更新。立即重启可在所有界面生效。", "dialog.language_changed.restart_now": "立即重启", "dialog.language_changed.restart_later": "稍后重启", + "dialog.branding_changed.title": "品牌已更改", + "dialog.branding_changed.msg": "当前品牌配置已更改。请立即重启,以便在所有界面应用更新后的视觉标识。", + "dialog.branding_changed.restart_now": "立即重启", + "dialog.branding_changed.restart_later": "稍后重启", "dialog.restart_failed.title": "重启失败", "dialog.restart_failed.msg": "无法自动重启应用:\n\n{error}\n\n请手动重启。", "dialog.update_timeout.title": "更新检查超时", @@ -86,6 +90,26 @@ "settings.tab.window": "窗口", "settings.tab.profiles": "设置", "settings.tab.general": "通用", + "settings.tab.branding": "品牌", + "settings.branding.select_label": "品牌:", + "settings.branding.select_tooltip": "选择应用启动时应自动加载的品牌模板。", + "settings.branding.help_text": "品牌控制应用的视觉标识,例如名称和图标,并与已保存的设置保持分离。", + "settings.branding.display_name_label": "显示名称:", + "settings.branding.app_name_label": "应用名称:", + "settings.branding.window_title_label": "窗口标题(可选):", + "settings.branding.logo_path_label": "Logo 路径(可选):", + "settings.branding.save_as_btn": "保存品牌配置", + "settings.branding.export_btn": "导出品牌配置", + "settings.branding.import_btn": "导入品牌配置", + "settings.branding.delete_btn": "删除品牌配置", + "settings.branding.export_title": "导出品牌配置", + "settings.branding.import_title": "导入品牌配置", + "settings.branding.preview_label": "预览:", + "settings.branding.no_icon_selected": "未选择图标", + "settings.branding.preview_default_name": "Default", + "settings.branding.save_as_title": "保存品牌配置", + "settings.branding.save_as_prompt": "输入品牌名称:", + "settings.branding.restart_note": "品牌更改会被持久保存,并将在应用重启后完整生效。", "settings.web_url.label": "Web 应用 URL:", "settings.web_url.placeholder": "例如: http://localhost:8080 或 file:///./webapp/index.html", "settings.web_url.open_btn": "打开", diff --git a/src/webdrop_bridge/config.py b/src/webdrop_bridge/config.py index 12bdeb7..d8a5740 100644 --- a/src/webdrop_bridge/config.py +++ b/src/webdrop_bridge/config.py @@ -18,6 +18,12 @@ DEFAULT_UPDATE_BASE_URL = "https://git.him-tools.de" DEFAULT_UPDATE_REPO = "HIM-public/webdrop-bridge" DEFAULT_UPDATE_CHANNEL = "stable" DEFAULT_UPDATE_MANIFEST_NAME = "release-manifest.json" +DEFAULT_ACTIVE_BRANDING_ID = "default" +DEFAULT_APP_ICON_PATH = "resources/icons/app.ico" +DEFAULT_TOOLBAR_ICON_HOME = "resources/icons/home.ico" +DEFAULT_TOOLBAR_ICON_RELOAD = "resources/icons/reload.ico" +DEFAULT_TOOLBAR_ICON_OPEN = "resources/icons/open.ico" +DEFAULT_TOOLBAR_ICON_OPENWITH = "resources/icons/openwith.ico" class ConfigurationError(Exception): @@ -96,6 +102,14 @@ class Config: enable_logging: bool = True enable_checkout: bool = False language: str = "auto" + active_branding_id: str = DEFAULT_ACTIVE_BRANDING_ID + branding_display_name: str = "Default" + logo_path: str = "" + app_icon_path: str = DEFAULT_APP_ICON_PATH + toolbar_icon_home: str = DEFAULT_TOOLBAR_ICON_HOME + toolbar_icon_reload: str = DEFAULT_TOOLBAR_ICON_RELOAD + toolbar_icon_open: str = DEFAULT_TOOLBAR_ICON_OPEN + toolbar_icon_openwith: str = DEFAULT_TOOLBAR_ICON_OPENWITH brand_id: str = DEFAULT_BRAND_ID config_dir_name: str = DEFAULT_CONFIG_DIR_NAME update_base_url: str = DEFAULT_UPDATE_BASE_URL @@ -179,7 +193,7 @@ class Config: # No window title specified, use default window_title = f"{app_name} v{__version__}" - return cls( + config = cls( app_name=app_name, app_version=__version__, log_level=data.get("log_level", "INFO").upper(), @@ -197,6 +211,13 @@ class Config: enable_logging=data.get("enable_logging", True), enable_checkout=data.get("enable_checkout", False), language=data.get("language", "auto"), + active_branding_id=data.get("active_branding_id", DEFAULT_ACTIVE_BRANDING_ID), + logo_path=data.get("logo_path", ""), + app_icon_path=data.get("app_icon_path", DEFAULT_APP_ICON_PATH), + toolbar_icon_home=data.get("toolbar_icon_home", DEFAULT_TOOLBAR_ICON_HOME), + toolbar_icon_reload=data.get("toolbar_icon_reload", DEFAULT_TOOLBAR_ICON_RELOAD), + toolbar_icon_open=data.get("toolbar_icon_open", DEFAULT_TOOLBAR_ICON_OPEN), + toolbar_icon_openwith=data.get("toolbar_icon_openwith", DEFAULT_TOOLBAR_ICON_OPENWITH), brand_id=brand_id, config_dir_name=config_dir_name, update_base_url=data.get("update_base_url", DEFAULT_UPDATE_BASE_URL), @@ -204,6 +225,7 @@ class Config: update_channel=data.get("update_channel", DEFAULT_UPDATE_CHANNEL), update_manifest_name=data.get("update_manifest_name", DEFAULT_UPDATE_MANIFEST_NAME), ) + return cls._apply_runtime_branding(config) @classmethod def from_env(cls, env_file: str | None = None) -> "Config": @@ -246,6 +268,12 @@ class Config: enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true" enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true" language = os.getenv("LANGUAGE", "auto") + active_branding_id = os.getenv("BRAND_TEMPLATE", DEFAULT_ACTIVE_BRANDING_ID) + app_icon_path = os.getenv("APP_ICON_PATH", DEFAULT_APP_ICON_PATH) + toolbar_icon_home = os.getenv("TOOLBAR_ICON_HOME", DEFAULT_TOOLBAR_ICON_HOME) + toolbar_icon_reload = os.getenv("TOOLBAR_ICON_RELOAD", DEFAULT_TOOLBAR_ICON_RELOAD) + toolbar_icon_open = os.getenv("TOOLBAR_ICON_OPEN", DEFAULT_TOOLBAR_ICON_OPEN) + toolbar_icon_openwith = os.getenv("TOOLBAR_ICON_OPENWITH", DEFAULT_TOOLBAR_ICON_OPENWITH) update_base_url = os.getenv("UPDATE_BASE_URL", DEFAULT_UPDATE_BASE_URL) update_repo = os.getenv("UPDATE_REPO", DEFAULT_UPDATE_REPO) update_channel = os.getenv("UPDATE_CHANNEL", DEFAULT_UPDATE_CHANNEL) @@ -328,7 +356,7 @@ class Config: f"Invalid URL_MAPPINGS: {url_mappings_str}. Error: {e}" ) from e - return cls( + config = cls( app_name=app_name, app_version=app_version, log_level=log_level, @@ -343,6 +371,12 @@ class Config: enable_logging=enable_logging, enable_checkout=enable_checkout, language=language, + active_branding_id=active_branding_id, + app_icon_path=app_icon_path, + toolbar_icon_home=toolbar_icon_home, + toolbar_icon_reload=toolbar_icon_reload, + toolbar_icon_open=toolbar_icon_open, + toolbar_icon_openwith=toolbar_icon_openwith, brand_id=brand_id, config_dir_name=config_dir_name, update_base_url=update_base_url, @@ -350,6 +384,7 @@ class Config: update_channel=update_channel, update_manifest_name=update_manifest_name, ) + return cls._apply_runtime_branding(config) def to_file(self, config_path: Path) -> None: """Save configuration to JSON file. @@ -378,6 +413,7 @@ class Config: "enable_logging": self.enable_logging, "enable_checkout": self.enable_checkout, "language": self.language, + "active_branding_id": self.active_branding_id, "brand_id": self.brand_id, "config_dir_name": self.config_dir_name, "update_base_url": self.update_base_url, @@ -390,6 +426,17 @@ class Config: with open(config_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) + @staticmethod + def _apply_runtime_branding(config: "Config") -> "Config": + """Apply the persisted runtime branding template to cosmetic fields.""" + try: + from webdrop_bridge.core.branding_manager import BrandingManager + + BrandingManager().apply_to_config(config) + except Exception as e: + logger.warning(f"Failed to apply runtime branding: {e}") + return config + @staticmethod def load_bootstrap_env(env_file: str | None = None) -> Path | None: """Load a bootstrap .env before configuration path lookup. diff --git a/src/webdrop_bridge/core/branding_manager.py b/src/webdrop_bridge/core/branding_manager.py new file mode 100644 index 0000000..ae1726f --- /dev/null +++ b/src/webdrop_bridge/core/branding_manager.py @@ -0,0 +1,406 @@ +"""Runtime branding template management for the shared application.""" + +from __future__ import annotations + +import json +import logging +import os +import platform +import shutil +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" +DEFAULT_LOGO_PATH = "resources/icons/app.png" +SUPPORTED_LOGO_SUFFIXES = {".png", ".jpg", ".jpeg", ".bmp", ".svg", ".ico", ".icns"} + + +@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="", + logo_path=DEFAULT_LOGO_PATH, + accent_color="#667eea", + ), + "agravity": BrandingTemplate( + template_id="agravity", + display_name="Agravity", + app_name="Agravity Bridge", + window_title="", + logo_path=DEFAULT_LOGO_PATH, + 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.assets_dir = self.base_dir / "assets" + 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) + self.assets_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") + if template.template_id in BUILTIN_BRANDING_TEMPLATES: + raise ConfigurationError(f"Cannot overwrite built-in branding: {template.template_id}") + + stored_logo_path = "" + if template.logo_path: + stored_logo_path = self._copy_logo_asset(template.logo_path, template.template_id) + + stored_template = BrandingTemplate( + template_id=template.template_id, + display_name=template.display_name, + app_name=template.app_name, + window_title=template.window_title, + logo_path=stored_logo_path or template.logo_path, + app_icon_path_windows=stored_logo_path or template.app_icon_path_windows, + app_icon_path_macos=stored_logo_path or template.app_icon_path_macos, + toolbar_icon_home=template.toolbar_icon_home, + toolbar_icon_reload=template.toolbar_icon_reload, + toolbar_icon_open=template.toolbar_icon_open, + toolbar_icon_openwith=template.toolbar_icon_openwith, + accent_color=template.accent_color, + ) + + self.templates_dir.mkdir(parents=True, exist_ok=True) + template_path = self.templates_dir / f"{stored_template.template_id}.json" + template_path.write_text(json.dumps(stored_template.to_dict(), indent=2), encoding="utf-8") + logger.info("Branding template saved: %s", stored_template.template_id) + return template_path + + def build_template( + self, + *, + template_id: str, + display_name: str, + app_name: str = "", + window_title: str = "", + logo_path: str = "", + ) -> BrandingTemplate: + """Build a validated branding template from editable UI fields.""" + safe_id = self._slugify(template_id.strip() or display_name) + safe_name = display_name.strip() + logo = logo_path.strip() + resolved_app_name = (app_name or display_name).strip() + + if not safe_id: + raise ConfigurationError("Branding requires a name") + if not safe_name: + raise ConfigurationError("Branding requires a display name") + if logo: + logo_file = self._resolve_asset_path(logo) + if logo_file is None or not logo_file.exists() or not logo_file.is_file(): + raise ConfigurationError(f"Logo file not found: {logo}") + if logo_file.suffix.lower() not in SUPPORTED_LOGO_SUFFIXES: + raise ConfigurationError( + "Unsupported logo format. Use PNG, JPG, BMP, SVG, ICO, or ICNS." + ) + + return BrandingTemplate( + template_id=safe_id, + display_name=safe_name, + app_name=resolved_app_name, + window_title=window_title.strip(), + logo_path=logo, + app_icon_path_windows=logo or "resources/icons/app.ico", + app_icon_path_macos=logo or "resources/icons/app.icns", + ) + + @staticmethod + def _slugify(value: str) -> str: + """Convert a human-readable branding name into a stable id.""" + return "".join(c.lower() if c.isalnum() else "_" for c in value).strip("_") + + @staticmethod + def _resolve_asset_path(configured_path: str) -> Path | None: + """Resolve a branding asset path in dev and packaged layouts.""" + if not configured_path: + return None + + path = Path(configured_path) + candidates = [path] if path.is_absolute() else [Path.cwd() / path] + if not path.is_absolute(): + project_root = Path(__file__).resolve().parents[3] + candidates.append(project_root / path) + + for candidate in candidates: + if candidate.exists(): + return candidate + + return None + + def _copy_logo_asset(self, configured_path: str, template_id: str) -> str: + """Copy a user-selected logo into managed branding storage.""" + resolved_path = self._resolve_asset_path(configured_path) + if resolved_path is None or not resolved_path.exists() or not resolved_path.is_file(): + raise ConfigurationError(f"Logo file not found: {configured_path}") + + self.assets_dir.mkdir(parents=True, exist_ok=True) + target_path = self.assets_dir / f"{template_id}{resolved_path.suffix.lower()}" + if resolved_path.resolve() != target_path.resolve(): + shutil.copy2(resolved_path, target_path) + + return str(target_path) + + def export_template(self, template_id: str, export_path: Path) -> Path: + """Export a branding into a shareable JSON file plus optional logo asset.""" + template = self.load_template(template_id) + export_path.parent.mkdir(parents=True, exist_ok=True) + + export_data = template.to_dict() + if template.logo_path: + resolved_logo = self._resolve_asset_path(template.logo_path) + if resolved_logo and resolved_logo.exists() and resolved_logo.is_file(): + export_logo_path = export_path.parent / resolved_logo.name + if resolved_logo.resolve() != export_logo_path.resolve(): + shutil.copy2(resolved_logo, export_logo_path) + export_data["logo_path"] = export_logo_path.name + export_data["app_icon_path_windows"] = export_logo_path.name + export_data["app_icon_path_macos"] = export_logo_path.name + + export_path.write_text(json.dumps(export_data, indent=2), encoding="utf-8") + logger.info("Branding template exported: %s -> %s", template_id, export_path) + return export_path + + def import_template(self, import_path: Path) -> BrandingTemplate: + """Import a branding from a previously exported JSON file.""" + if not import_path.exists() or not import_path.is_file(): + raise ConfigurationError(f"Branding import file not found: {import_path}") + + try: + data = json.loads(import_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ConfigurationError(f"Invalid branding import file: {import_path}") from exc + + template = BrandingTemplate.from_dict(data) + imported_template_id = template.template_id + if imported_template_id in BUILTIN_BRANDING_TEMPLATES: + imported_template_id = self._slugify(f"{template.display_name}_imported") + + imported_logo_path = "" + if template.logo_path: + candidate_logo = Path(template.logo_path) + if not candidate_logo.is_absolute(): + candidate_logo = import_path.parent / candidate_logo + imported_logo_path = self._copy_logo_asset(str(candidate_logo), imported_template_id) + + imported_template = BrandingTemplate( + template_id=imported_template_id, + display_name=template.display_name, + app_name=template.app_name, + window_title=template.window_title, + logo_path=imported_logo_path, + app_icon_path_windows=imported_logo_path or template.app_icon_path_windows, + app_icon_path_macos=imported_logo_path or template.app_icon_path_macos, + toolbar_icon_home=template.toolbar_icon_home, + toolbar_icon_reload=template.toolbar_icon_reload, + toolbar_icon_open=template.toolbar_icon_open, + toolbar_icon_openwith=template.toolbar_icon_openwith, + accent_color=template.accent_color, + ) + self.save_template(imported_template) + logger.info("Branding template imported: %s", imported_template.template_id) + return imported_template + + 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) + default_app_name = BUILTIN_BRANDING_TEMPLATES[DEFAULT_BRANDING_TEMPLATE_ID].app_name + known_app_names = {known_template.app_name for known_template in self.list_templates()} + known_title_prefixes = {f"{app_name} v" for app_name in known_app_names} + + config.active_branding_id = template.template_id + config.branding_display_name = template.display_name + + if ( + template.template_id != DEFAULT_BRANDING_TEMPLATE_ID + or not config.app_name + or config.app_name in known_app_names + ): + config.app_name = template.app_name or default_app_name + + if ( + template.template_id != DEFAULT_BRANDING_TEMPLATE_ID + or not config.window_title + or any(config.window_title.startswith(prefix) for prefix in known_title_prefixes) + ): + config.window_title = ( + template.window_title or f"{config.app_name} v{config.app_version}" + ) + + config.logo_path = template.logo_path + config.app_icon_path = template.get_app_icon_path() + config.toolbar_icon_home = template.toolbar_icon_home + config.toolbar_icon_reload = template.toolbar_icon_reload + config.toolbar_icon_open = template.toolbar_icon_open + config.toolbar_icon_openwith = template.toolbar_icon_openwith + + return template diff --git a/src/webdrop_bridge/ui/main_window.py b/src/webdrop_bridge/ui/main_window.py index f75d872..a32a09e 100644 --- a/src/webdrop_bridge/ui/main_window.py +++ b/src/webdrop_bridge/ui/main_window.py @@ -427,7 +427,9 @@ class MainWindow(QMainWindow): self._background_threads = [] # Keep references to background threads self._background_workers = {} # Keep references to background workers self._bridge_script_source = "" # Cache combined bridge source for recovery injection - self._bridge_script_re_registered = False # Flag to prevent duplicate re-registration on same load + self._bridge_script_re_registered = ( + False # Flag to prevent duplicate re-registration on same load + ) self._is_page_loading = False # Track if a page load is currently in progress self._pending_reload = False # Coalesce multiple rapid reload requests into one self._load_sequence = 0 # Monotonic counter to ignore stale async recovery callbacks @@ -444,22 +446,13 @@ class MainWindow(QMainWindow): config.window_height, ) - # Set window icon - # Support both development mode and PyInstaller bundle - if hasattr(sys, "_MEIPASS"): - # Running as PyInstaller bundle - icon_path = Path(sys._MEIPASS) / "resources" / "icons" / "app.ico" # type: ignore - else: - # Running in development mode - icon_path = ( - Path(__file__).parent.parent.parent.parent / "resources" / "icons" / "app.ico" - ) - - if icon_path.exists(): + # Set window icon from the active runtime branding + icon_path = self._resolve_toolbar_icon_path(config.app_icon_path) + if icon_path is not None: self.setWindowIcon(QIcon(str(icon_path))) logger.debug(f"Window icon set from {icon_path}") else: - logger.warning(f"Window icon not found at {icon_path}") + logger.warning(f"Window icon not found for configured path: {config.app_icon_path}") # Create web engine view with URL for profile isolation self.web_view = RestrictedWebEngineView( @@ -1189,7 +1182,9 @@ class MainWindow(QMainWindow): # This more reliably opens files with chosen applications. # Use a simple, more direct approach # Get the chosen app via AppleScript, then use open command - get_app_script = '''choose application with title "Select an application to open the file"''' + get_app_script = ( + '''choose application with title "Select an application to open the file"''' + ) try: # Get the chosen application app_result = subprocess.run( @@ -1199,19 +1194,21 @@ class MainWindow(QMainWindow): text=True, timeout=30, ) - + if app_result.returncode != 0: - logger.warning(f"User cancelled app chooser or error occurred: {app_result.stderr}") + logger.warning( + f"User cancelled app chooser or error occurred: {app_result.stderr}" + ) return False - + # Get the application name (strip whitespace) chosen_app = app_result.stdout.strip() if not chosen_app: logger.warning("No application was selected") return False - + logger.info(f"User selected app: {chosen_app}") - + # Now open the file with the chosen app using the 'open' command open_result = subprocess.run( ["open", "-a", chosen_app, normalized_path], @@ -1220,14 +1217,16 @@ class MainWindow(QMainWindow): text=True, timeout=10, ) - + if open_result.returncode == 0: logger.info(f"Opened '{normalized_path}' with '{chosen_app}'") return True else: - logger.warning(f"Failed to open file with '{chosen_app}': {open_result.stderr}") + logger.warning( + f"Failed to open file with '{chosen_app}': {open_result.stderr}" + ) return False - + except subprocess.TimeoutExpired: logger.warning("App chooser timed out") return False @@ -1393,7 +1392,7 @@ class MainWindow(QMainWindow): Re-registers the bridge script to ensure it will be injected on reload, page navigation, or any load event. - + Uses a flag to prevent duplicate re-registrations if loadStarted fires multiple times. """ self._is_page_loading = True @@ -1412,7 +1411,7 @@ class MainWindow(QMainWindow): Checks if the bridge script was successfully injected, with automatic recovery for page reloads and redirects. - + Resets the re-registration flag for the next load cycle. Args: @@ -1433,9 +1432,11 @@ class MainWindow(QMainWindow): logger.warning("Page failed to load") return - def _verify_bridge_loaded(stage: str, attempt: int = 1, sequence: int = finished_sequence) -> None: + def _verify_bridge_loaded( + stage: str, attempt: int = 1, sequence: int = finished_sequence + ) -> None: """Check if bridge marker exists and optionally recover script injection. - + Implements multi-attempt recovery strategy: - initial: First check after page load (50ms delay) - recovery_N: Recovery attempts with progressive delays @@ -1485,9 +1486,7 @@ class MainWindow(QMainWindow): delay = int(100 * (1.5 ** (attempt - 1))) QTimer.singleShot( delay, - lambda: _verify_bridge_loaded( - "recovery", attempt + 1, sequence - ), + lambda: _verify_bridge_loaded("recovery", attempt + 1, sequence), ) self.web_view.page().runJavaScript(self._bridge_script_source, after_retry) @@ -1507,11 +1506,15 @@ class MainWindow(QMainWindow): ) self._re_register_bridge_script() - self.web_view.page().runJavaScript(self._bridge_script_source, after_re_register) + self.web_view.page().runJavaScript( + self._bridge_script_source, after_re_register + ) return # All recovery attempts exhausted - logger.error("❌ WebDrop Bridge script failed to inject after all recovery attempts!") + logger.error( + "❌ WebDrop Bridge script failed to inject after all recovery attempts!" + ) logger.error(" Drag-and-drop functionality is DISABLED") logger.debug(f" Stage: {stage}, Attempt: {attempt}") @@ -1543,21 +1546,21 @@ class MainWindow(QMainWindow): def _ensure_bridge_script_exists(self, verbose: bool = False) -> None: """Ensure bridge script exists in QWebEngineScript collection (idempotent). - + Checks if the script already exists. If not, adds it. Never removes/re-adds to avoid race conditions with Qt's injection mechanism. - + This is safer than removing+re-adding because: - Avoids concurrent access conflicts with Qt's internal injection - Prevents missing injections during rapid reloads - Guarantees script is available without timing gaps - + Args: verbose: If True, use debug logging; otherwise use minimal logging """ try: scripts = self.web_view.page().scripts() - + # Check if script already exists already_exists = False for script in scripts.toList(): # type: ignore @@ -1566,7 +1569,7 @@ class MainWindow(QMainWindow): if verbose: logger.debug("Bridge script already exists in page().scripts()") break - + # If script doesn't exist, add it if not already_exists and self._bridge_script_source: new_script = QWebEngineScript() @@ -1582,16 +1585,18 @@ class MainWindow(QMainWindow): new_script.setSourceCode(self._bridge_script_source) scripts.insert(new_script) - logger.debug(f"✓ Added bridge script to collection ({len(self._bridge_script_source)} chars)") + logger.debug( + f"✓ Added bridge script to collection ({len(self._bridge_script_source)} chars)" + ) except Exception as e: logger.error(f"Failed to ensure bridge script exists: {e}") def _re_register_bridge_script(self, verbose: bool = False) -> None: """Force re-registration of bridge script in QWebEngineScript collection. - + Removes old script and re-adds it to ensure it's injected on next page load. This is a fallback for recovery mechanics when normal injection fails. - + Args: verbose: If True, use debug logging; otherwise use minimal logging """ @@ -1622,7 +1627,9 @@ class MainWindow(QMainWindow): scripts.insert(new_script) if verbose or removed: - logger.debug(f"✓ Re-registered webdrop-bridge script ({len(self._bridge_script_source)} chars)") + logger.debug( + f"✓ Re-registered webdrop-bridge script ({len(self._bridge_script_source)} chars)" + ) except Exception as e: logger.error(f"Failed to re-register bridge script: {e}") @@ -1649,9 +1656,7 @@ class MainWindow(QMainWindow): toolbar.addSeparator() # Home button - home_icon_path = self._resolve_toolbar_icon_path( - os.getenv("TOOLBAR_ICON_HOME", "resources/icons/home.ico") - ) + home_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_home) home_icon = ( QIcon(str(home_icon_path)) if home_icon_path is not None @@ -1663,9 +1668,7 @@ class MainWindow(QMainWindow): # Refresh button refresh_action = toolbar.addAction("") - reload_icon_path = self._resolve_toolbar_icon_path( - os.getenv("TOOLBAR_ICON_RELOAD", "resources/icons/reload.ico") - ) + reload_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_reload) if reload_icon_path is not None: refresh_action.setIcon(QIcon(str(reload_icon_path))) else: @@ -1677,9 +1680,7 @@ class MainWindow(QMainWindow): # Open-with-default-app drop zone (right of Reload) self._open_drop_zone = OpenDropZone() - open_icon_path = self._resolve_toolbar_icon_path( - os.getenv("TOOLBAR_ICON_OPEN", "resources/icons/open.ico") - ) + open_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_open) if open_icon_path is not None: self._open_drop_zone.set_icon(QIcon(str(open_icon_path))) self._open_drop_zone.file_opened.connect(self._on_file_opened_via_drop) @@ -1690,9 +1691,7 @@ class MainWindow(QMainWindow): # Open-with chooser drop zone (right of Open-with-default-app) self._open_with_drop_zone = OpenWithDropZone() - open_with_icon_path = self._resolve_toolbar_icon_path( - os.getenv("TOOLBAR_ICON_OPENWITH", "resources/icons/openwith.ico") - ) + open_with_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_openwith) if open_with_icon_path is not None: self._open_with_drop_zone.set_icon(QIcon(str(open_with_icon_path))) self._open_with_drop_zone.file_open_with_requested.connect( @@ -1959,6 +1958,7 @@ class MainWindow(QMainWindow): # Store current URL before opening dialog old_webapp_url = self.config.webapp_url old_language = self.config.language + old_branding_id = self.config.active_branding_id # Show dialog dialog = SettingsDialog(self.config, self) @@ -1967,6 +1967,9 @@ class MainWindow(QMainWindow): # Check if webapp URL changed new_webapp_url = self.config.webapp_url language_changed = old_language != self.config.language + branding_changed = old_branding_id != self.config.active_branding_id + restart_prompt_shown = False + if old_webapp_url != new_webapp_url: logger.info(f"Web application URL changed: {old_webapp_url} → {new_webapp_url}") @@ -1976,6 +1979,7 @@ class MainWindow(QMainWindow): if domain_changed: logger.warning("Domain has changed - recommending restart") self._handle_domain_change_restart() + restart_prompt_shown = True else: logger.info("Path changed but domain is same - reloading...") # Clear cache and navigate to home asynchronously @@ -1983,7 +1987,16 @@ class MainWindow(QMainWindow): self.web_view.clear_cache_and_cookies() QTimer.singleShot(100, self._navigate_home) - if language_changed: + if not restart_prompt_shown and branding_changed: + logger.info( + "Branding changed: %s → %s", + old_branding_id, + self.config.active_branding_id, + ) + self._handle_branding_change_restart() + restart_prompt_shown = True + + if not restart_prompt_shown and language_changed: logger.info(f"Language changed: {old_language} → {self.config.language}") self._handle_language_change_restart() @@ -2047,21 +2060,42 @@ class MainWindow(QMainWindow): self.web_view.clear_cache_and_cookies() self._navigate_home() + def _handle_branding_change_restart(self) -> None: + """Handle branding change by prompting for an optional restart.""" + self._show_restart_prompt( + title_key="dialog.branding_changed.title", + message_key="dialog.branding_changed.msg", + restart_now_key="dialog.branding_changed.restart_now", + restart_later_key="dialog.branding_changed.restart_later", + ) + def _handle_language_change_restart(self) -> None: """Handle language change by prompting for an optional restart.""" + self._show_restart_prompt( + title_key="dialog.language_changed.title", + message_key="dialog.language_changed.msg", + restart_now_key="dialog.language_changed.restart_now", + restart_later_key="dialog.language_changed.restart_later", + ) + + def _show_restart_prompt( + self, + *, + title_key: str, + message_key: str, + restart_now_key: str, + restart_later_key: str, + ) -> None: + """Show a restart prompt for settings that require a full restart.""" from PySide6.QtWidgets import QMessageBox msg = QMessageBox(self) - msg.setWindowTitle(tr("dialog.language_changed.title")) + msg.setWindowTitle(tr(title_key)) msg.setIcon(QMessageBox.Icon.Information) - msg.setText(tr("dialog.language_changed.msg")) + msg.setText(tr(message_key)) - restart_now_btn = msg.addButton( - tr("dialog.language_changed.restart_now"), QMessageBox.ButtonRole.AcceptRole - ) - msg.addButton( - tr("dialog.language_changed.restart_later"), QMessageBox.ButtonRole.RejectRole - ) + restart_now_btn = msg.addButton(tr(restart_now_key), QMessageBox.ButtonRole.AcceptRole) + msg.addButton(tr(restart_later_key), QMessageBox.ButtonRole.RejectRole) msg.exec() diff --git a/src/webdrop_bridge/ui/settings_dialog.py b/src/webdrop_bridge/ui/settings_dialog.py index 1da3eaa..9830cff 100644 --- a/src/webdrop_bridge/ui/settings_dialog.py +++ b/src/webdrop_bridge/ui/settings_dialog.py @@ -4,12 +4,14 @@ import logging from pathlib import Path from typing import Any, Dict, Optional +from PySide6.QtGui import QIcon, QPixmap from PySide6.QtWidgets import ( QComboBox, QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, + QInputDialog, QLabel, QLineEdit, QListWidget, @@ -23,6 +25,7 @@ from PySide6.QtWidgets import ( ) from webdrop_bridge.config import Config, ConfigurationError +from webdrop_bridge.core.branding_manager import BrandingManager from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator from webdrop_bridge.utils.i18n import get_available_languages, tr from webdrop_bridge.utils.logging import reconfigure_logging @@ -42,6 +45,7 @@ class SettingsDialog(QDialog): """ super().__init__(parent) self.config = config + self.branding_manager = BrandingManager() self.profile_manager = ConfigProfile(config.config_dir_name) self.setWindowTitle(tr("settings.title")) self.setGeometry(100, 100, 600, 500) @@ -54,6 +58,7 @@ class SettingsDialog(QDialog): self.tabs = QTabWidget() self.tabs.addTab(self._create_general_tab(), tr("settings.tab.general")) + self.tabs.addTab(self._create_branding_tab(), tr("settings.tab.branding")) self.tabs.addTab(self._create_web_source_tab(), tr("settings.tab.web_source")) self.tabs.addTab(self._create_paths_tab(), tr("settings.tab.paths")) self.tabs.addTab(self._create_urls_tab(), tr("settings.tab.urls")) @@ -83,6 +88,14 @@ class SettingsDialog(QDialog): for m in config_data["url_mappings"] ] + selected_branding_id = config_data.get( + "active_branding_id", self.config.active_branding_id + ) + old_branding_id = self.config.active_branding_id + self.branding_manager.set_active_branding_id(selected_branding_id) + self.config.active_branding_id = selected_branding_id + self.branding_manager.apply_to_config(self.config) + old_log_level = self.config.log_level self.config.language = config_data["language"] self.config.log_level = config_data["log_level"] @@ -102,6 +115,12 @@ class SettingsDialog(QDialog): logger.info(f"Configuration saved to {config_path}") logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})") logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}") + if old_branding_id != self.config.active_branding_id: + logger.info( + " Active branding changed: %s -> %s", + old_branding_id, + self.config.active_branding_id, + ) if old_log_level != self.config.log_level: reconfigure_logging( @@ -151,6 +170,273 @@ class SettingsDialog(QDialog): widget.setLayout(layout) return widget + def _create_branding_tab(self) -> QWidget: + """Create runtime branding tab.""" + widget = QWidget() + layout = QVBoxLayout() + + label = QLabel(tr("settings.branding.select_label")) + label.setToolTip(tr("settings.branding.select_tooltip")) + layout.addWidget(label) + + help_label = QLabel(tr("settings.branding.help_text")) + help_label.setWordWrap(True) + help_label.setStyleSheet("color: gray; font-size: 11px;") + layout.addWidget(help_label) + + self.branding_combo = QComboBox() + self.branding_combo.setToolTip(tr("settings.branding.select_tooltip")) + self._refresh_branding_combo() + self.branding_combo.currentIndexChanged.connect(self._on_branding_selection_changed) + layout.addWidget(self.branding_combo) + + self.branding_display_name_input = QLineEdit() + self.branding_display_name_input.setPlaceholderText( + tr("settings.branding.display_name_label") + ) + self.branding_display_name_input.textChanged.connect(self._update_branding_preview) + layout.addWidget(QLabel(tr("settings.branding.display_name_label"))) + layout.addWidget(self.branding_display_name_input) + + self.branding_app_name_input = QLineEdit() + self.branding_app_name_input.setPlaceholderText(tr("settings.branding.app_name_label")) + self.branding_app_name_input.textChanged.connect(self._update_branding_preview) + layout.addWidget(QLabel(tr("settings.branding.app_name_label"))) + layout.addWidget(self.branding_app_name_input) + + self.branding_window_title_input = QLineEdit() + self.branding_window_title_input.setPlaceholderText( + tr("settings.branding.window_title_label") + ) + self.branding_window_title_input.textChanged.connect(self._update_branding_preview) + layout.addWidget(QLabel(tr("settings.branding.window_title_label"))) + layout.addWidget(self.branding_window_title_input) + + layout.addWidget(QLabel(tr("settings.branding.logo_path_label"))) + logo_layout = QHBoxLayout() + self.branding_logo_path_input = QLineEdit() + self.branding_logo_path_input.setPlaceholderText(tr("settings.branding.logo_path_label")) + self.branding_logo_path_input.textChanged.connect(self._update_branding_preview) + logo_layout.addWidget(self.branding_logo_path_input) + + self.browse_branding_logo_btn = QPushButton(tr("settings.log_file.browse_btn")) + self.browse_branding_logo_btn.clicked.connect(self._browse_branding_logo) + logo_layout.addWidget(self.browse_branding_logo_btn) + layout.addLayout(logo_layout) + + layout.addWidget(QLabel(tr("settings.branding.preview_label"))) + self.branding_preview_name_label = QLabel() + self.branding_preview_name_label.setStyleSheet("font-weight: bold;") + layout.addWidget(self.branding_preview_name_label) + + self.branding_preview_title_label = QLabel() + self.branding_preview_title_label.setStyleSheet("color: gray;") + layout.addWidget(self.branding_preview_title_label) + + self.branding_preview_icon_label = QLabel(tr("settings.branding.no_icon_selected")) + self.branding_preview_icon_label.setFixedSize(72, 72) + self.branding_preview_icon_label.setStyleSheet( + "border: 1px solid #ccc; padding: 4px; background: #fafafa;" + ) + layout.addWidget(self.branding_preview_icon_label) + + branding_button_layout = QHBoxLayout() + self.save_branding_as_btn = QPushButton(tr("settings.branding.save_as_btn")) + self.save_branding_as_btn.clicked.connect(self._save_branding_as) + branding_button_layout.addWidget(self.save_branding_as_btn) + + self.export_branding_btn = QPushButton(tr("settings.branding.export_btn")) + self.export_branding_btn.clicked.connect(self._export_branding) + branding_button_layout.addWidget(self.export_branding_btn) + + self.import_branding_btn = QPushButton(tr("settings.branding.import_btn")) + self.import_branding_btn.clicked.connect(self._import_branding) + branding_button_layout.addWidget(self.import_branding_btn) + + self.delete_branding_btn = QPushButton(tr("settings.branding.delete_btn")) + self.delete_branding_btn.clicked.connect(self._delete_branding) + branding_button_layout.addWidget(self.delete_branding_btn) + layout.addLayout(branding_button_layout) + + self._load_branding_into_editor(self.branding_combo.currentData() or "default") + + 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 _refresh_branding_combo(self, selected_template_id: Optional[str] = None) -> None: + """Refresh the branding template selector.""" + current = selected_template_id or self.config.active_branding_id or "default" + self.branding_combo.blockSignals(True) + self.branding_combo.clear() + for template in self.branding_manager.list_templates(): + self.branding_combo.addItem(template.display_name, template.template_id) + + idx = self.branding_combo.findData(current) + if idx < 0: + idx = self.branding_combo.findData("default") + if idx >= 0: + self.branding_combo.setCurrentIndex(idx) + self.branding_combo.blockSignals(False) + + def _load_branding_into_editor(self, template_id: str) -> None: + """Load the selected branding into the editable fields.""" + template = self.branding_manager.load_template(template_id) + self.branding_display_name_input.setText(template.display_name) + self.branding_app_name_input.setText(template.app_name) + self.branding_window_title_input.setText( + template.window_title or f"{template.app_name} v{self.config.app_version}" + ) + self.branding_logo_path_input.setText(template.logo_path or template.get_app_icon_path()) + self._update_branding_preview() + + def _resolve_branding_preview_path(self, configured_path: str) -> Optional[Path]: + """Resolve a branding preview path in both dev and packaged layouts.""" + if not configured_path: + return None + + path = Path(configured_path) + candidates = [path] if path.is_absolute() else [Path.cwd() / path] + if not path.is_absolute(): + project_root = Path(__file__).resolve().parents[3] + candidates.append(project_root / path) + + for candidate in candidates: + if candidate.exists() and candidate.is_file(): + return candidate + + return None + + def _update_branding_preview(self) -> None: + """Refresh the small branding preview for name and icon.""" + display_name = self.branding_display_name_input.text().strip() or tr( + "settings.branding.preview_default_name" + ) + self.branding_preview_name_label.setText(display_name) + + effective_title = self.branding_window_title_input.text().strip() or ( + self.branding_app_name_input.text().strip() or display_name + ) + self.branding_preview_title_label.setText(effective_title) + + logo_path = self.branding_logo_path_input.text().strip() + resolved_logo_path = self._resolve_branding_preview_path(logo_path) + if resolved_logo_path: + pixmap = QPixmap(str(resolved_logo_path)) + if pixmap.isNull(): + icon = QIcon(str(resolved_logo_path)) + pixmap = icon.pixmap(64, 64) + + if not pixmap.isNull(): + self.branding_preview_icon_label.setPixmap(pixmap.scaled(64, 64)) + self.branding_preview_icon_label.setText("") + return + + self.branding_preview_icon_label.setPixmap(QPixmap()) + self.branding_preview_icon_label.setText(tr("settings.branding.no_icon_selected")) + + def _on_branding_selection_changed(self) -> None: + """Update editable branding fields when a different template is selected.""" + template_id = self.branding_combo.currentData() + if template_id: + self._load_branding_into_editor(template_id) + + def _browse_branding_logo(self) -> None: + """Select an external logo or icon file for the current branding.""" + file_path, _ = QFileDialog.getOpenFileName( + self, + tr("settings.branding.logo_path_label"), + str(Path.home()), + "Image Files (*.png *.jpg *.jpeg *.svg *.ico *.icns *.bmp);;All Files (*)", + ) + if file_path: + self.branding_logo_path_input.setText(file_path) + + def _save_branding_as(self) -> None: + """Save the edited branding as a new reusable branding entry.""" + branding_name, ok = QInputDialog.getText( + self, + tr("settings.branding.save_as_title"), + tr("settings.branding.save_as_prompt"), + text=self.branding_display_name_input.text().strip(), + ) + + if not ok or not branding_name: + return + + try: + display_name = self.branding_display_name_input.text().strip() or branding_name + app_name = self.branding_app_name_input.text().strip() or display_name + window_title = self.branding_window_title_input.text().strip() + template = self.branding_manager.build_template( + template_id=branding_name, + display_name=display_name, + app_name=app_name, + window_title=window_title, + logo_path=self.branding_logo_path_input.text(), + ) + self.branding_manager.save_template(template) + self._refresh_branding_combo(template.template_id) + self._load_branding_into_editor(template.template_id) + except ConfigurationError as e: + self._show_error(f"Failed to save branding: {e}") + + def _export_branding(self) -> None: + """Export the selected branding so it can be shared with other users.""" + template_id = self.branding_combo.currentData() + if not template_id: + return + + file_path, _ = QFileDialog.getSaveFileName( + self, + tr("settings.branding.export_title"), + str(Path.home() / f"{template_id}.json"), + "JSON Files (*.json);;All Files (*)", + ) + if not file_path: + return + + try: + self.branding_manager.export_template(template_id, Path(file_path)) + except ConfigurationError as e: + self._show_error(f"Failed to export branding: {e}") + + def _import_branding(self) -> None: + """Import a branding package from another user.""" + file_path, _ = QFileDialog.getOpenFileName( + self, + tr("settings.branding.import_title"), + str(Path.home()), + "JSON Files (*.json);;All Files (*)", + ) + if not file_path: + return + + try: + template = self.branding_manager.import_template(Path(file_path)) + self._refresh_branding_combo(template.template_id) + self._load_branding_into_editor(template.template_id) + except ConfigurationError as e: + self._show_error(f"Failed to import branding: {e}") + + def _delete_branding(self) -> None: + """Delete the currently selected custom branding.""" + template_id = self.branding_combo.currentData() + if not template_id: + return + + try: + self.branding_manager.delete_template(template_id) + self._refresh_branding_combo("default") + self._load_branding_into_editor("default") + except ConfigurationError as e: + self._show_error(f"Failed to delete branding: {e}") + def _create_web_source_tab(self) -> QWidget: """Create web source configuration tab.""" widget = QWidget() @@ -623,6 +909,7 @@ class SettingsDialog(QDialog): "app_name": self.config.app_name, "app_version": self.config.app_version, "language": self.language_combo.currentData(), + "active_branding_id": self.branding_combo.currentData(), "log_level": self.log_level_combo.currentText(), "log_file": self.log_file_input.text() or None, "allowed_roots": [ diff --git a/tests/unit/test_branding_manager.py b/tests/unit/test_branding_manager.py new file mode 100644 index 0000000..60997d4 --- /dev/null +++ b/tests/unit/test_branding_manager.py @@ -0,0 +1,172 @@ +"""Tests for runtime branding template management.""" + +from pathlib import Path + +import pytest + +from webdrop_bridge.config import Config, ConfigurationError +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" + + +def test_switching_back_to_default_restores_default_branding(tmp_path): + """Switching from a custom branding back to default should restore the default name.""" + manager = BrandingManager(base_dir=tmp_path) + config = Config( + app_name="WebDrop Bridge", + app_version="1.0.0", + log_level="INFO", + log_file=None, + allowed_roots=[], + allowed_urls=[], + webapp_url="http://localhost:8080", + enable_logging=True, + active_branding_id="agravity", + ) + + manager.apply_to_config(config) + assert config.app_name == "Agravity Bridge" + + config.active_branding_id = "default" + manager.apply_to_config(config) + + assert config.app_name == "WebDrop Bridge" + assert config.branding_display_name == "Default" + + +def test_delete_custom_branding_removes_it(tmp_path): + """Custom brandings should be removable while built-ins stay protected.""" + manager = BrandingManager(base_dir=tmp_path) + template = manager.build_template(template_id="Customer B", display_name="Customer B") + manager.save_template(template) + + assert manager.has_template("customer_b") + + manager.delete_template("customer_b") + + assert not manager.has_template("customer_b") + + +def test_build_template_preserves_app_and_window_titles(tmp_path): + """Custom brandings should keep their editable app and window title values.""" + manager = BrandingManager(base_dir=tmp_path) + + template = manager.build_template( + template_id="Customer C", + display_name="Customer C", + app_name="Customer Bridge", + window_title="Customer Bridge Desktop", + ) + + assert template.app_name == "Customer Bridge" + assert template.window_title == "Customer Bridge Desktop" + + +def test_invalid_logo_file_is_rejected(tmp_path): + """Non-existent logo files should not be accepted for saved brandings.""" + manager = BrandingManager(base_dir=tmp_path) + + with pytest.raises(ConfigurationError): + manager.build_template( + template_id="customer_c", + display_name="Customer C", + logo_path=str(tmp_path / "missing-logo.png"), + ) + + +def test_exported_branding_can_be_imported_for_another_user(tmp_path): + """Exported brandings should be shareable and importable by another user.""" + source_logo = tmp_path / "shared-logo.png" + source_logo.write_bytes(b"fake-png-data") + + source_manager = BrandingManager(base_dir=tmp_path / "source") + template = source_manager.build_template( + template_id="Customer D", + display_name="Customer D", + app_name="Customer Bridge", + window_title="Customer Window", + logo_path=str(source_logo), + ) + source_manager.save_template(template) + + export_path = tmp_path / "export" / "customer_d.json" + source_manager.export_template("customer_d", export_path) + + target_manager = BrandingManager(base_dir=tmp_path / "target") + imported = target_manager.import_template(export_path) + + assert imported.template_id == "customer_d" + assert imported.display_name == "Customer D" + assert imported.app_name == "Customer Bridge" + assert imported.window_title == "Customer Window" + assert Path(imported.logo_path).exists() + assert target_manager.has_template("customer_d") diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 09e4d6d..065caee 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -9,11 +9,14 @@ from webdrop_bridge.config import Config, ConfigurationError @pytest.fixture(autouse=True) -def clear_env(): +def clear_env(tmp_path): """Clear environment variables before each test to avoid persistence.""" # Save current env saved_env = os.environ.copy() + # Isolate runtime branding state from the developer machine + os.environ["WEBDROP_BRANDING_DIR"] = str(tmp_path / "branding") + # Clear relevant variables for key in list(os.environ.keys()): if key.startswith( diff --git a/tests/unit/test_main_window.py b/tests/unit/test_main_window.py index be89ab6..1fa7d66 100644 --- a/tests/unit/test_main_window.py +++ b/tests/unit/test_main_window.py @@ -82,6 +82,25 @@ class TestMainWindowInitialization: assert window.drag_interceptor is not None +class TestSettingsRestartBehavior: + """Test restart prompts for settings changes that require a restart.""" + + def test_branding_change_prompts_restart(self, qtbot, sample_config): + """Changing the active branding should trigger the restart flow.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + with patch.object(window, "_handle_branding_change_restart") as mock_restart: + with patch("webdrop_bridge.ui.settings_dialog.SettingsDialog") as mock_dialog_cls: + mock_dialog = mock_dialog_cls.return_value + mock_dialog.exec.side_effect = lambda: setattr( + window.config, "active_branding_id", "agravity" + ) + window._show_settings_dialog() + + mock_restart.assert_called_once() + + class TestMainWindowDragIntegration: """Test drag-and-drop integration.""" @@ -207,15 +226,15 @@ class TestMainWindowOpenWith: test_file.write_text("test") call_count = [0] # Use list to make it mutable in nested function - + class _AppChooseResult: returncode = 0 stdout = "TextEdit" # Simulated chosen app name - + class _OpenResult: returncode = 0 stdout = "" - + def mock_run(*args, **kwargs): """Mock subprocess.run with two different behaviors per call.""" call_count[0] += 1 @@ -227,8 +246,7 @@ class TestMainWindowOpenWith: return _OpenResult() else: raise AssertionError(f"Unexpected call #{call_count[0]} to subprocess.run") - - + with patch("webdrop_bridge.ui.main_window.sys.platform", "darwin"): with patch("webdrop_bridge.ui.main_window.subprocess.run", side_effect=mock_run): assert window._open_with_app_chooser(str(test_file)) is True diff --git a/tests/unit/test_settings_dialog.py b/tests/unit/test_settings_dialog.py index 1ff1773..b05239a 100644 --- a/tests/unit/test_settings_dialog.py +++ b/tests/unit/test_settings_dialog.py @@ -1,11 +1,11 @@ """Tests for settings dialog.""" from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest -from webdrop_bridge.config import Config, ConfigurationError +from webdrop_bridge.config import Config from webdrop_bridge.ui.settings_dialog import SettingsDialog @@ -44,7 +44,7 @@ class TestSettingsDialogInitialization: qtbot.addWidget(dialog) assert dialog.tabs is not None - assert dialog.tabs.count() == 7 # General + previous 6 tabs + assert dialog.tabs.count() == 8 # General + Branding + previous 6 tabs def test_dialog_has_general_tab(self, qtbot, sample_config): """Test General tab exists.""" @@ -53,47 +53,54 @@ class TestSettingsDialogInitialization: assert dialog.tabs.tabText(0) == "General" + def test_dialog_has_branding_tab(self, qtbot, sample_config): + """Test Branding tab exists.""" + dialog = SettingsDialog(sample_config) + qtbot.addWidget(dialog) + + assert dialog.tabs.tabText(1) == "Branding" + def test_dialog_has_web_source_tab(self, qtbot, sample_config): """Test Web Source tab exists.""" dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) - assert dialog.tabs.tabText(1) == "Web Source" + assert dialog.tabs.tabText(2) == "Web Source" def test_dialog_has_paths_tab(self, qtbot, sample_config): """Test Paths tab exists.""" dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) - assert dialog.tabs.tabText(2) == "Paths" + assert dialog.tabs.tabText(3) == "Paths" def test_dialog_has_urls_tab(self, qtbot, sample_config): """Test URLs tab exists.""" dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) - assert dialog.tabs.tabText(3) == "URLs" + assert dialog.tabs.tabText(4) == "URLs" def test_dialog_has_logging_tab(self, qtbot, sample_config): """Test Logging tab exists.""" dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) - assert dialog.tabs.tabText(4) == "Logging" + assert dialog.tabs.tabText(5) == "Logging" def test_dialog_has_window_tab(self, qtbot, sample_config): """Test Window tab exists.""" dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) - assert dialog.tabs.tabText(5) == "Window" + assert dialog.tabs.tabText(6) == "Window" def test_dialog_has_profiles_tab(self, qtbot, sample_config): """Test Setups tab exists with clearer wording.""" dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) - assert dialog.tabs.tabText(6) == "Setups" + assert dialog.tabs.tabText(7) == "Setups" def test_profiles_actions_have_explanatory_tooltips(self, qtbot, sample_config): """Test profile/config actions expose helpful explanations.""" @@ -104,6 +111,46 @@ class TestSettingsDialogInitialization: assert "backup" in dialog.export_btn.toolTip().lower() assert "json" in dialog.import_btn.toolTip().lower() + def test_branding_editor_fields_are_initialized( + self, qtbot, sample_config, monkeypatch, tmp_path + ): + """Test branding tab exposes editable fields for the selected template.""" + monkeypatch.setenv("WEBDROP_BRANDING_DIR", str(tmp_path / "branding")) + dialog = SettingsDialog(sample_config) + qtbot.addWidget(dialog) + + assert dialog.branding_display_name_input.text() == "Default" + assert dialog.branding_app_name_input.text() == "WebDrop Bridge" + assert "WebDrop Bridge" in dialog.branding_window_title_input.text() + assert dialog.branding_logo_path_input is not None + assert dialog.browse_branding_logo_btn is not None + assert dialog.branding_preview_name_label.text() == "Default" + assert dialog.branding_preview_icon_label.pixmap() is not None + assert not dialog.branding_preview_icon_label.pixmap().isNull() + assert dialog.export_branding_btn is not None + assert dialog.import_branding_btn is not None + assert dialog.delete_branding_btn is not None + + def test_save_branding_as_creates_custom_template( + self, qtbot, sample_config, monkeypatch, tmp_path + ): + """Test edited branding can be saved as a new reusable template.""" + monkeypatch.setenv("WEBDROP_BRANDING_DIR", str(tmp_path / "branding")) + dialog = SettingsDialog(sample_config) + qtbot.addWidget(dialog) + + logo_path = tmp_path / "customer-logo.png" + logo_path.write_bytes(b"fake-png-data") + + dialog.branding_display_name_input.setText("Customer A") + dialog.branding_logo_path_input.setText(str(logo_path)) + + with patch("PySide6.QtWidgets.QInputDialog.getText", return_value=("Customer A", True)): + dialog._save_branding_as() + + assert dialog.branding_manager.has_template("customer_a") + assert dialog.branding_combo.findData("customer_a") >= 0 + class TestPathsTab: """Test Paths configuration tab."""