Compare commits

...

7 commits

Author SHA1 Message Date
55f2ddf4b1 feat: Add branding import/export functionality and enhance settings dialog with new fields
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-04-15 16:26:38 +02:00
b826bd9b20 feat: Add delete and preview functionality for branding in settings dialog and update translations 2026-04-15 15:15:56 +02:00
e1dbc2ee84 feat: Update branding terminology and improve settings dialog for logo management 2026-04-15 14:27:56 +02:00
e52c09857f feat: Enhance branding management with editable fields and save functionality in settings dialog 2026-04-15 13:58:36 +02:00
fe341163e8 feat: Add branding change prompts and settings translations for multiple languages 2026-04-15 12:15:35 +02:00
2ecd299f31 feat: Add branding change prompts and corresponding translations for restart notifications 2026-04-15 11:49:09 +02:00
ca7105a6bc feat: Implement runtime branding management and add branding settings to UI 2026-04-15 11:01:49 +02:00
14 changed files with 1237 additions and 79 deletions

View file

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

View file

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

View file

@ -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 lidentité 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 lidentité visuelle de lapplication, comme le nom et les icônes. Il reste séparé de vos configurations enregistrées.",
"settings.branding.display_name_label": "Nom daffichage :",
"settings.branding.app_name_label": "Nom de lapplication :",
"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 lapplication.",
"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",

View file

@ -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 lidentità 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 allavvio.",
"settings.branding.help_text": "Il branding controlla lidentità visiva dellapp, 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 dellapplicazione.",
"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",

View file

@ -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": "Открыть",

View file

@ -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": "打开",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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