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.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_now": "Jetzt neu starten",
"dialog.language_changed.restart_later": "Sp\u00e4ter 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.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.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", "dialog.update_timeout.title": "Zeitüberschreitung bei der Update-Pr\u00fcfung",
@ -86,6 +90,26 @@
"settings.tab.window": "Fenster", "settings.tab.window": "Fenster",
"settings.tab.profiles": "Setups", "settings.tab.profiles": "Setups",
"settings.tab.general": "Allgemein", "settings.tab.general": "Allgemein",
"settings.tab.branding": "Branding",
"settings.branding.select_label": "Branding:",
"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.label": "Web-Anwendungs-URL:",
"settings.web_url.placeholder": "z.B. http://localhost:8080 oder file:///./webapp/index.html", "settings.web_url.placeholder": "z.B. http://localhost:8080 oder file:///./webapp/index.html",
"settings.web_url.open_btn": "\u00d6ffnen", "settings.web_url.open_btn": "\u00d6ffnen",

View file

@ -58,6 +58,10 @@
"dialog.language_changed.msg": "The language setting was updated. Restart now to apply the selected language everywhere.", "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_now": "Restart Now",
"dialog.language_changed.restart_later": "Restart Later", "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.title": "Restart Failed",
"dialog.restart_failed.msg": "Could not automatically restart the application:\n\n{error}\n\nPlease restart manually.", "dialog.restart_failed.msg": "Could not automatically restart the application:\n\n{error}\n\nPlease restart manually.",
"dialog.update_timeout.title": "Update Check Timeout", "dialog.update_timeout.title": "Update Check Timeout",
@ -86,6 +90,26 @@
"settings.tab.window": "Window", "settings.tab.window": "Window",
"settings.tab.profiles": "Setups", "settings.tab.profiles": "Setups",
"settings.tab.general": "General", "settings.tab.general": "General",
"settings.tab.branding": "Branding",
"settings.branding.select_label": "Branding:",
"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.label": "Web Application URL:",
"settings.web_url.placeholder": "e.g., http://localhost:8080 or file:///./webapp/index.html", "settings.web_url.placeholder": "e.g., http://localhost:8080 or file:///./webapp/index.html",
"settings.web_url.open_btn": "Open", "settings.web_url.open_btn": "Open",

View file

@ -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.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_now": "Red\u00e9marrer maintenant",
"dialog.language_changed.restart_later": "Red\u00e9marrer plus tard", "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.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.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", "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.window": "Fen\u00eatre",
"settings.tab.profiles": "Configs", "settings.tab.profiles": "Configs",
"settings.tab.general": "G\u00e9n\u00e9ral", "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.label": "URL de l'application web\u00a0:",
"settings.web_url.placeholder": "p.ex. http://localhost:8080 ou file:///./webapp/index.html", "settings.web_url.placeholder": "p.ex. http://localhost:8080 ou file:///./webapp/index.html",
"settings.web_url.open_btn": "Ouvrir", "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.msg": "La lingua è stata aggiornata. Riavvia ora per applicarla ovunque.",
"dialog.language_changed.restart_now": "Riavvia ora", "dialog.language_changed.restart_now": "Riavvia ora",
"dialog.language_changed.restart_later": "Riavvia più tardi", "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.title": "Riavvio non riuscito",
"dialog.restart_failed.msg": "Impossibile riavviare automaticamente l'applicazione:\n\n{error}\n\nRiavvia manualmente.", "dialog.restart_failed.msg": "Impossibile riavviare automaticamente l'applicazione:\n\n{error}\n\nRiavvia manualmente.",
"dialog.update_timeout.title": "Timeout controllo aggiornamenti", "dialog.update_timeout.title": "Timeout controllo aggiornamenti",
@ -86,6 +90,26 @@
"settings.tab.window": "Finestra", "settings.tab.window": "Finestra",
"settings.tab.profiles": "Config", "settings.tab.profiles": "Config",
"settings.tab.general": "Generale", "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.label": "URL applicazione web:",
"settings.web_url.placeholder": "es. http://localhost:8080 o file:///./webapp/index.html", "settings.web_url.placeholder": "es. http://localhost:8080 o file:///./webapp/index.html",
"settings.web_url.open_btn": "Apri", "settings.web_url.open_btn": "Apri",

View file

@ -58,6 +58,10 @@
"dialog.language_changed.msg": "Настройка языка обновлена. Перезапустите сейчас, чтобы применить язык везде.", "dialog.language_changed.msg": "Настройка языка обновлена. Перезапустите сейчас, чтобы применить язык везде.",
"dialog.language_changed.restart_now": "Перезапустить сейчас", "dialog.language_changed.restart_now": "Перезапустить сейчас",
"dialog.language_changed.restart_later": "Перезапустить позже", "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.title": "Сбой перезапуска",
"dialog.restart_failed.msg": "Не удалось автоматически перезапустить приложение:\n\n{error}\n\nПерезапустите вручную.", "dialog.restart_failed.msg": "Не удалось автоматически перезапустить приложение:\n\n{error}\n\nПерезапустите вручную.",
"dialog.update_timeout.title": "Таймаут проверки обновлений", "dialog.update_timeout.title": "Таймаут проверки обновлений",
@ -86,6 +90,26 @@
"settings.tab.window": "Окно", "settings.tab.window": "Окно",
"settings.tab.profiles": "Наборы", "settings.tab.profiles": "Наборы",
"settings.tab.general": "Общие настройки", "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.label": "URL веб-приложения:",
"settings.web_url.placeholder": "например, http://localhost:8080 или file:///./webapp/index.html", "settings.web_url.placeholder": "например, http://localhost:8080 или file:///./webapp/index.html",
"settings.web_url.open_btn": "Открыть", "settings.web_url.open_btn": "Открыть",

View file

@ -58,6 +58,10 @@
"dialog.language_changed.msg": "语言设置已更新。立即重启可在所有界面生效。", "dialog.language_changed.msg": "语言设置已更新。立即重启可在所有界面生效。",
"dialog.language_changed.restart_now": "立即重启", "dialog.language_changed.restart_now": "立即重启",
"dialog.language_changed.restart_later": "稍后重启", "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.title": "重启失败",
"dialog.restart_failed.msg": "无法自动重启应用:\n\n{error}\n\n请手动重启。", "dialog.restart_failed.msg": "无法自动重启应用:\n\n{error}\n\n请手动重启。",
"dialog.update_timeout.title": "更新检查超时", "dialog.update_timeout.title": "更新检查超时",
@ -86,6 +90,26 @@
"settings.tab.window": "窗口", "settings.tab.window": "窗口",
"settings.tab.profiles": "设置", "settings.tab.profiles": "设置",
"settings.tab.general": "通用", "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.label": "Web 应用 URL:",
"settings.web_url.placeholder": "例如: http://localhost:8080 或 file:///./webapp/index.html", "settings.web_url.placeholder": "例如: http://localhost:8080 或 file:///./webapp/index.html",
"settings.web_url.open_btn": "打开", "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_REPO = "HIM-public/webdrop-bridge"
DEFAULT_UPDATE_CHANNEL = "stable" DEFAULT_UPDATE_CHANNEL = "stable"
DEFAULT_UPDATE_MANIFEST_NAME = "release-manifest.json" DEFAULT_UPDATE_MANIFEST_NAME = "release-manifest.json"
DEFAULT_ACTIVE_BRANDING_ID = "default"
DEFAULT_APP_ICON_PATH = "resources/icons/app.ico"
DEFAULT_TOOLBAR_ICON_HOME = "resources/icons/home.ico"
DEFAULT_TOOLBAR_ICON_RELOAD = "resources/icons/reload.ico"
DEFAULT_TOOLBAR_ICON_OPEN = "resources/icons/open.ico"
DEFAULT_TOOLBAR_ICON_OPENWITH = "resources/icons/openwith.ico"
class ConfigurationError(Exception): class ConfigurationError(Exception):
@ -96,6 +102,14 @@ class Config:
enable_logging: bool = True enable_logging: bool = True
enable_checkout: bool = False enable_checkout: bool = False
language: str = "auto" language: str = "auto"
active_branding_id: str = DEFAULT_ACTIVE_BRANDING_ID
branding_display_name: str = "Default"
logo_path: str = ""
app_icon_path: str = DEFAULT_APP_ICON_PATH
toolbar_icon_home: str = DEFAULT_TOOLBAR_ICON_HOME
toolbar_icon_reload: str = DEFAULT_TOOLBAR_ICON_RELOAD
toolbar_icon_open: str = DEFAULT_TOOLBAR_ICON_OPEN
toolbar_icon_openwith: str = DEFAULT_TOOLBAR_ICON_OPENWITH
brand_id: str = DEFAULT_BRAND_ID brand_id: str = DEFAULT_BRAND_ID
config_dir_name: str = DEFAULT_CONFIG_DIR_NAME config_dir_name: str = DEFAULT_CONFIG_DIR_NAME
update_base_url: str = DEFAULT_UPDATE_BASE_URL update_base_url: str = DEFAULT_UPDATE_BASE_URL
@ -179,7 +193,7 @@ class Config:
# No window title specified, use default # No window title specified, use default
window_title = f"{app_name} v{__version__}" window_title = f"{app_name} v{__version__}"
return cls( config = cls(
app_name=app_name, app_name=app_name,
app_version=__version__, app_version=__version__,
log_level=data.get("log_level", "INFO").upper(), log_level=data.get("log_level", "INFO").upper(),
@ -197,6 +211,13 @@ class Config:
enable_logging=data.get("enable_logging", True), enable_logging=data.get("enable_logging", True),
enable_checkout=data.get("enable_checkout", False), enable_checkout=data.get("enable_checkout", False),
language=data.get("language", "auto"), language=data.get("language", "auto"),
active_branding_id=data.get("active_branding_id", DEFAULT_ACTIVE_BRANDING_ID),
logo_path=data.get("logo_path", ""),
app_icon_path=data.get("app_icon_path", DEFAULT_APP_ICON_PATH),
toolbar_icon_home=data.get("toolbar_icon_home", DEFAULT_TOOLBAR_ICON_HOME),
toolbar_icon_reload=data.get("toolbar_icon_reload", DEFAULT_TOOLBAR_ICON_RELOAD),
toolbar_icon_open=data.get("toolbar_icon_open", DEFAULT_TOOLBAR_ICON_OPEN),
toolbar_icon_openwith=data.get("toolbar_icon_openwith", DEFAULT_TOOLBAR_ICON_OPENWITH),
brand_id=brand_id, brand_id=brand_id,
config_dir_name=config_dir_name, config_dir_name=config_dir_name,
update_base_url=data.get("update_base_url", DEFAULT_UPDATE_BASE_URL), update_base_url=data.get("update_base_url", DEFAULT_UPDATE_BASE_URL),
@ -204,6 +225,7 @@ class Config:
update_channel=data.get("update_channel", DEFAULT_UPDATE_CHANNEL), update_channel=data.get("update_channel", DEFAULT_UPDATE_CHANNEL),
update_manifest_name=data.get("update_manifest_name", DEFAULT_UPDATE_MANIFEST_NAME), update_manifest_name=data.get("update_manifest_name", DEFAULT_UPDATE_MANIFEST_NAME),
) )
return cls._apply_runtime_branding(config)
@classmethod @classmethod
def from_env(cls, env_file: str | None = None) -> "Config": def from_env(cls, env_file: str | None = None) -> "Config":
@ -246,6 +268,12 @@ class Config:
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true" enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true" enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true"
language = os.getenv("LANGUAGE", "auto") language = os.getenv("LANGUAGE", "auto")
active_branding_id = os.getenv("BRAND_TEMPLATE", DEFAULT_ACTIVE_BRANDING_ID)
app_icon_path = os.getenv("APP_ICON_PATH", DEFAULT_APP_ICON_PATH)
toolbar_icon_home = os.getenv("TOOLBAR_ICON_HOME", DEFAULT_TOOLBAR_ICON_HOME)
toolbar_icon_reload = os.getenv("TOOLBAR_ICON_RELOAD", DEFAULT_TOOLBAR_ICON_RELOAD)
toolbar_icon_open = os.getenv("TOOLBAR_ICON_OPEN", DEFAULT_TOOLBAR_ICON_OPEN)
toolbar_icon_openwith = os.getenv("TOOLBAR_ICON_OPENWITH", DEFAULT_TOOLBAR_ICON_OPENWITH)
update_base_url = os.getenv("UPDATE_BASE_URL", DEFAULT_UPDATE_BASE_URL) update_base_url = os.getenv("UPDATE_BASE_URL", DEFAULT_UPDATE_BASE_URL)
update_repo = os.getenv("UPDATE_REPO", DEFAULT_UPDATE_REPO) update_repo = os.getenv("UPDATE_REPO", DEFAULT_UPDATE_REPO)
update_channel = os.getenv("UPDATE_CHANNEL", DEFAULT_UPDATE_CHANNEL) update_channel = os.getenv("UPDATE_CHANNEL", DEFAULT_UPDATE_CHANNEL)
@ -328,7 +356,7 @@ class Config:
f"Invalid URL_MAPPINGS: {url_mappings_str}. Error: {e}" f"Invalid URL_MAPPINGS: {url_mappings_str}. Error: {e}"
) from e ) from e
return cls( config = cls(
app_name=app_name, app_name=app_name,
app_version=app_version, app_version=app_version,
log_level=log_level, log_level=log_level,
@ -343,6 +371,12 @@ class Config:
enable_logging=enable_logging, enable_logging=enable_logging,
enable_checkout=enable_checkout, enable_checkout=enable_checkout,
language=language, language=language,
active_branding_id=active_branding_id,
app_icon_path=app_icon_path,
toolbar_icon_home=toolbar_icon_home,
toolbar_icon_reload=toolbar_icon_reload,
toolbar_icon_open=toolbar_icon_open,
toolbar_icon_openwith=toolbar_icon_openwith,
brand_id=brand_id, brand_id=brand_id,
config_dir_name=config_dir_name, config_dir_name=config_dir_name,
update_base_url=update_base_url, update_base_url=update_base_url,
@ -350,6 +384,7 @@ class Config:
update_channel=update_channel, update_channel=update_channel,
update_manifest_name=update_manifest_name, update_manifest_name=update_manifest_name,
) )
return cls._apply_runtime_branding(config)
def to_file(self, config_path: Path) -> None: def to_file(self, config_path: Path) -> None:
"""Save configuration to JSON file. """Save configuration to JSON file.
@ -378,6 +413,7 @@ class Config:
"enable_logging": self.enable_logging, "enable_logging": self.enable_logging,
"enable_checkout": self.enable_checkout, "enable_checkout": self.enable_checkout,
"language": self.language, "language": self.language,
"active_branding_id": self.active_branding_id,
"brand_id": self.brand_id, "brand_id": self.brand_id,
"config_dir_name": self.config_dir_name, "config_dir_name": self.config_dir_name,
"update_base_url": self.update_base_url, "update_base_url": self.update_base_url,
@ -390,6 +426,17 @@ class Config:
with open(config_path, "w", encoding="utf-8") as f: with open(config_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
@staticmethod
def _apply_runtime_branding(config: "Config") -> "Config":
"""Apply the persisted runtime branding template to cosmetic fields."""
try:
from webdrop_bridge.core.branding_manager import BrandingManager
BrandingManager().apply_to_config(config)
except Exception as e:
logger.warning(f"Failed to apply runtime branding: {e}")
return config
@staticmethod @staticmethod
def load_bootstrap_env(env_file: str | None = None) -> Path | None: def load_bootstrap_env(env_file: str | None = None) -> Path | None:
"""Load a bootstrap .env before configuration path lookup. """Load a bootstrap .env before configuration path lookup.

View file

@ -0,0 +1,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_threads = [] # Keep references to background threads
self._background_workers = {} # Keep references to background workers self._background_workers = {} # Keep references to background workers
self._bridge_script_source = "" # Cache combined bridge source for recovery injection self._bridge_script_source = "" # Cache combined bridge source for recovery injection
self._bridge_script_re_registered = False # Flag to prevent duplicate re-registration on same load self._bridge_script_re_registered = (
False # Flag to prevent duplicate re-registration on same load
)
self._is_page_loading = False # Track if a page load is currently in progress self._is_page_loading = False # Track if a page load is currently in progress
self._pending_reload = False # Coalesce multiple rapid reload requests into one self._pending_reload = False # Coalesce multiple rapid reload requests into one
self._load_sequence = 0 # Monotonic counter to ignore stale async recovery callbacks self._load_sequence = 0 # Monotonic counter to ignore stale async recovery callbacks
@ -444,22 +446,13 @@ class MainWindow(QMainWindow):
config.window_height, config.window_height,
) )
# Set window icon # Set window icon from the active runtime branding
# Support both development mode and PyInstaller bundle icon_path = self._resolve_toolbar_icon_path(config.app_icon_path)
if hasattr(sys, "_MEIPASS"): if icon_path is not None:
# Running as PyInstaller bundle
icon_path = Path(sys._MEIPASS) / "resources" / "icons" / "app.ico" # type: ignore
else:
# Running in development mode
icon_path = (
Path(__file__).parent.parent.parent.parent / "resources" / "icons" / "app.ico"
)
if icon_path.exists():
self.setWindowIcon(QIcon(str(icon_path))) self.setWindowIcon(QIcon(str(icon_path)))
logger.debug(f"Window icon set from {icon_path}") logger.debug(f"Window icon set from {icon_path}")
else: else:
logger.warning(f"Window icon not found at {icon_path}") logger.warning(f"Window icon not found for configured path: {config.app_icon_path}")
# Create web engine view with URL for profile isolation # Create web engine view with URL for profile isolation
self.web_view = RestrictedWebEngineView( self.web_view = RestrictedWebEngineView(
@ -1189,7 +1182,9 @@ class MainWindow(QMainWindow):
# This more reliably opens files with chosen applications. # This more reliably opens files with chosen applications.
# Use a simple, more direct approach # Use a simple, more direct approach
# Get the chosen app via AppleScript, then use open command # Get the chosen app via AppleScript, then use open command
get_app_script = '''choose application with title "Select an application to open the file"''' get_app_script = (
'''choose application with title "Select an application to open the file"'''
)
try: try:
# Get the chosen application # Get the chosen application
app_result = subprocess.run( app_result = subprocess.run(
@ -1199,19 +1194,21 @@ class MainWindow(QMainWindow):
text=True, text=True,
timeout=30, timeout=30,
) )
if app_result.returncode != 0: if app_result.returncode != 0:
logger.warning(f"User cancelled app chooser or error occurred: {app_result.stderr}") logger.warning(
f"User cancelled app chooser or error occurred: {app_result.stderr}"
)
return False return False
# Get the application name (strip whitespace) # Get the application name (strip whitespace)
chosen_app = app_result.stdout.strip() chosen_app = app_result.stdout.strip()
if not chosen_app: if not chosen_app:
logger.warning("No application was selected") logger.warning("No application was selected")
return False return False
logger.info(f"User selected app: {chosen_app}") logger.info(f"User selected app: {chosen_app}")
# Now open the file with the chosen app using the 'open' command # Now open the file with the chosen app using the 'open' command
open_result = subprocess.run( open_result = subprocess.run(
["open", "-a", chosen_app, normalized_path], ["open", "-a", chosen_app, normalized_path],
@ -1220,14 +1217,16 @@ class MainWindow(QMainWindow):
text=True, text=True,
timeout=10, timeout=10,
) )
if open_result.returncode == 0: if open_result.returncode == 0:
logger.info(f"Opened '{normalized_path}' with '{chosen_app}'") logger.info(f"Opened '{normalized_path}' with '{chosen_app}'")
return True return True
else: else:
logger.warning(f"Failed to open file with '{chosen_app}': {open_result.stderr}") logger.warning(
f"Failed to open file with '{chosen_app}': {open_result.stderr}"
)
return False return False
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
logger.warning("App chooser timed out") logger.warning("App chooser timed out")
return False return False
@ -1393,7 +1392,7 @@ class MainWindow(QMainWindow):
Re-registers the bridge script to ensure it will be injected on reload, Re-registers the bridge script to ensure it will be injected on reload,
page navigation, or any load event. page navigation, or any load event.
Uses a flag to prevent duplicate re-registrations if loadStarted fires multiple times. Uses a flag to prevent duplicate re-registrations if loadStarted fires multiple times.
""" """
self._is_page_loading = True self._is_page_loading = True
@ -1412,7 +1411,7 @@ class MainWindow(QMainWindow):
Checks if the bridge script was successfully injected, with automatic recovery Checks if the bridge script was successfully injected, with automatic recovery
for page reloads and redirects. for page reloads and redirects.
Resets the re-registration flag for the next load cycle. Resets the re-registration flag for the next load cycle.
Args: Args:
@ -1433,9 +1432,11 @@ class MainWindow(QMainWindow):
logger.warning("Page failed to load") logger.warning("Page failed to load")
return return
def _verify_bridge_loaded(stage: str, attempt: int = 1, sequence: int = finished_sequence) -> None: def _verify_bridge_loaded(
stage: str, attempt: int = 1, sequence: int = finished_sequence
) -> None:
"""Check if bridge marker exists and optionally recover script injection. """Check if bridge marker exists and optionally recover script injection.
Implements multi-attempt recovery strategy: Implements multi-attempt recovery strategy:
- initial: First check after page load (50ms delay) - initial: First check after page load (50ms delay)
- recovery_N: Recovery attempts with progressive delays - recovery_N: Recovery attempts with progressive delays
@ -1485,9 +1486,7 @@ class MainWindow(QMainWindow):
delay = int(100 * (1.5 ** (attempt - 1))) delay = int(100 * (1.5 ** (attempt - 1)))
QTimer.singleShot( QTimer.singleShot(
delay, delay,
lambda: _verify_bridge_loaded( lambda: _verify_bridge_loaded("recovery", attempt + 1, sequence),
"recovery", attempt + 1, sequence
),
) )
self.web_view.page().runJavaScript(self._bridge_script_source, after_retry) self.web_view.page().runJavaScript(self._bridge_script_source, after_retry)
@ -1507,11 +1506,15 @@ class MainWindow(QMainWindow):
) )
self._re_register_bridge_script() self._re_register_bridge_script()
self.web_view.page().runJavaScript(self._bridge_script_source, after_re_register) self.web_view.page().runJavaScript(
self._bridge_script_source, after_re_register
)
return return
# All recovery attempts exhausted # All recovery attempts exhausted
logger.error("❌ WebDrop Bridge script failed to inject after all recovery attempts!") logger.error(
"❌ WebDrop Bridge script failed to inject after all recovery attempts!"
)
logger.error(" Drag-and-drop functionality is DISABLED") logger.error(" Drag-and-drop functionality is DISABLED")
logger.debug(f" Stage: {stage}, Attempt: {attempt}") logger.debug(f" Stage: {stage}, Attempt: {attempt}")
@ -1543,21 +1546,21 @@ class MainWindow(QMainWindow):
def _ensure_bridge_script_exists(self, verbose: bool = False) -> None: def _ensure_bridge_script_exists(self, verbose: bool = False) -> None:
"""Ensure bridge script exists in QWebEngineScript collection (idempotent). """Ensure bridge script exists in QWebEngineScript collection (idempotent).
Checks if the script already exists. If not, adds it. Checks if the script already exists. If not, adds it.
Never removes/re-adds to avoid race conditions with Qt's injection mechanism. Never removes/re-adds to avoid race conditions with Qt's injection mechanism.
This is safer than removing+re-adding because: This is safer than removing+re-adding because:
- Avoids concurrent access conflicts with Qt's internal injection - Avoids concurrent access conflicts with Qt's internal injection
- Prevents missing injections during rapid reloads - Prevents missing injections during rapid reloads
- Guarantees script is available without timing gaps - Guarantees script is available without timing gaps
Args: Args:
verbose: If True, use debug logging; otherwise use minimal logging verbose: If True, use debug logging; otherwise use minimal logging
""" """
try: try:
scripts = self.web_view.page().scripts() scripts = self.web_view.page().scripts()
# Check if script already exists # Check if script already exists
already_exists = False already_exists = False
for script in scripts.toList(): # type: ignore for script in scripts.toList(): # type: ignore
@ -1566,7 +1569,7 @@ class MainWindow(QMainWindow):
if verbose: if verbose:
logger.debug("Bridge script already exists in page().scripts()") logger.debug("Bridge script already exists in page().scripts()")
break break
# If script doesn't exist, add it # If script doesn't exist, add it
if not already_exists and self._bridge_script_source: if not already_exists and self._bridge_script_source:
new_script = QWebEngineScript() new_script = QWebEngineScript()
@ -1582,16 +1585,18 @@ class MainWindow(QMainWindow):
new_script.setSourceCode(self._bridge_script_source) new_script.setSourceCode(self._bridge_script_source)
scripts.insert(new_script) scripts.insert(new_script)
logger.debug(f"✓ Added bridge script to collection ({len(self._bridge_script_source)} chars)") logger.debug(
f"✓ Added bridge script to collection ({len(self._bridge_script_source)} chars)"
)
except Exception as e: except Exception as e:
logger.error(f"Failed to ensure bridge script exists: {e}") logger.error(f"Failed to ensure bridge script exists: {e}")
def _re_register_bridge_script(self, verbose: bool = False) -> None: def _re_register_bridge_script(self, verbose: bool = False) -> None:
"""Force re-registration of bridge script in QWebEngineScript collection. """Force re-registration of bridge script in QWebEngineScript collection.
Removes old script and re-adds it to ensure it's injected on next page load. Removes old script and re-adds it to ensure it's injected on next page load.
This is a fallback for recovery mechanics when normal injection fails. This is a fallback for recovery mechanics when normal injection fails.
Args: Args:
verbose: If True, use debug logging; otherwise use minimal logging verbose: If True, use debug logging; otherwise use minimal logging
""" """
@ -1622,7 +1627,9 @@ class MainWindow(QMainWindow):
scripts.insert(new_script) scripts.insert(new_script)
if verbose or removed: if verbose or removed:
logger.debug(f"✓ Re-registered webdrop-bridge script ({len(self._bridge_script_source)} chars)") logger.debug(
f"✓ Re-registered webdrop-bridge script ({len(self._bridge_script_source)} chars)"
)
except Exception as e: except Exception as e:
logger.error(f"Failed to re-register bridge script: {e}") logger.error(f"Failed to re-register bridge script: {e}")
@ -1649,9 +1656,7 @@ class MainWindow(QMainWindow):
toolbar.addSeparator() toolbar.addSeparator()
# Home button # Home button
home_icon_path = self._resolve_toolbar_icon_path( home_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_home)
os.getenv("TOOLBAR_ICON_HOME", "resources/icons/home.ico")
)
home_icon = ( home_icon = (
QIcon(str(home_icon_path)) QIcon(str(home_icon_path))
if home_icon_path is not None if home_icon_path is not None
@ -1663,9 +1668,7 @@ class MainWindow(QMainWindow):
# Refresh button # Refresh button
refresh_action = toolbar.addAction("") refresh_action = toolbar.addAction("")
reload_icon_path = self._resolve_toolbar_icon_path( reload_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_reload)
os.getenv("TOOLBAR_ICON_RELOAD", "resources/icons/reload.ico")
)
if reload_icon_path is not None: if reload_icon_path is not None:
refresh_action.setIcon(QIcon(str(reload_icon_path))) refresh_action.setIcon(QIcon(str(reload_icon_path)))
else: else:
@ -1677,9 +1680,7 @@ class MainWindow(QMainWindow):
# Open-with-default-app drop zone (right of Reload) # Open-with-default-app drop zone (right of Reload)
self._open_drop_zone = OpenDropZone() self._open_drop_zone = OpenDropZone()
open_icon_path = self._resolve_toolbar_icon_path( open_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_open)
os.getenv("TOOLBAR_ICON_OPEN", "resources/icons/open.ico")
)
if open_icon_path is not None: if open_icon_path is not None:
self._open_drop_zone.set_icon(QIcon(str(open_icon_path))) self._open_drop_zone.set_icon(QIcon(str(open_icon_path)))
self._open_drop_zone.file_opened.connect(self._on_file_opened_via_drop) self._open_drop_zone.file_opened.connect(self._on_file_opened_via_drop)
@ -1690,9 +1691,7 @@ class MainWindow(QMainWindow):
# Open-with chooser drop zone (right of Open-with-default-app) # Open-with chooser drop zone (right of Open-with-default-app)
self._open_with_drop_zone = OpenWithDropZone() self._open_with_drop_zone = OpenWithDropZone()
open_with_icon_path = self._resolve_toolbar_icon_path( open_with_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_openwith)
os.getenv("TOOLBAR_ICON_OPENWITH", "resources/icons/openwith.ico")
)
if open_with_icon_path is not None: if open_with_icon_path is not None:
self._open_with_drop_zone.set_icon(QIcon(str(open_with_icon_path))) self._open_with_drop_zone.set_icon(QIcon(str(open_with_icon_path)))
self._open_with_drop_zone.file_open_with_requested.connect( self._open_with_drop_zone.file_open_with_requested.connect(
@ -1959,6 +1958,7 @@ class MainWindow(QMainWindow):
# Store current URL before opening dialog # Store current URL before opening dialog
old_webapp_url = self.config.webapp_url old_webapp_url = self.config.webapp_url
old_language = self.config.language old_language = self.config.language
old_branding_id = self.config.active_branding_id
# Show dialog # Show dialog
dialog = SettingsDialog(self.config, self) dialog = SettingsDialog(self.config, self)
@ -1967,6 +1967,9 @@ class MainWindow(QMainWindow):
# Check if webapp URL changed # Check if webapp URL changed
new_webapp_url = self.config.webapp_url new_webapp_url = self.config.webapp_url
language_changed = old_language != self.config.language 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: if old_webapp_url != new_webapp_url:
logger.info(f"Web application URL changed: {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: if domain_changed:
logger.warning("Domain has changed - recommending restart") logger.warning("Domain has changed - recommending restart")
self._handle_domain_change_restart() self._handle_domain_change_restart()
restart_prompt_shown = True
else: else:
logger.info("Path changed but domain is same - reloading...") logger.info("Path changed but domain is same - reloading...")
# Clear cache and navigate to home asynchronously # Clear cache and navigate to home asynchronously
@ -1983,7 +1987,16 @@ class MainWindow(QMainWindow):
self.web_view.clear_cache_and_cookies() self.web_view.clear_cache_and_cookies()
QTimer.singleShot(100, self._navigate_home) 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}") logger.info(f"Language changed: {old_language}{self.config.language}")
self._handle_language_change_restart() self._handle_language_change_restart()
@ -2047,21 +2060,42 @@ class MainWindow(QMainWindow):
self.web_view.clear_cache_and_cookies() self.web_view.clear_cache_and_cookies()
self._navigate_home() 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: def _handle_language_change_restart(self) -> None:
"""Handle language change by prompting for an optional restart.""" """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 from PySide6.QtWidgets import QMessageBox
msg = QMessageBox(self) msg = QMessageBox(self)
msg.setWindowTitle(tr("dialog.language_changed.title")) msg.setWindowTitle(tr(title_key))
msg.setIcon(QMessageBox.Icon.Information) msg.setIcon(QMessageBox.Icon.Information)
msg.setText(tr("dialog.language_changed.msg")) msg.setText(tr(message_key))
restart_now_btn = msg.addButton( restart_now_btn = msg.addButton(tr(restart_now_key), QMessageBox.ButtonRole.AcceptRole)
tr("dialog.language_changed.restart_now"), QMessageBox.ButtonRole.AcceptRole msg.addButton(tr(restart_later_key), QMessageBox.ButtonRole.RejectRole)
)
msg.addButton(
tr("dialog.language_changed.restart_later"), QMessageBox.ButtonRole.RejectRole
)
msg.exec() msg.exec()

View file

@ -4,12 +4,14 @@ import logging
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from PySide6.QtGui import QIcon, QPixmap
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
QFileDialog, QFileDialog,
QHBoxLayout, QHBoxLayout,
QInputDialog,
QLabel, QLabel,
QLineEdit, QLineEdit,
QListWidget, QListWidget,
@ -23,6 +25,7 @@ from PySide6.QtWidgets import (
) )
from webdrop_bridge.config import Config, ConfigurationError from webdrop_bridge.config import Config, ConfigurationError
from webdrop_bridge.core.branding_manager import BrandingManager
from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator
from webdrop_bridge.utils.i18n import get_available_languages, tr from webdrop_bridge.utils.i18n import get_available_languages, tr
from webdrop_bridge.utils.logging import reconfigure_logging from webdrop_bridge.utils.logging import reconfigure_logging
@ -42,6 +45,7 @@ class SettingsDialog(QDialog):
""" """
super().__init__(parent) super().__init__(parent)
self.config = config self.config = config
self.branding_manager = BrandingManager()
self.profile_manager = ConfigProfile(config.config_dir_name) self.profile_manager = ConfigProfile(config.config_dir_name)
self.setWindowTitle(tr("settings.title")) self.setWindowTitle(tr("settings.title"))
self.setGeometry(100, 100, 600, 500) self.setGeometry(100, 100, 600, 500)
@ -54,6 +58,7 @@ class SettingsDialog(QDialog):
self.tabs = QTabWidget() self.tabs = QTabWidget()
self.tabs.addTab(self._create_general_tab(), tr("settings.tab.general")) self.tabs.addTab(self._create_general_tab(), tr("settings.tab.general"))
self.tabs.addTab(self._create_branding_tab(), tr("settings.tab.branding"))
self.tabs.addTab(self._create_web_source_tab(), tr("settings.tab.web_source")) self.tabs.addTab(self._create_web_source_tab(), tr("settings.tab.web_source"))
self.tabs.addTab(self._create_paths_tab(), tr("settings.tab.paths")) self.tabs.addTab(self._create_paths_tab(), tr("settings.tab.paths"))
self.tabs.addTab(self._create_urls_tab(), tr("settings.tab.urls")) self.tabs.addTab(self._create_urls_tab(), tr("settings.tab.urls"))
@ -83,6 +88,14 @@ class SettingsDialog(QDialog):
for m in config_data["url_mappings"] for m in config_data["url_mappings"]
] ]
selected_branding_id = config_data.get(
"active_branding_id", self.config.active_branding_id
)
old_branding_id = self.config.active_branding_id
self.branding_manager.set_active_branding_id(selected_branding_id)
self.config.active_branding_id = selected_branding_id
self.branding_manager.apply_to_config(self.config)
old_log_level = self.config.log_level old_log_level = self.config.log_level
self.config.language = config_data["language"] self.config.language = config_data["language"]
self.config.log_level = config_data["log_level"] self.config.log_level = config_data["log_level"]
@ -102,6 +115,12 @@ class SettingsDialog(QDialog):
logger.info(f"Configuration saved to {config_path}") logger.info(f"Configuration saved to {config_path}")
logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})") logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})")
logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}") logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}")
if old_branding_id != self.config.active_branding_id:
logger.info(
" Active branding changed: %s -> %s",
old_branding_id,
self.config.active_branding_id,
)
if old_log_level != self.config.log_level: if old_log_level != self.config.log_level:
reconfigure_logging( reconfigure_logging(
@ -151,6 +170,273 @@ class SettingsDialog(QDialog):
widget.setLayout(layout) widget.setLayout(layout)
return widget return widget
def _create_branding_tab(self) -> QWidget:
"""Create runtime branding tab."""
widget = QWidget()
layout = QVBoxLayout()
label = QLabel(tr("settings.branding.select_label"))
label.setToolTip(tr("settings.branding.select_tooltip"))
layout.addWidget(label)
help_label = QLabel(tr("settings.branding.help_text"))
help_label.setWordWrap(True)
help_label.setStyleSheet("color: gray; font-size: 11px;")
layout.addWidget(help_label)
self.branding_combo = QComboBox()
self.branding_combo.setToolTip(tr("settings.branding.select_tooltip"))
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: def _create_web_source_tab(self) -> QWidget:
"""Create web source configuration tab.""" """Create web source configuration tab."""
widget = QWidget() widget = QWidget()
@ -623,6 +909,7 @@ class SettingsDialog(QDialog):
"app_name": self.config.app_name, "app_name": self.config.app_name,
"app_version": self.config.app_version, "app_version": self.config.app_version,
"language": self.language_combo.currentData(), "language": self.language_combo.currentData(),
"active_branding_id": self.branding_combo.currentData(),
"log_level": self.log_level_combo.currentText(), "log_level": self.log_level_combo.currentText(),
"log_file": self.log_file_input.text() or None, "log_file": self.log_file_input.text() or None,
"allowed_roots": [ "allowed_roots": [

View file

@ -0,0 +1,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) @pytest.fixture(autouse=True)
def clear_env(): def clear_env(tmp_path):
"""Clear environment variables before each test to avoid persistence.""" """Clear environment variables before each test to avoid persistence."""
# Save current env # Save current env
saved_env = os.environ.copy() 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 # Clear relevant variables
for key in list(os.environ.keys()): for key in list(os.environ.keys()):
if key.startswith( if key.startswith(

View file

@ -82,6 +82,25 @@ class TestMainWindowInitialization:
assert window.drag_interceptor is not None 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: class TestMainWindowDragIntegration:
"""Test drag-and-drop integration.""" """Test drag-and-drop integration."""
@ -207,15 +226,15 @@ class TestMainWindowOpenWith:
test_file.write_text("test") test_file.write_text("test")
call_count = [0] # Use list to make it mutable in nested function call_count = [0] # Use list to make it mutable in nested function
class _AppChooseResult: class _AppChooseResult:
returncode = 0 returncode = 0
stdout = "TextEdit" # Simulated chosen app name stdout = "TextEdit" # Simulated chosen app name
class _OpenResult: class _OpenResult:
returncode = 0 returncode = 0
stdout = "" stdout = ""
def mock_run(*args, **kwargs): def mock_run(*args, **kwargs):
"""Mock subprocess.run with two different behaviors per call.""" """Mock subprocess.run with two different behaviors per call."""
call_count[0] += 1 call_count[0] += 1
@ -227,8 +246,7 @@ class TestMainWindowOpenWith:
return _OpenResult() return _OpenResult()
else: else:
raise AssertionError(f"Unexpected call #{call_count[0]} to subprocess.run") 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.sys.platform", "darwin"):
with patch("webdrop_bridge.ui.main_window.subprocess.run", side_effect=mock_run): with patch("webdrop_bridge.ui.main_window.subprocess.run", side_effect=mock_run):
assert window._open_with_app_chooser(str(test_file)) is True assert window._open_with_app_chooser(str(test_file)) is True

View file

@ -1,11 +1,11 @@
"""Tests for settings dialog.""" """Tests for settings dialog."""
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import patch
import pytest import pytest
from webdrop_bridge.config import Config, ConfigurationError from webdrop_bridge.config import Config
from webdrop_bridge.ui.settings_dialog import SettingsDialog from webdrop_bridge.ui.settings_dialog import SettingsDialog
@ -44,7 +44,7 @@ class TestSettingsDialogInitialization:
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs is not None assert dialog.tabs is not None
assert dialog.tabs.count() == 7 # General + previous 6 tabs assert dialog.tabs.count() == 8 # General + Branding + previous 6 tabs
def test_dialog_has_general_tab(self, qtbot, sample_config): def test_dialog_has_general_tab(self, qtbot, sample_config):
"""Test General tab exists.""" """Test General tab exists."""
@ -53,47 +53,54 @@ class TestSettingsDialogInitialization:
assert dialog.tabs.tabText(0) == "General" assert dialog.tabs.tabText(0) == "General"
def test_dialog_has_branding_tab(self, qtbot, sample_config):
"""Test Branding tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.tabs.tabText(1) == "Branding"
def test_dialog_has_web_source_tab(self, qtbot, sample_config): def test_dialog_has_web_source_tab(self, qtbot, sample_config):
"""Test Web Source tab exists.""" """Test Web Source tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs.tabText(1) == "Web Source" assert dialog.tabs.tabText(2) == "Web Source"
def test_dialog_has_paths_tab(self, qtbot, sample_config): def test_dialog_has_paths_tab(self, qtbot, sample_config):
"""Test Paths tab exists.""" """Test Paths tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs.tabText(2) == "Paths" assert dialog.tabs.tabText(3) == "Paths"
def test_dialog_has_urls_tab(self, qtbot, sample_config): def test_dialog_has_urls_tab(self, qtbot, sample_config):
"""Test URLs tab exists.""" """Test URLs tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs.tabText(3) == "URLs" assert dialog.tabs.tabText(4) == "URLs"
def test_dialog_has_logging_tab(self, qtbot, sample_config): def test_dialog_has_logging_tab(self, qtbot, sample_config):
"""Test Logging tab exists.""" """Test Logging tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs.tabText(4) == "Logging" assert dialog.tabs.tabText(5) == "Logging"
def test_dialog_has_window_tab(self, qtbot, sample_config): def test_dialog_has_window_tab(self, qtbot, sample_config):
"""Test Window tab exists.""" """Test Window tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs.tabText(5) == "Window" assert dialog.tabs.tabText(6) == "Window"
def test_dialog_has_profiles_tab(self, qtbot, sample_config): def test_dialog_has_profiles_tab(self, qtbot, sample_config):
"""Test Setups tab exists with clearer wording.""" """Test Setups tab exists with clearer wording."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs.tabText(6) == "Setups" assert dialog.tabs.tabText(7) == "Setups"
def test_profiles_actions_have_explanatory_tooltips(self, qtbot, sample_config): def test_profiles_actions_have_explanatory_tooltips(self, qtbot, sample_config):
"""Test profile/config actions expose helpful explanations.""" """Test profile/config actions expose helpful explanations."""
@ -104,6 +111,46 @@ class TestSettingsDialogInitialization:
assert "backup" in dialog.export_btn.toolTip().lower() assert "backup" in dialog.export_btn.toolTip().lower()
assert "json" in dialog.import_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: class TestPathsTab:
"""Test Paths configuration tab.""" """Test Paths configuration tab."""