From f022d984b62bbf9e85f10fe61bb657c179e746bd Mon Sep 17 00:00:00 2001 From: claudi Date: Wed, 15 Apr 2026 09:52:40 +0200 Subject: [PATCH 01/10] feat: Update terminology from "Profiles" to "Setups" across translations and UI for clarity --- resources/translations/de.json | 48 +++++++++++++--------- resources/translations/en.json | 48 +++++++++++++--------- resources/translations/fr.json | 48 +++++++++++++--------- resources/translations/it.json | 48 +++++++++++++--------- resources/translations/ru.json | 48 +++++++++++++--------- resources/translations/zh.json | 48 +++++++++++++--------- src/webdrop_bridge/ui/settings_dialog.py | 51 ++++++++++++++++-------- tests/unit/test_settings_dialog.py | 16 ++++++-- 8 files changed, 221 insertions(+), 134 deletions(-) diff --git a/resources/translations/de.json b/resources/translations/de.json index 3940c17..7be1ed0 100644 --- a/resources/translations/de.json +++ b/resources/translations/de.json @@ -84,7 +84,7 @@ "settings.tab.urls": "URLs", "settings.tab.logging": "Protokollierung", "settings.tab.window": "Fenster", - "settings.tab.profiles": "Profile", + "settings.tab.profiles": "Setups", "settings.tab.general": "Allgemein", "settings.web_url.label": "Web-Anwendungs-URL:", "settings.web_url.placeholder": "z.B. http://localhost:8080 oder file:///./webapp/index.html", @@ -106,12 +106,22 @@ "settings.log_file.browse_btn": "Durchsuchen...", "settings.window.width_label": "Fensterbreite:", "settings.window.height_label": "Fensterh\u00f6he:", - "settings.profiles.label": "Gespeicherte Konfigurationsprofile:", - "settings.profiles.save_btn": "Als Profil speichern", - "settings.profiles.load_btn": "Profil laden", - "settings.profiles.delete_btn": "Profil l\u00f6schen", - "settings.profiles.export_btn": "Konfiguration exportieren", - "settings.profiles.import_btn": "Konfiguration importieren", + "settings.profiles.label": "Gespeicherte Setups auf diesem Ger\u00e4t:", + "settings.profiles.label_tooltip": "Gespeicherte Setups sind benannte Schnappsch\u00fcsse Ihrer aktuellen Einstellungen f\u00fcr den schnellen Wechsel auf diesem Ger\u00e4t.", + "settings.profiles.help_text": "Speichern Sie den aktuellen Stand als benanntes Setup f\u00fcr den schnellen Wechsel auf diesem Ger\u00e4t. Nutzen Sie Export/Import, wenn Sie eine Konfigurationsdatei sichern oder teilen m\u00f6chten.", + "settings.profiles.list_tooltip": "Zeigt die auf diesem Ger\u00e4t verf\u00fcgbaren gespeicherten Setups.", + "settings.profiles.save_btn": "Setup speichern", + "settings.profiles.save_tooltip": "Speichert die aktuellen Einstellungen als benanntes Setup auf diesem Ger\u00e4t.", + "settings.profiles.load_btn": "Setup laden", + "settings.profiles.load_tooltip": "L\u00e4dt das ausgew\u00e4hlte gespeicherte Setup in diesen Dialog.", + "settings.profiles.delete_btn": "Setup l\u00f6schen", + "settings.profiles.delete_tooltip": "L\u00f6scht das ausgew\u00e4hlte gespeicherte Setup von diesem Ger\u00e4t.", + "settings.profiles.transfer_label": "Aktuelle Einstellungen sichern oder teilen:", + "settings.profiles.transfer_tooltip": "Export erstellt eine JSON-Datei zum Sichern oder Teilen. Import liest eine solche Datei ein und wendet sie hier an.", + "settings.profiles.export_btn": "In Datei exportieren", + "settings.profiles.export_tooltip": "Exportiert die aktuellen Einstellungen als JSON-Datei zum Sichern oder Teilen.", + "settings.profiles.import_btn": "Aus Datei importieren", + "settings.profiles.import_tooltip": "Importiert Einstellungen aus einer JSON-Datei und wendet sie hier an.", "settings.general.language_label": "Sprache:", "settings.general.language_auto": "Systemstandard (Auto)", "settings.general.language_restart_note": "Sprach\u00e4nderung wirksam nach Neustart.", @@ -123,15 +133,15 @@ "settings.edit_mapping.path_prompt": "Lokalen Dateisystempfad eingeben:", "settings.add_url.title": "URL hinzuf\u00fcgen", "settings.add_url.prompt": "URL-Muster eingeben (z.B. http://example.com oder http://*.example.com):", - "settings.profile.save.title": "Profil speichern", - "settings.profile.save.prompt": "Profilnamen eingeben (z.B. Arbeit, Privat):", + "settings.profile.save.title": "Setup speichern", + "settings.profile.save.prompt": "Namen für das Setup eingeben (z.B. Arbeit, Kunde A):", "settings.select_directory.title": "Verzeichnis ausw\u00e4hlen", "settings.select_log_file.title": "Protokolldatei ausw\u00e4hlen", - "settings.export_config.title": "Konfiguration exportieren", - "settings.import_config.title": "Konfiguration importieren", + "settings.export_config.title": "Einstellungen exportieren", + "settings.import_config.title": "Einstellungen importieren", "settings.error.select_mapping": "Bitte w\u00e4hlen Sie eine Zuordnung zur Bearbeitung aus", - "settings.error.select_profile_load": "Bitte w\u00e4hlen Sie ein Profil zum Laden aus", - "settings.error.select_profile_delete": "Bitte w\u00e4hlen Sie ein Profil zum L\u00f6schen aus", + "settings.error.select_profile_load": "Bitte w\u00e4hlen Sie ein Setup zum Laden aus", + "settings.error.select_profile_delete": "Bitte w\u00e4hlen Sie ein Setup zum L\u00f6schen aus", "settings.web_source.url_label": "Webanwendungs-URL:", "settings.web_source.open_btn": "\u00d6ffnen", "settings.web_source.url_mappings_label": "URL-Zuordnungen (Azure Blob Storage \u2192 Lokale Pfade):", @@ -154,12 +164,12 @@ "settings.paths.select_dir_title": "Verzeichnis ausw\u00e4hlen", "settings.urls.add_title": "URL hinzuf\u00fcgen", "settings.urls.add_prompt": "URL-Muster eingeben (z.B. http://example.com oder http://*.example.com):", - "settings.profiles.save_title": "Profil speichern", - "settings.profiles.save_prompt": "Profilnamen eingeben (z.B. Arbeit, Privat):", - "settings.profiles.select_to_load": "Bitte w\u00e4hlen Sie ein Profil zum Laden aus", - "settings.profiles.select_to_delete": "Bitte w\u00e4hlen Sie ein Profil zum L\u00f6schen aus", - "settings.profiles.export_title": "Konfiguration exportieren", - "settings.profiles.import_title": "Konfiguration importieren", + "settings.profiles.save_title": "Setup speichern", + "settings.profiles.save_prompt": "Namen für das Setup eingeben (z.B. Arbeit, Kunde A):", + "settings.profiles.select_to_load": "Bitte wählen Sie ein Setup zum Laden aus", + "settings.profiles.select_to_delete": "Bitte wählen Sie ein Setup zum Löschen aus", + "settings.profiles.export_title": "Einstellungen exportieren", + "settings.profiles.import_title": "Einstellungen importieren", "update.checking.title": "Update-Pr\u00fcfung", "update.checking.label": "Suche nach Updates...", diff --git a/resources/translations/en.json b/resources/translations/en.json index 3bbfb25..57798d5 100644 --- a/resources/translations/en.json +++ b/resources/translations/en.json @@ -84,7 +84,7 @@ "settings.tab.urls": "URLs", "settings.tab.logging": "Logging", "settings.tab.window": "Window", - "settings.tab.profiles": "Profiles", + "settings.tab.profiles": "Setups", "settings.tab.general": "General", "settings.web_url.label": "Web Application URL:", "settings.web_url.placeholder": "e.g., http://localhost:8080 or file:///./webapp/index.html", @@ -106,12 +106,22 @@ "settings.log_file.browse_btn": "Browse...", "settings.window.width_label": "Window Width:", "settings.window.height_label": "Window Height:", - "settings.profiles.label": "Saved Configuration Profiles:", - "settings.profiles.save_btn": "Save as Profile", - "settings.profiles.load_btn": "Load Profile", - "settings.profiles.delete_btn": "Delete Profile", - "settings.profiles.export_btn": "Export Configuration", - "settings.profiles.import_btn": "Import Configuration", + "settings.profiles.label": "Saved setups on this device:", + "settings.profiles.label_tooltip": "Saved setups are named snapshots of your current settings kept on this device for quick switching.", + "settings.profiles.help_text": "Save the current settings as a named setup for quick switching on this device. Use export/import when you want to back up or share a configuration file.", + "settings.profiles.list_tooltip": "Shows the saved setups available on this device.", + "settings.profiles.save_btn": "Save Setup", + "settings.profiles.save_tooltip": "Save the current settings as a named setup on this device.", + "settings.profiles.load_btn": "Load Setup", + "settings.profiles.load_tooltip": "Load the selected saved setup into the dialog.", + "settings.profiles.delete_btn": "Delete Setup", + "settings.profiles.delete_tooltip": "Delete the selected saved setup from this device.", + "settings.profiles.transfer_label": "Backup or share the current settings:", + "settings.profiles.transfer_tooltip": "Export creates a JSON file for backup or sharing. Import reads such a file and applies it here.", + "settings.profiles.export_btn": "Export to File", + "settings.profiles.export_tooltip": "Export the current settings to a JSON file for backup or sharing.", + "settings.profiles.import_btn": "Import from File", + "settings.profiles.import_tooltip": "Import settings from a JSON file and apply them here.", "settings.general.language_label": "Language:", "settings.general.language_auto": "System Default (Auto)", "settings.general.language_restart_note": "Language change takes effect after restart.", @@ -123,15 +133,15 @@ "settings.edit_mapping.path_prompt": "Enter local file system path:", "settings.add_url.title": "Add URL", "settings.add_url.prompt": "Enter URL pattern (e.g., http://example.com or http://*.example.com):", - "settings.profile.save.title": "Save Profile", - "settings.profile.save.prompt": "Enter profile name (e.g., work, personal):", + "settings.profile.save.title": "Save Setup", + "settings.profile.save.prompt": "Enter a setup name (e.g., Work, Customer A):", "settings.select_directory.title": "Select Directory to Allow", "settings.select_log_file.title": "Select Log File", - "settings.export_config.title": "Export Configuration", - "settings.import_config.title": "Import Configuration", + "settings.export_config.title": "Export Settings", + "settings.import_config.title": "Import Settings", "settings.error.select_mapping": "Please select a mapping to edit", - "settings.error.select_profile_load": "Please select a profile to load", - "settings.error.select_profile_delete": "Please select a profile to delete", + "settings.error.select_profile_load": "Please select a setup to load", + "settings.error.select_profile_delete": "Please select a setup to delete", "settings.web_source.url_label": "Web Application URL:", "settings.web_source.open_btn": "Open", "settings.web_source.url_mappings_label": "URL Mappings (Azure Blob Storage \u2192 Local Paths):", @@ -154,12 +164,12 @@ "settings.paths.select_dir_title": "Select Directory to Allow", "settings.urls.add_title": "Add URL", "settings.urls.add_prompt": "Enter URL pattern (e.g., http://example.com or http://*.example.com):", - "settings.profiles.save_title": "Save Profile", - "settings.profiles.save_prompt": "Enter profile name (e.g., work, personal):", - "settings.profiles.select_to_load": "Please select a profile to load", - "settings.profiles.select_to_delete": "Please select a profile to delete", - "settings.profiles.export_title": "Export Configuration", - "settings.profiles.import_title": "Import Configuration", + "settings.profiles.save_title": "Save Setup", + "settings.profiles.save_prompt": "Enter a setup name (e.g., Work, Customer A):", + "settings.profiles.select_to_load": "Please select a setup to load", + "settings.profiles.select_to_delete": "Please select a setup to delete", + "settings.profiles.export_title": "Export Settings", + "settings.profiles.import_title": "Import Settings", "update.checking.title": "Checking for Updates", "update.checking.label": "Checking for updates...", diff --git a/resources/translations/fr.json b/resources/translations/fr.json index 3e76e2d..d024e21 100644 --- a/resources/translations/fr.json +++ b/resources/translations/fr.json @@ -84,7 +84,7 @@ "settings.tab.urls": "URLs", "settings.tab.logging": "Journalisation", "settings.tab.window": "Fen\u00eatre", - "settings.tab.profiles": "Profils", + "settings.tab.profiles": "Configs", "settings.tab.general": "G\u00e9n\u00e9ral", "settings.web_url.label": "URL de l'application web\u00a0:", "settings.web_url.placeholder": "p.ex. http://localhost:8080 ou file:///./webapp/index.html", @@ -106,12 +106,22 @@ "settings.log_file.browse_btn": "Parcourir...", "settings.window.width_label": "Largeur de la fen\u00eatre\u00a0:", "settings.window.height_label": "Hauteur de la fen\u00eatre\u00a0:", - "settings.profiles.label": "Profils de configuration enregistr\u00e9s\u00a0:", - "settings.profiles.save_btn": "Enregistrer comme profil", - "settings.profiles.load_btn": "Charger le profil", - "settings.profiles.delete_btn": "Supprimer le profil", - "settings.profiles.export_btn": "Exporter la configuration", - "settings.profiles.import_btn": "Importer la configuration", + "settings.profiles.label": "Configurations enregistr\u00e9es sur cet appareil\u00a0:", + "settings.profiles.label_tooltip": "Les configurations enregistr\u00e9es sont des instantan\u00e9s nomm\u00e9s de vos r\u00e9glages actuels pour basculer rapidement sur cet appareil.", + "settings.profiles.help_text": "Enregistrez l’\u00e9tat actuel comme configuration nomm\u00e9e pour basculer rapidement sur cet appareil. Utilisez l’export/import pour sauvegarder ou partager un fichier de configuration.", + "settings.profiles.list_tooltip": "Affiche les configurations enregistr\u00e9es disponibles sur cet appareil.", + "settings.profiles.save_btn": "Enregistrer la configuration", + "settings.profiles.save_tooltip": "Enregistre les r\u00e9glages actuels comme configuration nomm\u00e9e sur cet appareil.", + "settings.profiles.load_btn": "Charger la configuration", + "settings.profiles.load_tooltip": "Charge la configuration enregistr\u00e9e s\u00e9lectionn\u00e9e dans cette bo\u00eete de dialogue.", + "settings.profiles.delete_btn": "Supprimer la configuration", + "settings.profiles.delete_tooltip": "Supprime la configuration enregistr\u00e9e s\u00e9lectionn\u00e9e de cet appareil.", + "settings.profiles.transfer_label": "Sauvegarder ou partager les r\u00e9glages actuels\u00a0:", + "settings.profiles.transfer_tooltip": "Exporter cr\u00e9e un fichier JSON pour la sauvegarde ou le partage. Importer lit un tel fichier et l’applique ici.", + "settings.profiles.export_btn": "Exporter vers un fichier", + "settings.profiles.export_tooltip": "Exporte les r\u00e9glages actuels vers un fichier JSON pour sauvegarde ou partage.", + "settings.profiles.import_btn": "Importer depuis un fichier", + "settings.profiles.import_tooltip": "Importe des r\u00e9glages depuis un fichier JSON et les applique ici.", "settings.general.language_label": "Langue\u00a0:", "settings.general.language_auto": "Par d\u00e9faut du syst\u00e8me (Auto)", "settings.general.language_restart_note": "Le changement de langue prend effet apr\u00e8s red\u00e9marrage.", @@ -123,15 +133,15 @@ "settings.edit_mapping.path_prompt": "Entrez le chemin du syst\u00e8me de fichiers local\u00a0:", "settings.add_url.title": "Ajouter une URL", "settings.add_url.prompt": "Entrez le mod\u00e8le d'URL (p.ex. http://example.com ou http://*.example.com)\u00a0:", - "settings.profile.save.title": "Enregistrer le profil", - "settings.profile.save.prompt": "Entrez le nom du profil (p.ex. travail, personnel)\u00a0:", + "settings.profile.save.title": "Enregistrer la configuration", + "settings.profile.save.prompt": "Entrez un nom de configuration (p.ex. travail, client A)\u00a0:", "settings.select_directory.title": "S\u00e9lectionner un r\u00e9pertoire autoris\u00e9", "settings.select_log_file.title": "S\u00e9lectionner le fichier journal", - "settings.export_config.title": "Exporter la configuration", - "settings.import_config.title": "Importer la configuration", + "settings.export_config.title": "Exporter les réglages", + "settings.import_config.title": "Importer les réglages", "settings.error.select_mapping": "Veuillez s\u00e9lectionner un mappage \u00e0 modifier", - "settings.error.select_profile_load": "Veuillez s\u00e9lectionner un profil \u00e0 charger", - "settings.error.select_profile_delete": "Veuillez s\u00e9lectionner un profil \u00e0 supprimer", + "settings.error.select_profile_load": "Veuillez sélectionner une configuration à charger", + "settings.error.select_profile_delete": "Veuillez sélectionner une configuration à supprimer", "settings.web_source.url_label": "URL de l'application web\u00a0:", "settings.web_source.open_btn": "Ouvrir", "settings.web_source.url_mappings_label": "Mappages d'URL (Azure Blob Storage \u2192 Chemins locaux)\u00a0:", @@ -154,12 +164,12 @@ "settings.paths.select_dir_title": "S\u00e9lectionner un r\u00e9pertoire autoris\u00e9", "settings.urls.add_title": "Ajouter une URL", "settings.urls.add_prompt": "Entrez le mod\u00e8le d'URL (p.ex. http://example.com ou http://*.example.com)\u00a0:", - "settings.profiles.save_title": "Enregistrer le profil", - "settings.profiles.save_prompt": "Entrez le nom du profil (p.ex. travail, personnel)\u00a0:", - "settings.profiles.select_to_load": "Veuillez s\u00e9lectionner un profil \u00e0 charger", - "settings.profiles.select_to_delete": "Veuillez s\u00e9lectionner un profil \u00e0 supprimer", - "settings.profiles.export_title": "Exporter la configuration", - "settings.profiles.import_title": "Importer la configuration", + "settings.profiles.save_title": "Enregistrer la configuration", + "settings.profiles.save_prompt": "Entrez un nom de configuration (p.ex. travail, client A) :", + "settings.profiles.select_to_load": "Veuillez sélectionner une configuration à charger", + "settings.profiles.select_to_delete": "Veuillez sélectionner une configuration à supprimer", + "settings.profiles.export_title": "Exporter les réglages", + "settings.profiles.import_title": "Importer les réglages", "update.checking.title": "V\u00e9rification des mises \u00e0 jour", "update.checking.label": "Recherche de mises \u00e0 jour...", diff --git a/resources/translations/it.json b/resources/translations/it.json index 8695a68..25e026b 100644 --- a/resources/translations/it.json +++ b/resources/translations/it.json @@ -84,7 +84,7 @@ "settings.tab.urls": "URL", "settings.tab.logging": "Log", "settings.tab.window": "Finestra", - "settings.tab.profiles": "Profili", + "settings.tab.profiles": "Config", "settings.tab.general": "Generale", "settings.web_url.label": "URL applicazione web:", "settings.web_url.placeholder": "es. http://localhost:8080 o file:///./webapp/index.html", @@ -106,12 +106,22 @@ "settings.log_file.browse_btn": "Sfoglia...", "settings.window.width_label": "Larghezza finestra:", "settings.window.height_label": "Altezza finestra:", - "settings.profiles.label": "Profili configurazione salvati:", - "settings.profiles.save_btn": "Salva come profilo", - "settings.profiles.load_btn": "Carica profilo", - "settings.profiles.delete_btn": "Elimina profilo", - "settings.profiles.export_btn": "Esporta configurazione", - "settings.profiles.import_btn": "Importa configurazione", + "settings.profiles.label": "Configurazioni salvate su questo dispositivo:", + "settings.profiles.label_tooltip": "Le configurazioni salvate sono istantanee con nome delle impostazioni correnti per passare rapidamente da un assetto all’altro su questo dispositivo.", + "settings.profiles.help_text": "Salva lo stato corrente come configurazione con nome per cambiare rapidamente su questo dispositivo. Usa esporta/importa per eseguire un backup o condividere un file di configurazione.", + "settings.profiles.list_tooltip": "Mostra le configurazioni salvate disponibili su questo dispositivo.", + "settings.profiles.save_btn": "Salva configurazione", + "settings.profiles.save_tooltip": "Salva le impostazioni correnti come configurazione con nome su questo dispositivo.", + "settings.profiles.load_btn": "Carica configurazione", + "settings.profiles.load_tooltip": "Carica in questa finestra di dialogo la configurazione salvata selezionata.", + "settings.profiles.delete_btn": "Elimina configurazione", + "settings.profiles.delete_tooltip": "Elimina da questo dispositivo la configurazione salvata selezionata.", + "settings.profiles.transfer_label": "Backup o condivisione delle impostazioni correnti:", + "settings.profiles.transfer_tooltip": "Esporta crea un file JSON per backup o condivisione. Importa legge tale file e lo applica qui.", + "settings.profiles.export_btn": "Esporta in file", + "settings.profiles.export_tooltip": "Esporta le impostazioni correnti in un file JSON per backup o condivisione.", + "settings.profiles.import_btn": "Importa da file", + "settings.profiles.import_tooltip": "Importa impostazioni da un file JSON e le applica qui.", "settings.general.language_label": "Lingua:", "settings.general.language_auto": "Predefinita sistema (Auto)", "settings.general.language_restart_note": "La modifica lingua si applica dopo il riavvio.", @@ -123,15 +133,15 @@ "settings.edit_mapping.path_prompt": "Inserisci percorso file system locale:", "settings.add_url.title": "Aggiungi URL", "settings.add_url.prompt": "Inserisci pattern URL (es. http://example.com o http://*.example.com):", - "settings.profile.save.title": "Salva profilo", - "settings.profile.save.prompt": "Inserisci nome profilo (es. lavoro, personale):", + "settings.profile.save.title": "Salva configurazione", + "settings.profile.save.prompt": "Inserisci un nome per la configurazione (es. Lavoro, Cliente A):", "settings.select_directory.title": "Seleziona directory da consentire", "settings.select_log_file.title": "Seleziona file di log", - "settings.export_config.title": "Esporta configurazione", - "settings.import_config.title": "Importa configurazione", + "settings.export_config.title": "Esporta impostazioni", + "settings.import_config.title": "Importa impostazioni", "settings.error.select_mapping": "Seleziona una mappatura da modificare", - "settings.error.select_profile_load": "Seleziona un profilo da caricare", - "settings.error.select_profile_delete": "Seleziona un profilo da eliminare", + "settings.error.select_profile_load": "Seleziona una configurazione da caricare", + "settings.error.select_profile_delete": "Seleziona una configurazione da eliminare", "settings.web_source.url_label": "URL applicazione web:", "settings.web_source.open_btn": "Apri", "settings.web_source.url_mappings_label": "Mappature URL (Azure Blob Storage \u2192 Percorsi locali):", @@ -154,12 +164,12 @@ "settings.paths.select_dir_title": "Seleziona directory da consentire", "settings.urls.add_title": "Aggiungi URL", "settings.urls.add_prompt": "Inserisci pattern URL (es. http://example.com o http://*.example.com):", - "settings.profiles.save_title": "Salva profilo", - "settings.profiles.save_prompt": "Inserisci nome profilo (es. lavoro, personale):", - "settings.profiles.select_to_load": "Seleziona un profilo da caricare", - "settings.profiles.select_to_delete": "Seleziona un profilo da eliminare", - "settings.profiles.export_title": "Esporta configurazione", - "settings.profiles.import_title": "Importa configurazione", + "settings.profiles.save_title": "Salva configurazione", + "settings.profiles.save_prompt": "Inserisci un nome per la configurazione (es. Lavoro, Cliente A):", + "settings.profiles.select_to_load": "Seleziona una configurazione da caricare", + "settings.profiles.select_to_delete": "Seleziona una configurazione da eliminare", + "settings.profiles.export_title": "Esporta impostazioni", + "settings.profiles.import_title": "Importa impostazioni", "update.checking.title": "Controllo aggiornamenti", "update.checking.label": "Controllo aggiornamenti...", diff --git a/resources/translations/ru.json b/resources/translations/ru.json index 9d36a37..a7abdec 100644 --- a/resources/translations/ru.json +++ b/resources/translations/ru.json @@ -84,7 +84,7 @@ "settings.tab.urls": "URL", "settings.tab.logging": "Логирование", "settings.tab.window": "Окно", - "settings.tab.profiles": "Профили", + "settings.tab.profiles": "Наборы", "settings.tab.general": "Общие настройки", "settings.web_url.label": "URL веб-приложения:", "settings.web_url.placeholder": "например, http://localhost:8080 или file:///./webapp/index.html", @@ -106,12 +106,22 @@ "settings.log_file.browse_btn": "Обзор...", "settings.window.width_label": "Ширина окна:", "settings.window.height_label": "Высота окна:", - "settings.profiles.label": "Сохраненные профили конфигурации:", - "settings.profiles.save_btn": "Сохранить как профиль", - "settings.profiles.load_btn": "Загрузить профиль", - "settings.profiles.delete_btn": "Удалить профиль", - "settings.profiles.export_btn": "Экспорт конфигурации", - "settings.profiles.import_btn": "Импорт конфигурации", + "settings.profiles.label": "Сохраненные наборы настроек на этом устройстве:", + "settings.profiles.label_tooltip": "Сохраненные наборы — это именованные снимки текущих настроек для быстрого переключения на этом устройстве.", + "settings.profiles.help_text": "Сохраните текущее состояние как именованный набор для быстрого переключения на этом устройстве. Используйте экспорт/импорт для резервного копирования или обмена файлом конфигурации.", + "settings.profiles.list_tooltip": "Показывает сохраненные наборы, доступные на этом устройстве.", + "settings.profiles.save_btn": "Сохранить набор", + "settings.profiles.save_tooltip": "Сохраняет текущие настройки как именованный набор на этом устройстве.", + "settings.profiles.load_btn": "Загрузить набор", + "settings.profiles.load_tooltip": "Загружает выбранный сохраненный набор в это окно.", + "settings.profiles.delete_btn": "Удалить набор", + "settings.profiles.delete_tooltip": "Удаляет выбранный сохраненный набор с этого устройства.", + "settings.profiles.transfer_label": "Сохранить резервную копию или поделиться текущими настройками:", + "settings.profiles.transfer_tooltip": "Экспорт создает JSON-файл для резервного копирования или обмена. Импорт читает такой файл и применяет его здесь.", + "settings.profiles.export_btn": "Экспорт в файл", + "settings.profiles.export_tooltip": "Экспортирует текущие настройки в JSON-файл для резервного копирования или обмена.", + "settings.profiles.import_btn": "Импорт из файла", + "settings.profiles.import_tooltip": "Импортирует настройки из JSON-файла и применяет их здесь.", "settings.general.language_label": "Язык:", "settings.general.language_auto": "Системный язык (авто)", "settings.general.language_restart_note": "Изменение языка вступает в силу после перезапуска.", @@ -123,15 +133,15 @@ "settings.edit_mapping.path_prompt": "Введите локальный путь файловой системы:", "settings.add_url.title": "Добавить URL", "settings.add_url.prompt": "Введите шаблон URL (например, http://example.com или http://*.example.com):", - "settings.profile.save.title": "Сохранить профиль", - "settings.profile.save.prompt": "Введите имя профиля (например, работа, личный):", + "settings.profile.save.title": "Сохранить набор", + "settings.profile.save.prompt": "Введите имя набора (например, Работа, Клиент A):", "settings.select_directory.title": "Выберите разрешенную папку", "settings.select_log_file.title": "Выберите файл журнала", - "settings.export_config.title": "Экспорт конфигурации", - "settings.import_config.title": "Импорт конфигурации", + "settings.export_config.title": "Экспорт настроек", + "settings.import_config.title": "Импорт настроек", "settings.error.select_mapping": "Выберите сопоставление для редактирования", - "settings.error.select_profile_load": "Выберите профиль для загрузки", - "settings.error.select_profile_delete": "Выберите профиль для удаления", + "settings.error.select_profile_load": "Выберите набор для загрузки", + "settings.error.select_profile_delete": "Выберите набор для удаления", "settings.web_source.url_label": "URL веб-приложения:", "settings.web_source.open_btn": "Открыть", "settings.web_source.url_mappings_label": "Сопоставления URL (Azure Blob Storage → локальные пути):", @@ -154,12 +164,12 @@ "settings.paths.select_dir_title": "Выберите разрешенную папку", "settings.urls.add_title": "Добавить URL", "settings.urls.add_prompt": "Введите шаблон URL (например, http://example.com или http://*.example.com):", - "settings.profiles.save_title": "Сохранить профиль", - "settings.profiles.save_prompt": "Введите имя профиля (например, работа, личный):", - "settings.profiles.select_to_load": "Выберите профиль для загрузки", - "settings.profiles.select_to_delete": "Выберите профиль для удаления", - "settings.profiles.export_title": "Экспорт конфигурации", - "settings.profiles.import_title": "Импорт конфигурации", + "settings.profiles.save_title": "Сохранить набор", + "settings.profiles.save_prompt": "Введите имя набора (например, Работа, Клиент A):", + "settings.profiles.select_to_load": "Выберите набор для загрузки", + "settings.profiles.select_to_delete": "Выберите набор для удаления", + "settings.profiles.export_title": "Экспорт настроек", + "settings.profiles.import_title": "Импорт настроек", "update.checking.title": "Проверка обновлений", "update.checking.label": "Проверка обновлений...", diff --git a/resources/translations/zh.json b/resources/translations/zh.json index 62a049d..ea2686b 100644 --- a/resources/translations/zh.json +++ b/resources/translations/zh.json @@ -84,7 +84,7 @@ "settings.tab.urls": "URL", "settings.tab.logging": "日志", "settings.tab.window": "窗口", - "settings.tab.profiles": "配置档案", + "settings.tab.profiles": "设置", "settings.tab.general": "通用", "settings.web_url.label": "Web 应用 URL:", "settings.web_url.placeholder": "例如: http://localhost:8080 或 file:///./webapp/index.html", @@ -106,12 +106,22 @@ "settings.log_file.browse_btn": "浏览...", "settings.window.width_label": "窗口宽度:", "settings.window.height_label": "窗口高度:", - "settings.profiles.label": "已保存配置档案:", - "settings.profiles.save_btn": "保存为档案", - "settings.profiles.load_btn": "加载档案", - "settings.profiles.delete_btn": "删除档案", - "settings.profiles.export_btn": "导出配置", - "settings.profiles.import_btn": "导入配置", + "settings.profiles.label": "此设备上已保存的设置:", + "settings.profiles.label_tooltip": "已保存设置是当前配置的命名快照,可用于在此设备上快速切换。", + "settings.profiles.help_text": "将当前状态保存为命名设置,便于在此设备上快速切换。需要备份或共享配置文件时,请使用导出/导入。", + "settings.profiles.list_tooltip": "显示此设备上可用的已保存设置。", + "settings.profiles.save_btn": "保存设置", + "settings.profiles.save_tooltip": "将当前设置保存为此设备上的命名设置。", + "settings.profiles.load_btn": "加载设置", + "settings.profiles.load_tooltip": "将选中的已保存设置加载到此对话框中。", + "settings.profiles.delete_btn": "删除设置", + "settings.profiles.delete_tooltip": "从此设备删除选中的已保存设置。", + "settings.profiles.transfer_label": "备份或共享当前设置:", + "settings.profiles.transfer_tooltip": "导出会创建一个 JSON 文件用于备份或共享。导入会读取此类文件并在此处应用。", + "settings.profiles.export_btn": "导出到文件", + "settings.profiles.export_tooltip": "将当前设置导出为 JSON 文件,用于备份或共享。", + "settings.profiles.import_btn": "从文件导入", + "settings.profiles.import_tooltip": "从 JSON 文件导入设置并在此处应用。", "settings.general.language_label": "语言:", "settings.general.language_auto": "跟随系统(自动)", "settings.general.language_restart_note": "语言更改将在重启后生效。", @@ -123,15 +133,15 @@ "settings.edit_mapping.path_prompt": "输入本地文件系统路径:", "settings.add_url.title": "添加 URL", "settings.add_url.prompt": "输入 URL 模式(例如: http://example.com 或 http://*.example.com):", - "settings.profile.save.title": "保存档案", - "settings.profile.save.prompt": "输入配置档案名称(例如: 工作, 个人):", + "settings.profile.save.title": "保存设置", + "settings.profile.save.prompt": "输入设置名称(例如:工作、客户A):", "settings.select_directory.title": "选择允许目录", "settings.select_log_file.title": "选择日志文件", - "settings.export_config.title": "导出配置", - "settings.import_config.title": "导入配置", + "settings.export_config.title": "导出设置", + "settings.import_config.title": "导入设置", "settings.error.select_mapping": "请选择要编辑的映射", - "settings.error.select_profile_load": "请选择要加载的档案", - "settings.error.select_profile_delete": "请选择要删除的档案", + "settings.error.select_profile_load": "请选择要加载的设置", + "settings.error.select_profile_delete": "请选择要删除的设置", "settings.web_source.url_label": "Web 应用 URL:", "settings.web_source.open_btn": "打开", "settings.web_source.url_mappings_label": "URL 映射(Azure Blob Storage → 本地路径):", @@ -154,12 +164,12 @@ "settings.paths.select_dir_title": "选择允许目录", "settings.urls.add_title": "添加 URL", "settings.urls.add_prompt": "输入 URL 模式(例如: http://example.com 或 http://*.example.com):", - "settings.profiles.save_title": "保存档案", - "settings.profiles.save_prompt": "输入配置档案名称(例如: 工作, 个人):", - "settings.profiles.select_to_load": "请选择要加载的档案", - "settings.profiles.select_to_delete": "请选择要删除的档案", - "settings.profiles.export_title": "导出配置", - "settings.profiles.import_title": "导入配置", + "settings.profiles.save_title": "保存设置", + "settings.profiles.save_prompt": "输入设置名称(例如:工作、客户A):", + "settings.profiles.select_to_load": "请选择要加载的设置", + "settings.profiles.select_to_delete": "请选择要删除的设置", + "settings.profiles.export_title": "导出设置", + "settings.profiles.import_title": "导入设置", "update.checking.title": "检查更新", "update.checking.label": "正在检查更新...", diff --git a/src/webdrop_bridge/ui/settings_dialog.py b/src/webdrop_bridge/ui/settings_dialog.py index 99f5241..1da3eaa 100644 --- a/src/webdrop_bridge/ui/settings_dialog.py +++ b/src/webdrop_bridge/ui/settings_dialog.py @@ -396,41 +396,58 @@ class SettingsDialog(QDialog): return widget def _create_profiles_tab(self) -> QWidget: - """Create profiles management tab.""" + """Create setups/import-export tab with clearer guidance.""" widget = QWidget() layout = QVBoxLayout() - layout.addWidget(QLabel(tr("settings.profiles.label"))) + saved_setups_label = QLabel(tr("settings.profiles.label")) + saved_setups_label.setToolTip(tr("settings.profiles.label_tooltip")) + layout.addWidget(saved_setups_label) + + self.profiles_help_label = QLabel(tr("settings.profiles.help_text")) + self.profiles_help_label.setWordWrap(True) + self.profiles_help_label.setStyleSheet("color: gray; font-size: 11px;") + layout.addWidget(self.profiles_help_label) self.profiles_list = QListWidget() + self.profiles_list.setToolTip(tr("settings.profiles.list_tooltip")) self._refresh_profiles_list() layout.addWidget(self.profiles_list) button_layout = QHBoxLayout() - save_profile_btn = QPushButton(tr("settings.profiles.save_btn")) - save_profile_btn.clicked.connect(self._save_profile) - button_layout.addWidget(save_profile_btn) + self.save_profile_btn = QPushButton(tr("settings.profiles.save_btn")) + self.save_profile_btn.setToolTip(tr("settings.profiles.save_tooltip")) + self.save_profile_btn.clicked.connect(self._save_profile) + button_layout.addWidget(self.save_profile_btn) - load_profile_btn = QPushButton(tr("settings.profiles.load_btn")) - load_profile_btn.clicked.connect(self._load_profile) - button_layout.addWidget(load_profile_btn) + self.load_profile_btn = QPushButton(tr("settings.profiles.load_btn")) + self.load_profile_btn.setToolTip(tr("settings.profiles.load_tooltip")) + self.load_profile_btn.clicked.connect(self._load_profile) + button_layout.addWidget(self.load_profile_btn) - delete_profile_btn = QPushButton(tr("settings.profiles.delete_btn")) - delete_profile_btn.clicked.connect(self._delete_profile) - button_layout.addWidget(delete_profile_btn) + self.delete_profile_btn = QPushButton(tr("settings.profiles.delete_btn")) + self.delete_profile_btn.setToolTip(tr("settings.profiles.delete_tooltip")) + self.delete_profile_btn.clicked.connect(self._delete_profile) + button_layout.addWidget(self.delete_profile_btn) layout.addLayout(button_layout) + export_label = QLabel(tr("settings.profiles.transfer_label")) + export_label.setToolTip(tr("settings.profiles.transfer_tooltip")) + layout.addWidget(export_label) + export_layout = QHBoxLayout() - export_btn = QPushButton(tr("settings.profiles.export_btn")) - export_btn.clicked.connect(self._export_config) - export_layout.addWidget(export_btn) + self.export_btn = QPushButton(tr("settings.profiles.export_btn")) + self.export_btn.setToolTip(tr("settings.profiles.export_tooltip")) + self.export_btn.clicked.connect(self._export_config) + export_layout.addWidget(self.export_btn) - import_btn = QPushButton(tr("settings.profiles.import_btn")) - import_btn.clicked.connect(self._import_config) - export_layout.addWidget(import_btn) + self.import_btn = QPushButton(tr("settings.profiles.import_btn")) + self.import_btn.setToolTip(tr("settings.profiles.import_tooltip")) + self.import_btn.clicked.connect(self._import_config) + export_layout.addWidget(self.import_btn) layout.addLayout(export_layout) layout.addStretch() diff --git a/tests/unit/test_settings_dialog.py b/tests/unit/test_settings_dialog.py index 06d78d0..1ff1773 100644 --- a/tests/unit/test_settings_dialog.py +++ b/tests/unit/test_settings_dialog.py @@ -89,11 +89,20 @@ class TestSettingsDialogInitialization: assert dialog.tabs.tabText(5) == "Window" def test_dialog_has_profiles_tab(self, qtbot, sample_config): - """Test Profiles tab exists.""" + """Test Setups tab exists with clearer wording.""" dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) - assert dialog.tabs.tabText(6) == "Profiles" + assert dialog.tabs.tabText(6) == "Setups" + + def test_profiles_actions_have_explanatory_tooltips(self, qtbot, sample_config): + """Test profile/config actions expose helpful explanations.""" + dialog = SettingsDialog(sample_config) + qtbot.addWidget(dialog) + + assert "this device" in dialog.save_profile_btn.toolTip().lower() + assert "backup" in dialog.export_btn.toolTip().lower() + assert "json" in dialog.import_btn.toolTip().lower() class TestPathsTab: @@ -190,7 +199,7 @@ class TestWindowTab: class TestProfilesTab: - """Test Profiles management tab.""" + """Test profiles management tab.""" def test_profiles_list_initialized(self, qtbot, sample_config): """Test profiles list is initialized.""" @@ -198,6 +207,7 @@ class TestProfilesTab: qtbot.addWidget(dialog) assert dialog.profiles_list is not None + assert dialog.profiles_help_label.wordWrap() is True class TestConfigDataRetrieval: From ca7105a6bc9d7752b401b8adf50f3f61f9b02ebd Mon Sep 17 00:00:00 2001 From: claudi Date: Wed, 15 Apr 2026 11:01:49 +0200 Subject: [PATCH 02/10] feat: Implement runtime branding management and add branding settings to UI --- resources/translations/de.json | 5 + resources/translations/en.json | 5 + src/webdrop_bridge/config.py | 51 ++++- src/webdrop_bridge/core/branding_manager.py | 240 ++++++++++++++++++++ src/webdrop_bridge/ui/main_window.py | 105 +++++---- src/webdrop_bridge/ui/settings_dialog.py | 53 +++++ tests/unit/test_branding_manager.py | 74 ++++++ tests/unit/test_settings_dialog.py | 24 +- 8 files changed, 493 insertions(+), 64 deletions(-) create mode 100644 src/webdrop_bridge/core/branding_manager.py create mode 100644 tests/unit/test_branding_manager.py diff --git a/resources/translations/de.json b/resources/translations/de.json index 7be1ed0..1597ddd 100644 --- a/resources/translations/de.json +++ b/resources/translations/de.json @@ -86,6 +86,11 @@ "settings.tab.window": "Fenster", "settings.tab.profiles": "Setups", "settings.tab.general": "Allgemein", + "settings.tab.branding": "Branding", + "settings.branding.select_label": "Branding-Vorlage:", + "settings.branding.select_tooltip": "Wählen Sie die Branding-Vorlage, die beim Start automatisch geladen werden soll.", + "settings.branding.help_text": "Branding steuert die visuelle Identität der App, zum Beispiel Name und Icons. Es ist klar von den gespeicherten Setups getrennt.", + "settings.branding.restart_note": "Branding-Änderungen werden persistent gespeichert und nach einem Neustart vollständig angewendet.", "settings.web_url.label": "Web-Anwendungs-URL:", "settings.web_url.placeholder": "z.B. http://localhost:8080 oder file:///./webapp/index.html", "settings.web_url.open_btn": "\u00d6ffnen", diff --git a/resources/translations/en.json b/resources/translations/en.json index 57798d5..1638741 100644 --- a/resources/translations/en.json +++ b/resources/translations/en.json @@ -86,6 +86,11 @@ "settings.tab.window": "Window", "settings.tab.profiles": "Setups", "settings.tab.general": "General", + "settings.tab.branding": "Branding", + "settings.branding.select_label": "Branding template:", + "settings.branding.select_tooltip": "Choose the branding template that should be loaded automatically on startup.", + "settings.branding.help_text": "Branding controls the visual identity of the app, such as name and icons. It is kept separate from your saved setups.", + "settings.branding.restart_note": "Branding changes are persisted immediately and are fully applied after restarting the app.", "settings.web_url.label": "Web Application URL:", "settings.web_url.placeholder": "e.g., http://localhost:8080 or file:///./webapp/index.html", "settings.web_url.open_btn": "Open", diff --git a/src/webdrop_bridge/config.py b/src/webdrop_bridge/config.py index 12bdeb7..d8a5740 100644 --- a/src/webdrop_bridge/config.py +++ b/src/webdrop_bridge/config.py @@ -18,6 +18,12 @@ DEFAULT_UPDATE_BASE_URL = "https://git.him-tools.de" DEFAULT_UPDATE_REPO = "HIM-public/webdrop-bridge" DEFAULT_UPDATE_CHANNEL = "stable" DEFAULT_UPDATE_MANIFEST_NAME = "release-manifest.json" +DEFAULT_ACTIVE_BRANDING_ID = "default" +DEFAULT_APP_ICON_PATH = "resources/icons/app.ico" +DEFAULT_TOOLBAR_ICON_HOME = "resources/icons/home.ico" +DEFAULT_TOOLBAR_ICON_RELOAD = "resources/icons/reload.ico" +DEFAULT_TOOLBAR_ICON_OPEN = "resources/icons/open.ico" +DEFAULT_TOOLBAR_ICON_OPENWITH = "resources/icons/openwith.ico" class ConfigurationError(Exception): @@ -96,6 +102,14 @@ class Config: enable_logging: bool = True enable_checkout: bool = False language: str = "auto" + active_branding_id: str = DEFAULT_ACTIVE_BRANDING_ID + branding_display_name: str = "Default" + logo_path: str = "" + app_icon_path: str = DEFAULT_APP_ICON_PATH + toolbar_icon_home: str = DEFAULT_TOOLBAR_ICON_HOME + toolbar_icon_reload: str = DEFAULT_TOOLBAR_ICON_RELOAD + toolbar_icon_open: str = DEFAULT_TOOLBAR_ICON_OPEN + toolbar_icon_openwith: str = DEFAULT_TOOLBAR_ICON_OPENWITH brand_id: str = DEFAULT_BRAND_ID config_dir_name: str = DEFAULT_CONFIG_DIR_NAME update_base_url: str = DEFAULT_UPDATE_BASE_URL @@ -179,7 +193,7 @@ class Config: # No window title specified, use default window_title = f"{app_name} v{__version__}" - return cls( + config = cls( app_name=app_name, app_version=__version__, log_level=data.get("log_level", "INFO").upper(), @@ -197,6 +211,13 @@ class Config: enable_logging=data.get("enable_logging", True), enable_checkout=data.get("enable_checkout", False), language=data.get("language", "auto"), + active_branding_id=data.get("active_branding_id", DEFAULT_ACTIVE_BRANDING_ID), + logo_path=data.get("logo_path", ""), + app_icon_path=data.get("app_icon_path", DEFAULT_APP_ICON_PATH), + toolbar_icon_home=data.get("toolbar_icon_home", DEFAULT_TOOLBAR_ICON_HOME), + toolbar_icon_reload=data.get("toolbar_icon_reload", DEFAULT_TOOLBAR_ICON_RELOAD), + toolbar_icon_open=data.get("toolbar_icon_open", DEFAULT_TOOLBAR_ICON_OPEN), + toolbar_icon_openwith=data.get("toolbar_icon_openwith", DEFAULT_TOOLBAR_ICON_OPENWITH), brand_id=brand_id, config_dir_name=config_dir_name, update_base_url=data.get("update_base_url", DEFAULT_UPDATE_BASE_URL), @@ -204,6 +225,7 @@ class Config: update_channel=data.get("update_channel", DEFAULT_UPDATE_CHANNEL), update_manifest_name=data.get("update_manifest_name", DEFAULT_UPDATE_MANIFEST_NAME), ) + return cls._apply_runtime_branding(config) @classmethod def from_env(cls, env_file: str | None = None) -> "Config": @@ -246,6 +268,12 @@ class Config: enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true" enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true" language = os.getenv("LANGUAGE", "auto") + active_branding_id = os.getenv("BRAND_TEMPLATE", DEFAULT_ACTIVE_BRANDING_ID) + app_icon_path = os.getenv("APP_ICON_PATH", DEFAULT_APP_ICON_PATH) + toolbar_icon_home = os.getenv("TOOLBAR_ICON_HOME", DEFAULT_TOOLBAR_ICON_HOME) + toolbar_icon_reload = os.getenv("TOOLBAR_ICON_RELOAD", DEFAULT_TOOLBAR_ICON_RELOAD) + toolbar_icon_open = os.getenv("TOOLBAR_ICON_OPEN", DEFAULT_TOOLBAR_ICON_OPEN) + toolbar_icon_openwith = os.getenv("TOOLBAR_ICON_OPENWITH", DEFAULT_TOOLBAR_ICON_OPENWITH) update_base_url = os.getenv("UPDATE_BASE_URL", DEFAULT_UPDATE_BASE_URL) update_repo = os.getenv("UPDATE_REPO", DEFAULT_UPDATE_REPO) update_channel = os.getenv("UPDATE_CHANNEL", DEFAULT_UPDATE_CHANNEL) @@ -328,7 +356,7 @@ class Config: f"Invalid URL_MAPPINGS: {url_mappings_str}. Error: {e}" ) from e - return cls( + config = cls( app_name=app_name, app_version=app_version, log_level=log_level, @@ -343,6 +371,12 @@ class Config: enable_logging=enable_logging, enable_checkout=enable_checkout, language=language, + active_branding_id=active_branding_id, + app_icon_path=app_icon_path, + toolbar_icon_home=toolbar_icon_home, + toolbar_icon_reload=toolbar_icon_reload, + toolbar_icon_open=toolbar_icon_open, + toolbar_icon_openwith=toolbar_icon_openwith, brand_id=brand_id, config_dir_name=config_dir_name, update_base_url=update_base_url, @@ -350,6 +384,7 @@ class Config: update_channel=update_channel, update_manifest_name=update_manifest_name, ) + return cls._apply_runtime_branding(config) def to_file(self, config_path: Path) -> None: """Save configuration to JSON file. @@ -378,6 +413,7 @@ class Config: "enable_logging": self.enable_logging, "enable_checkout": self.enable_checkout, "language": self.language, + "active_branding_id": self.active_branding_id, "brand_id": self.brand_id, "config_dir_name": self.config_dir_name, "update_base_url": self.update_base_url, @@ -390,6 +426,17 @@ class Config: with open(config_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) + @staticmethod + def _apply_runtime_branding(config: "Config") -> "Config": + """Apply the persisted runtime branding template to cosmetic fields.""" + try: + from webdrop_bridge.core.branding_manager import BrandingManager + + BrandingManager().apply_to_config(config) + except Exception as e: + logger.warning(f"Failed to apply runtime branding: {e}") + return config + @staticmethod def load_bootstrap_env(env_file: str | None = None) -> Path | None: """Load a bootstrap .env before configuration path lookup. diff --git a/src/webdrop_bridge/core/branding_manager.py b/src/webdrop_bridge/core/branding_manager.py new file mode 100644 index 0000000..6fad03e --- /dev/null +++ b/src/webdrop_bridge/core/branding_manager.py @@ -0,0 +1,240 @@ +"""Runtime branding template management for the shared application.""" + +from __future__ import annotations + +import json +import logging +import os +import platform +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +from webdrop_bridge.config import DEFAULT_CONFIG_DIR_NAME, Config, ConfigurationError + +logger = logging.getLogger(__name__) + +DEFAULT_BRANDING_TEMPLATE_ID = "default" + + +@dataclass(frozen=True) +class BrandingTemplate: + """Serializable runtime branding template.""" + + template_id: str + display_name: str + app_name: str + window_title: str = "" + logo_path: str = "" + app_icon_path_windows: str = "resources/icons/app.ico" + app_icon_path_macos: str = "resources/icons/app.icns" + toolbar_icon_home: str = "resources/icons/home.ico" + toolbar_icon_reload: str = "resources/icons/reload.ico" + toolbar_icon_open: str = "resources/icons/open.ico" + toolbar_icon_openwith: str = "resources/icons/openwith.ico" + accent_color: str = "#667eea" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "BrandingTemplate": + """Create a template from a JSON-compatible dictionary.""" + template_id = str(data.get("template_id") or data.get("id") or "").strip() + display_name = str(data.get("display_name") or template_id or "").strip() + app_name = str(data.get("app_name") or display_name or "").strip() + + if not template_id: + raise ConfigurationError("Branding template requires a template_id") + if not display_name: + raise ConfigurationError("Branding template requires a display_name") + if not app_name: + raise ConfigurationError("Branding template requires an app_name") + + return cls( + template_id=template_id, + display_name=display_name, + app_name=app_name, + window_title=str(data.get("window_title", "")), + logo_path=str(data.get("logo_path", "")), + app_icon_path_windows=str(data.get("app_icon_path_windows", "resources/icons/app.ico")), + app_icon_path_macos=str(data.get("app_icon_path_macos", "resources/icons/app.icns")), + toolbar_icon_home=str(data.get("toolbar_icon_home", "resources/icons/home.ico")), + toolbar_icon_reload=str(data.get("toolbar_icon_reload", "resources/icons/reload.ico")), + toolbar_icon_open=str(data.get("toolbar_icon_open", "resources/icons/open.ico")), + toolbar_icon_openwith=str( + data.get("toolbar_icon_openwith", "resources/icons/openwith.ico") + ), + accent_color=str(data.get("accent_color", "#667eea")), + ) + + def to_dict(self) -> dict[str, Any]: + """Convert the template to a JSON-compatible dictionary.""" + return asdict(self) + + def get_app_icon_path(self) -> str: + """Return the best app icon path for the current platform.""" + if platform.system() == "Darwin": + return self.app_icon_path_macos + return self.app_icon_path_windows + + +BUILTIN_BRANDING_TEMPLATES: dict[str, BrandingTemplate] = { + "default": BrandingTemplate( + template_id="default", + display_name="Default", + app_name="WebDrop Bridge", + window_title="", + accent_color="#667eea", + ), + "agravity": BrandingTemplate( + template_id="agravity", + display_name="Agravity", + app_name="Agravity Bridge", + window_title="", + accent_color="#2d7d6e", + ), +} + + +class BrandingManager: + """Manage runtime branding templates independently from saved setups.""" + + def __init__(self, base_dir: Path | None = None) -> None: + env_dir = os.getenv("WEBDROP_BRANDING_DIR") + resolved_base = Path(env_dir).resolve() if env_dir and base_dir is None else base_dir + self.base_dir = resolved_base or self._default_base_dir() + self.templates_dir = self.base_dir / "templates" + self.active_branding_path = self.base_dir / "active_branding.json" + self.ensure_builtin_templates() + + @staticmethod + def _default_base_dir() -> Path: + """Return the shared branding storage directory.""" + return Config.get_default_config_path(DEFAULT_CONFIG_DIR_NAME).parent / "branding" + + def ensure_builtin_templates(self) -> None: + """Ensure built-in templates exist on disk for discovery and later editing.""" + self.templates_dir.mkdir(parents=True, exist_ok=True) + + for template in BUILTIN_BRANDING_TEMPLATES.values(): + template_path = self.templates_dir / f"{template.template_id}.json" + if not template_path.exists(): + template_path.write_text(json.dumps(template.to_dict(), indent=2), encoding="utf-8") + + def list_templates(self) -> list[BrandingTemplate]: + """List all available templates with built-ins guaranteed.""" + self.ensure_builtin_templates() + + templates: dict[str, BrandingTemplate] = {} + for template_path in self.templates_dir.glob("*.json"): + try: + data = json.loads(template_path.read_text(encoding="utf-8")) + template = BrandingTemplate.from_dict(data) + templates[template.template_id] = template + except (OSError, json.JSONDecodeError, ConfigurationError) as exc: + logger.warning("Skipping invalid branding template %s: %s", template_path, exc) + + for template_id, template in BUILTIN_BRANDING_TEMPLATES.items(): + templates.setdefault(template_id, template) + + ordered_templates = sorted( + templates.values(), + key=lambda template: ( + template.template_id != DEFAULT_BRANDING_TEMPLATE_ID, + template.display_name.lower(), + ), + ) + return ordered_templates + + def has_template(self, template_id: str) -> bool: + """Return whether a template with the given id exists.""" + return any(template.template_id == template_id for template in self.list_templates()) + + def load_template(self, template_id: str) -> BrandingTemplate: + """Load a template by id, falling back to the default template if missing.""" + for template in self.list_templates(): + if template.template_id == template_id: + return template + + logger.warning("Branding template '%s' not found. Falling back to default.", template_id) + return BUILTIN_BRANDING_TEMPLATES[DEFAULT_BRANDING_TEMPLATE_ID] + + def save_template(self, template: BrandingTemplate) -> Path: + """Save or update a branding template on disk.""" + if not template.template_id: + raise ConfigurationError("Branding template requires a template_id") + + self.templates_dir.mkdir(parents=True, exist_ok=True) + template_path = self.templates_dir / f"{template.template_id}.json" + template_path.write_text(json.dumps(template.to_dict(), indent=2), encoding="utf-8") + logger.info("Branding template saved: %s", template.template_id) + return template_path + + def delete_template(self, template_id: str) -> None: + """Delete a user template while protecting built-ins.""" + if template_id in BUILTIN_BRANDING_TEMPLATES: + raise ConfigurationError(f"Cannot delete built-in branding template: {template_id}") + + template_path = self.templates_dir / f"{template_id}.json" + if not template_path.exists(): + raise ConfigurationError(f"Branding template not found: {template_id}") + + template_path.unlink() + logger.info("Branding template deleted: %s", template_id) + + def get_active_branding_id(self) -> str: + """Return the persisted active branding selection.""" + if not self.active_branding_path.exists(): + return DEFAULT_BRANDING_TEMPLATE_ID + + try: + data = json.loads(self.active_branding_path.read_text(encoding="utf-8")) + template_id = str(data.get("active_branding_id", DEFAULT_BRANDING_TEMPLATE_ID)) + return template_id if self.has_template(template_id) else DEFAULT_BRANDING_TEMPLATE_ID + except (OSError, json.JSONDecodeError): + return DEFAULT_BRANDING_TEMPLATE_ID + + def set_active_branding_id(self, template_id: str) -> None: + """Persist the active branding selection.""" + if not self.has_template(template_id): + raise ConfigurationError(f"Branding template not found: {template_id}") + + self.base_dir.mkdir(parents=True, exist_ok=True) + self.active_branding_path.write_text( + json.dumps({"active_branding_id": template_id}, indent=2), + encoding="utf-8", + ) + logger.info("Active branding set to %s", template_id) + + def apply_to_config(self, config: Config) -> BrandingTemplate: + """Apply the active branding template to cosmetic config fields only.""" + requested_id = (getattr(config, "active_branding_id", "") or "").strip() + if not requested_id or requested_id == DEFAULT_BRANDING_TEMPLATE_ID: + requested_id = self.get_active_branding_id() + + template = self.load_template(requested_id) + is_non_default_branding = template.template_id != DEFAULT_BRANDING_TEMPLATE_ID + default_app_name = BUILTIN_BRANDING_TEMPLATES[DEFAULT_BRANDING_TEMPLATE_ID].app_name + + config.active_branding_id = template.template_id + config.branding_display_name = template.display_name + + if is_non_default_branding or not config.app_name or config.app_name == default_app_name: + config.app_name = template.app_name + + if ( + is_non_default_branding + or not config.window_title + or config.window_title.startswith(f"{default_app_name} v") + or config.window_title.startswith(f"{config.app_name} v") + ): + config.window_title = ( + template.window_title or f"{config.app_name} v{config.app_version}" + ) + + config.logo_path = template.logo_path + config.app_icon_path = template.get_app_icon_path() + config.toolbar_icon_home = template.toolbar_icon_home + config.toolbar_icon_reload = template.toolbar_icon_reload + config.toolbar_icon_open = template.toolbar_icon_open + config.toolbar_icon_openwith = template.toolbar_icon_openwith + + return template diff --git a/src/webdrop_bridge/ui/main_window.py b/src/webdrop_bridge/ui/main_window.py index f75d872..74ecb97 100644 --- a/src/webdrop_bridge/ui/main_window.py +++ b/src/webdrop_bridge/ui/main_window.py @@ -427,7 +427,9 @@ class MainWindow(QMainWindow): self._background_threads = [] # Keep references to background threads self._background_workers = {} # Keep references to background workers self._bridge_script_source = "" # Cache combined bridge source for recovery injection - self._bridge_script_re_registered = False # Flag to prevent duplicate re-registration on same load + self._bridge_script_re_registered = ( + False # Flag to prevent duplicate re-registration on same load + ) self._is_page_loading = False # Track if a page load is currently in progress self._pending_reload = False # Coalesce multiple rapid reload requests into one self._load_sequence = 0 # Monotonic counter to ignore stale async recovery callbacks @@ -444,22 +446,13 @@ class MainWindow(QMainWindow): config.window_height, ) - # Set window icon - # Support both development mode and PyInstaller bundle - if hasattr(sys, "_MEIPASS"): - # Running as PyInstaller bundle - icon_path = Path(sys._MEIPASS) / "resources" / "icons" / "app.ico" # type: ignore - else: - # Running in development mode - icon_path = ( - Path(__file__).parent.parent.parent.parent / "resources" / "icons" / "app.ico" - ) - - if icon_path.exists(): + # Set window icon from the active runtime branding + icon_path = self._resolve_toolbar_icon_path(config.app_icon_path) + if icon_path is not None: self.setWindowIcon(QIcon(str(icon_path))) logger.debug(f"Window icon set from {icon_path}") else: - logger.warning(f"Window icon not found at {icon_path}") + logger.warning(f"Window icon not found for configured path: {config.app_icon_path}") # Create web engine view with URL for profile isolation self.web_view = RestrictedWebEngineView( @@ -1189,7 +1182,9 @@ class MainWindow(QMainWindow): # This more reliably opens files with chosen applications. # Use a simple, more direct approach # Get the chosen app via AppleScript, then use open command - get_app_script = '''choose application with title "Select an application to open the file"''' + get_app_script = ( + '''choose application with title "Select an application to open the file"''' + ) try: # Get the chosen application app_result = subprocess.run( @@ -1199,19 +1194,21 @@ class MainWindow(QMainWindow): text=True, timeout=30, ) - + if app_result.returncode != 0: - logger.warning(f"User cancelled app chooser or error occurred: {app_result.stderr}") + logger.warning( + f"User cancelled app chooser or error occurred: {app_result.stderr}" + ) return False - + # Get the application name (strip whitespace) chosen_app = app_result.stdout.strip() if not chosen_app: logger.warning("No application was selected") return False - + logger.info(f"User selected app: {chosen_app}") - + # Now open the file with the chosen app using the 'open' command open_result = subprocess.run( ["open", "-a", chosen_app, normalized_path], @@ -1220,14 +1217,16 @@ class MainWindow(QMainWindow): text=True, timeout=10, ) - + if open_result.returncode == 0: logger.info(f"Opened '{normalized_path}' with '{chosen_app}'") return True else: - logger.warning(f"Failed to open file with '{chosen_app}': {open_result.stderr}") + logger.warning( + f"Failed to open file with '{chosen_app}': {open_result.stderr}" + ) return False - + except subprocess.TimeoutExpired: logger.warning("App chooser timed out") return False @@ -1393,7 +1392,7 @@ class MainWindow(QMainWindow): Re-registers the bridge script to ensure it will be injected on reload, page navigation, or any load event. - + Uses a flag to prevent duplicate re-registrations if loadStarted fires multiple times. """ self._is_page_loading = True @@ -1412,7 +1411,7 @@ class MainWindow(QMainWindow): Checks if the bridge script was successfully injected, with automatic recovery for page reloads and redirects. - + Resets the re-registration flag for the next load cycle. Args: @@ -1433,9 +1432,11 @@ class MainWindow(QMainWindow): logger.warning("Page failed to load") return - def _verify_bridge_loaded(stage: str, attempt: int = 1, sequence: int = finished_sequence) -> None: + def _verify_bridge_loaded( + stage: str, attempt: int = 1, sequence: int = finished_sequence + ) -> None: """Check if bridge marker exists and optionally recover script injection. - + Implements multi-attempt recovery strategy: - initial: First check after page load (50ms delay) - recovery_N: Recovery attempts with progressive delays @@ -1485,9 +1486,7 @@ class MainWindow(QMainWindow): delay = int(100 * (1.5 ** (attempt - 1))) QTimer.singleShot( delay, - lambda: _verify_bridge_loaded( - "recovery", attempt + 1, sequence - ), + lambda: _verify_bridge_loaded("recovery", attempt + 1, sequence), ) self.web_view.page().runJavaScript(self._bridge_script_source, after_retry) @@ -1507,11 +1506,15 @@ class MainWindow(QMainWindow): ) self._re_register_bridge_script() - self.web_view.page().runJavaScript(self._bridge_script_source, after_re_register) + self.web_view.page().runJavaScript( + self._bridge_script_source, after_re_register + ) return # All recovery attempts exhausted - logger.error("❌ WebDrop Bridge script failed to inject after all recovery attempts!") + logger.error( + "❌ WebDrop Bridge script failed to inject after all recovery attempts!" + ) logger.error(" Drag-and-drop functionality is DISABLED") logger.debug(f" Stage: {stage}, Attempt: {attempt}") @@ -1543,21 +1546,21 @@ class MainWindow(QMainWindow): def _ensure_bridge_script_exists(self, verbose: bool = False) -> None: """Ensure bridge script exists in QWebEngineScript collection (idempotent). - + Checks if the script already exists. If not, adds it. Never removes/re-adds to avoid race conditions with Qt's injection mechanism. - + This is safer than removing+re-adding because: - Avoids concurrent access conflicts with Qt's internal injection - Prevents missing injections during rapid reloads - Guarantees script is available without timing gaps - + Args: verbose: If True, use debug logging; otherwise use minimal logging """ try: scripts = self.web_view.page().scripts() - + # Check if script already exists already_exists = False for script in scripts.toList(): # type: ignore @@ -1566,7 +1569,7 @@ class MainWindow(QMainWindow): if verbose: logger.debug("Bridge script already exists in page().scripts()") break - + # If script doesn't exist, add it if not already_exists and self._bridge_script_source: new_script = QWebEngineScript() @@ -1582,16 +1585,18 @@ class MainWindow(QMainWindow): new_script.setSourceCode(self._bridge_script_source) scripts.insert(new_script) - logger.debug(f"✓ Added bridge script to collection ({len(self._bridge_script_source)} chars)") + logger.debug( + f"✓ Added bridge script to collection ({len(self._bridge_script_source)} chars)" + ) except Exception as e: logger.error(f"Failed to ensure bridge script exists: {e}") def _re_register_bridge_script(self, verbose: bool = False) -> None: """Force re-registration of bridge script in QWebEngineScript collection. - + Removes old script and re-adds it to ensure it's injected on next page load. This is a fallback for recovery mechanics when normal injection fails. - + Args: verbose: If True, use debug logging; otherwise use minimal logging """ @@ -1622,7 +1627,9 @@ class MainWindow(QMainWindow): scripts.insert(new_script) if verbose or removed: - logger.debug(f"✓ Re-registered webdrop-bridge script ({len(self._bridge_script_source)} chars)") + logger.debug( + f"✓ Re-registered webdrop-bridge script ({len(self._bridge_script_source)} chars)" + ) except Exception as e: logger.error(f"Failed to re-register bridge script: {e}") @@ -1649,9 +1656,7 @@ class MainWindow(QMainWindow): toolbar.addSeparator() # Home button - home_icon_path = self._resolve_toolbar_icon_path( - os.getenv("TOOLBAR_ICON_HOME", "resources/icons/home.ico") - ) + home_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_home) home_icon = ( QIcon(str(home_icon_path)) if home_icon_path is not None @@ -1663,9 +1668,7 @@ class MainWindow(QMainWindow): # Refresh button refresh_action = toolbar.addAction("") - reload_icon_path = self._resolve_toolbar_icon_path( - os.getenv("TOOLBAR_ICON_RELOAD", "resources/icons/reload.ico") - ) + reload_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_reload) if reload_icon_path is not None: refresh_action.setIcon(QIcon(str(reload_icon_path))) else: @@ -1677,9 +1680,7 @@ class MainWindow(QMainWindow): # Open-with-default-app drop zone (right of Reload) self._open_drop_zone = OpenDropZone() - open_icon_path = self._resolve_toolbar_icon_path( - os.getenv("TOOLBAR_ICON_OPEN", "resources/icons/open.ico") - ) + open_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_open) if open_icon_path is not None: self._open_drop_zone.set_icon(QIcon(str(open_icon_path))) self._open_drop_zone.file_opened.connect(self._on_file_opened_via_drop) @@ -1690,9 +1691,7 @@ class MainWindow(QMainWindow): # Open-with chooser drop zone (right of Open-with-default-app) self._open_with_drop_zone = OpenWithDropZone() - open_with_icon_path = self._resolve_toolbar_icon_path( - os.getenv("TOOLBAR_ICON_OPENWITH", "resources/icons/openwith.ico") - ) + open_with_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_openwith) if open_with_icon_path is not None: self._open_with_drop_zone.set_icon(QIcon(str(open_with_icon_path))) self._open_with_drop_zone.file_open_with_requested.connect( diff --git a/src/webdrop_bridge/ui/settings_dialog.py b/src/webdrop_bridge/ui/settings_dialog.py index 1da3eaa..c83d65a 100644 --- a/src/webdrop_bridge/ui/settings_dialog.py +++ b/src/webdrop_bridge/ui/settings_dialog.py @@ -23,6 +23,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 +43,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 +56,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 +86,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 +113,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 +168,41 @@ 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")) + for template in self.branding_manager.list_templates(): + self.branding_combo.addItem(template.display_name, template.template_id) + + idx = self.branding_combo.findData(self.config.active_branding_id) + if idx < 0: + idx = self.branding_combo.findData("default") + if idx >= 0: + self.branding_combo.setCurrentIndex(idx) + layout.addWidget(self.branding_combo) + + note = QLabel(tr("settings.branding.restart_note")) + note.setWordWrap(True) + note.setStyleSheet("color: gray; font-size: 11px;") + layout.addWidget(note) + + layout.addStretch() + widget.setLayout(layout) + return widget + def _create_web_source_tab(self) -> QWidget: """Create web source configuration tab.""" widget = QWidget() @@ -623,6 +675,7 @@ class SettingsDialog(QDialog): "app_name": self.config.app_name, "app_version": self.config.app_version, "language": self.language_combo.currentData(), + "active_branding_id": self.branding_combo.currentData(), "log_level": self.log_level_combo.currentText(), "log_file": self.log_file_input.text() or None, "allowed_roots": [ diff --git a/tests/unit/test_branding_manager.py b/tests/unit/test_branding_manager.py new file mode 100644 index 0000000..891ce93 --- /dev/null +++ b/tests/unit/test_branding_manager.py @@ -0,0 +1,74 @@ +"""Tests for runtime branding template management.""" + +from webdrop_bridge.config import Config +from webdrop_bridge.core.branding_manager import BrandingManager + + +def test_builtin_brandings_are_available(tmp_path): + """Built-in default and Agravity templates should always be available.""" + manager = BrandingManager(base_dir=tmp_path) + + brandings = manager.list_templates() + template_ids = [template.template_id for template in brandings] + + assert "default" in template_ids + assert "agravity" in template_ids + + +def test_active_branding_persists_across_manager_instances(tmp_path): + """Selected active branding should persist on disk.""" + manager = BrandingManager(base_dir=tmp_path) + manager.set_active_branding_id("agravity") + + reloaded_manager = BrandingManager(base_dir=tmp_path) + + assert reloaded_manager.get_active_branding_id() == "agravity" + + +def test_apply_branding_updates_cosmetic_fields_only(tmp_path): + """Applying a branding template should not overwrite setup-specific values.""" + allowed_root = tmp_path / "allowed" + allowed_root.mkdir() + + config = Config( + app_name="WebDrop Bridge", + app_version="1.0.0", + log_level="INFO", + log_file=None, + allowed_roots=[allowed_root], + allowed_urls=["example.com"], + webapp_url="http://localhost:8080", + window_width=1024, + window_height=768, + enable_logging=True, + active_branding_id="agravity", + ) + + manager = BrandingManager(base_dir=tmp_path) + manager.apply_to_config(config) + + assert config.active_branding_id == "agravity" + assert config.app_name == "Agravity Bridge" + assert config.webapp_url == "http://localhost:8080" + assert config.allowed_roots == [allowed_root] + assert config.toolbar_icon_home.endswith("home.ico") + + +def test_config_from_env_uses_persisted_active_branding(tmp_path, monkeypatch): + """Config loading should apply the persisted active branding automatically.""" + branding_dir = tmp_path / "branding-state" + manager = BrandingManager(base_dir=branding_dir) + manager.set_active_branding_id("agravity") + + monkeypatch.setenv("WEBDROP_BRANDING_DIR", str(branding_dir)) + + root = tmp_path / "root" + root.mkdir() + env_file = tmp_path / ".env" + env_file.write_text(f"ALLOWED_ROOTS={root}\n", encoding="utf-8") + + config = Config.from_env(str(env_file)) + + assert config.active_branding_id == "agravity" + assert config.app_name == "Agravity Bridge" + assert config.get_config_path().name == "config.json" diff --git a/tests/unit/test_settings_dialog.py b/tests/unit/test_settings_dialog.py index 1ff1773..b63796e 100644 --- a/tests/unit/test_settings_dialog.py +++ b/tests/unit/test_settings_dialog.py @@ -1,11 +1,10 @@ """Tests for settings dialog.""" from pathlib import Path -from unittest.mock import MagicMock, 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 +43,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 +52,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.""" From 2ecd299f314f03f8d77d1c1febcd29025a82c031 Mon Sep 17 00:00:00 2001 From: claudi Date: Wed, 15 Apr 2026 11:49:09 +0200 Subject: [PATCH 03/10] feat: Add branding change prompts and corresponding translations for restart notifications --- resources/translations/de.json | 4 +++ resources/translations/en.json | 4 +++ src/webdrop_bridge/ui/main_window.py | 53 +++++++++++++++++++++++----- tests/unit/test_config.py | 5 ++- tests/unit/test_main_window.py | 28 ++++++++++++--- 5 files changed, 79 insertions(+), 15 deletions(-) diff --git a/resources/translations/de.json b/resources/translations/de.json index 1597ddd..e3162e4 100644 --- a/resources/translations/de.json +++ b/resources/translations/de.json @@ -58,6 +58,10 @@ "dialog.language_changed.msg": "Die Spracheinstellung wurde aktualisiert. Starten Sie jetzt neu, um die ausgew\u00e4hlte Sprache \u00fcberall anzuwenden.", "dialog.language_changed.restart_now": "Jetzt neu starten", "dialog.language_changed.restart_later": "Sp\u00e4ter neu starten", + "dialog.branding_changed.title": "Branding ge\u00e4ndert", + "dialog.branding_changed.msg": "Das aktive Branding wurde geändert. Starten Sie jetzt neu, damit die aktualisierte visuelle Identität überall angewendet wird.", + "dialog.branding_changed.restart_now": "Jetzt neu starten", + "dialog.branding_changed.restart_later": "Sp\u00e4ter neu starten", "dialog.restart_failed.title": "Neustart fehlgeschlagen", "dialog.restart_failed.msg": "Die Anwendung konnte nicht automatisch neu gestartet werden:\n\n{error}\n\nBitte starten Sie manuell neu.", "dialog.update_timeout.title": "Zeitüberschreitung bei der Update-Pr\u00fcfung", diff --git a/resources/translations/en.json b/resources/translations/en.json index 1638741..d92c546 100644 --- a/resources/translations/en.json +++ b/resources/translations/en.json @@ -58,6 +58,10 @@ "dialog.language_changed.msg": "The language setting was updated. Restart now to apply the selected language everywhere.", "dialog.language_changed.restart_now": "Restart Now", "dialog.language_changed.restart_later": "Restart Later", + "dialog.branding_changed.title": "Branding Changed", + "dialog.branding_changed.msg": "The active branding was changed. Restart now so the updated visual identity is applied everywhere.", + "dialog.branding_changed.restart_now": "Restart Now", + "dialog.branding_changed.restart_later": "Restart Later", "dialog.restart_failed.title": "Restart Failed", "dialog.restart_failed.msg": "Could not automatically restart the application:\n\n{error}\n\nPlease restart manually.", "dialog.update_timeout.title": "Update Check Timeout", diff --git a/src/webdrop_bridge/ui/main_window.py b/src/webdrop_bridge/ui/main_window.py index 74ecb97..a32a09e 100644 --- a/src/webdrop_bridge/ui/main_window.py +++ b/src/webdrop_bridge/ui/main_window.py @@ -1958,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) @@ -1966,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}") @@ -1975,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 @@ -1982,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() @@ -2046,21 +2060,42 @@ class MainWindow(QMainWindow): self.web_view.clear_cache_and_cookies() self._navigate_home() + def _handle_branding_change_restart(self) -> None: + """Handle branding change by prompting for an optional restart.""" + self._show_restart_prompt( + title_key="dialog.branding_changed.title", + message_key="dialog.branding_changed.msg", + restart_now_key="dialog.branding_changed.restart_now", + restart_later_key="dialog.branding_changed.restart_later", + ) + def _handle_language_change_restart(self) -> None: """Handle language change by prompting for an optional restart.""" + self._show_restart_prompt( + title_key="dialog.language_changed.title", + message_key="dialog.language_changed.msg", + restart_now_key="dialog.language_changed.restart_now", + restart_later_key="dialog.language_changed.restart_later", + ) + + def _show_restart_prompt( + self, + *, + title_key: str, + message_key: str, + restart_now_key: str, + restart_later_key: str, + ) -> None: + """Show a restart prompt for settings that require a full restart.""" from PySide6.QtWidgets import QMessageBox msg = QMessageBox(self) - msg.setWindowTitle(tr("dialog.language_changed.title")) + msg.setWindowTitle(tr(title_key)) msg.setIcon(QMessageBox.Icon.Information) - msg.setText(tr("dialog.language_changed.msg")) + msg.setText(tr(message_key)) - restart_now_btn = msg.addButton( - tr("dialog.language_changed.restart_now"), QMessageBox.ButtonRole.AcceptRole - ) - msg.addButton( - tr("dialog.language_changed.restart_later"), QMessageBox.ButtonRole.RejectRole - ) + restart_now_btn = msg.addButton(tr(restart_now_key), QMessageBox.ButtonRole.AcceptRole) + msg.addButton(tr(restart_later_key), QMessageBox.ButtonRole.RejectRole) msg.exec() diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 09e4d6d..065caee 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -9,11 +9,14 @@ from webdrop_bridge.config import Config, ConfigurationError @pytest.fixture(autouse=True) -def clear_env(): +def clear_env(tmp_path): """Clear environment variables before each test to avoid persistence.""" # Save current env saved_env = os.environ.copy() + # Isolate runtime branding state from the developer machine + os.environ["WEBDROP_BRANDING_DIR"] = str(tmp_path / "branding") + # Clear relevant variables for key in list(os.environ.keys()): if key.startswith( diff --git a/tests/unit/test_main_window.py b/tests/unit/test_main_window.py index be89ab6..1fa7d66 100644 --- a/tests/unit/test_main_window.py +++ b/tests/unit/test_main_window.py @@ -82,6 +82,25 @@ class TestMainWindowInitialization: assert window.drag_interceptor is not None +class TestSettingsRestartBehavior: + """Test restart prompts for settings changes that require a restart.""" + + def test_branding_change_prompts_restart(self, qtbot, sample_config): + """Changing the active branding should trigger the restart flow.""" + window = MainWindow(sample_config) + qtbot.addWidget(window) + + with patch.object(window, "_handle_branding_change_restart") as mock_restart: + with patch("webdrop_bridge.ui.settings_dialog.SettingsDialog") as mock_dialog_cls: + mock_dialog = mock_dialog_cls.return_value + mock_dialog.exec.side_effect = lambda: setattr( + window.config, "active_branding_id", "agravity" + ) + window._show_settings_dialog() + + mock_restart.assert_called_once() + + class TestMainWindowDragIntegration: """Test drag-and-drop integration.""" @@ -207,15 +226,15 @@ class TestMainWindowOpenWith: test_file.write_text("test") call_count = [0] # Use list to make it mutable in nested function - + class _AppChooseResult: returncode = 0 stdout = "TextEdit" # Simulated chosen app name - + class _OpenResult: returncode = 0 stdout = "" - + def mock_run(*args, **kwargs): """Mock subprocess.run with two different behaviors per call.""" call_count[0] += 1 @@ -227,8 +246,7 @@ class TestMainWindowOpenWith: return _OpenResult() else: raise AssertionError(f"Unexpected call #{call_count[0]} to subprocess.run") - - + with patch("webdrop_bridge.ui.main_window.sys.platform", "darwin"): with patch("webdrop_bridge.ui.main_window.subprocess.run", side_effect=mock_run): assert window._open_with_app_chooser(str(test_file)) is True From fe341163e81993d5ee251fae02cf5cc4400e3eab Mon Sep 17 00:00:00 2001 From: claudi Date: Wed, 15 Apr 2026 12:15:35 +0200 Subject: [PATCH 04/10] feat: Add branding change prompts and settings translations for multiple languages --- resources/translations/fr.json | 9 +++++++++ resources/translations/it.json | 9 +++++++++ resources/translations/ru.json | 9 +++++++++ resources/translations/zh.json | 9 +++++++++ 4 files changed, 36 insertions(+) diff --git a/resources/translations/fr.json b/resources/translations/fr.json index d024e21..92c970a 100644 --- a/resources/translations/fr.json +++ b/resources/translations/fr.json @@ -58,6 +58,10 @@ "dialog.language_changed.msg": "Le param\u00e8tre de langue a \u00e9t\u00e9 mis \u00e0 jour. Red\u00e9marrez maintenant pour appliquer la langue s\u00e9lectionn\u00e9e partout.", "dialog.language_changed.restart_now": "Red\u00e9marrer maintenant", "dialog.language_changed.restart_later": "Red\u00e9marrer plus tard", + "dialog.branding_changed.title": "Branding modifié", + "dialog.branding_changed.msg": "Le branding actif a été modifié. Redémarrez maintenant pour appliquer partout l’identité visuelle mise à jour.", + "dialog.branding_changed.restart_now": "Redémarrer maintenant", + "dialog.branding_changed.restart_later": "Redémarrer plus tard", "dialog.restart_failed.title": "\u00c9chec du red\u00e9marrage", "dialog.restart_failed.msg": "Impossible de red\u00e9marrer automatiquement l'application\u00a0:\n\n{error}\n\nVeuillez red\u00e9marrer manuellement.", "dialog.update_timeout.title": "D\u00e9lai de v\u00e9rification des mises \u00e0 jour d\u00e9pass\u00e9", @@ -86,6 +90,11 @@ "settings.tab.window": "Fen\u00eatre", "settings.tab.profiles": "Configs", "settings.tab.general": "G\u00e9n\u00e9ral", + "settings.tab.branding": "Branding", + "settings.branding.select_label": "Modèle de branding :", + "settings.branding.select_tooltip": "Choisissez le modèle de branding qui doit être chargé automatiquement au démarrage.", + "settings.branding.help_text": "Le branding contrôle l’identité visuelle de l’application, comme le nom et les icônes. Il reste séparé de vos configurations enregistrées.", + "settings.branding.restart_note": "Les changements de branding sont enregistrés de façon persistante et seront entièrement appliqués après le redémarrage de l’application.", "settings.web_url.label": "URL de l'application web\u00a0:", "settings.web_url.placeholder": "p.ex. http://localhost:8080 ou file:///./webapp/index.html", "settings.web_url.open_btn": "Ouvrir", diff --git a/resources/translations/it.json b/resources/translations/it.json index 25e026b..58e54f7 100644 --- a/resources/translations/it.json +++ b/resources/translations/it.json @@ -58,6 +58,10 @@ "dialog.language_changed.msg": "La lingua è stata aggiornata. Riavvia ora per applicarla ovunque.", "dialog.language_changed.restart_now": "Riavvia ora", "dialog.language_changed.restart_later": "Riavvia più tardi", + "dialog.branding_changed.title": "Branding cambiato", + "dialog.branding_changed.msg": "Il branding attivo è stato modificato. Riavvia ora per applicare ovunque l’identità visiva aggiornata.", + "dialog.branding_changed.restart_now": "Riavvia ora", + "dialog.branding_changed.restart_later": "Riavvia più tardi", "dialog.restart_failed.title": "Riavvio non riuscito", "dialog.restart_failed.msg": "Impossibile riavviare automaticamente l'applicazione:\n\n{error}\n\nRiavvia manualmente.", "dialog.update_timeout.title": "Timeout controllo aggiornamenti", @@ -86,6 +90,11 @@ "settings.tab.window": "Finestra", "settings.tab.profiles": "Config", "settings.tab.general": "Generale", + "settings.tab.branding": "Branding", + "settings.branding.select_label": "Modello di branding:", + "settings.branding.select_tooltip": "Scegli il modello di branding da caricare automaticamente all’avvio.", + "settings.branding.help_text": "Il branding controlla l’identità visiva dell’app, come nome e icone. Rimane separato dalle configurazioni salvate.", + "settings.branding.restart_note": "Le modifiche al branding vengono salvate in modo persistente e saranno applicate completamente dopo il riavvio dell’applicazione.", "settings.web_url.label": "URL applicazione web:", "settings.web_url.placeholder": "es. http://localhost:8080 o file:///./webapp/index.html", "settings.web_url.open_btn": "Apri", diff --git a/resources/translations/ru.json b/resources/translations/ru.json index a7abdec..93e82dc 100644 --- a/resources/translations/ru.json +++ b/resources/translations/ru.json @@ -58,6 +58,10 @@ "dialog.language_changed.msg": "Настройка языка обновлена. Перезапустите сейчас, чтобы применить язык везде.", "dialog.language_changed.restart_now": "Перезапустить сейчас", "dialog.language_changed.restart_later": "Перезапустить позже", + "dialog.branding_changed.title": "Брендинг изменен", + "dialog.branding_changed.msg": "Активный брендинг был изменен. Перезапустите приложение сейчас, чтобы обновленная визуальная идентичность применялась везде.", + "dialog.branding_changed.restart_now": "Перезапустить сейчас", + "dialog.branding_changed.restart_later": "Перезапустить позже", "dialog.restart_failed.title": "Сбой перезапуска", "dialog.restart_failed.msg": "Не удалось автоматически перезапустить приложение:\n\n{error}\n\nПерезапустите вручную.", "dialog.update_timeout.title": "Таймаут проверки обновлений", @@ -86,6 +90,11 @@ "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.restart_note": "Изменения брендинга сохраняются постоянно и будут полностью применены после перезапуска приложения.", "settings.web_url.label": "URL веб-приложения:", "settings.web_url.placeholder": "например, http://localhost:8080 или file:///./webapp/index.html", "settings.web_url.open_btn": "Открыть", diff --git a/resources/translations/zh.json b/resources/translations/zh.json index ea2686b..f54d1fb 100644 --- a/resources/translations/zh.json +++ b/resources/translations/zh.json @@ -58,6 +58,10 @@ "dialog.language_changed.msg": "语言设置已更新。立即重启可在所有界面生效。", "dialog.language_changed.restart_now": "立即重启", "dialog.language_changed.restart_later": "稍后重启", + "dialog.branding_changed.title": "品牌已更改", + "dialog.branding_changed.msg": "当前品牌配置已更改。请立即重启,以便在所有界面应用更新后的视觉标识。", + "dialog.branding_changed.restart_now": "立即重启", + "dialog.branding_changed.restart_later": "稍后重启", "dialog.restart_failed.title": "重启失败", "dialog.restart_failed.msg": "无法自动重启应用:\n\n{error}\n\n请手动重启。", "dialog.update_timeout.title": "更新检查超时", @@ -86,6 +90,11 @@ "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.restart_note": "品牌更改会被持久保存,并将在应用重启后完整生效。", "settings.web_url.label": "Web 应用 URL:", "settings.web_url.placeholder": "例如: http://localhost:8080 或 file:///./webapp/index.html", "settings.web_url.open_btn": "打开", From e52c09857fad0680d58b24c64412e9ed76f706b8 Mon Sep 17 00:00:00 2001 From: claudi Date: Wed, 15 Apr 2026 13:58:36 +0200 Subject: [PATCH 05/10] feat: Enhance branding management with editable fields and save functionality in settings dialog --- resources/translations/de.json | 7 ++ resources/translations/en.json | 7 ++ resources/translations/fr.json | 7 ++ resources/translations/it.json | 7 ++ resources/translations/ru.json | 7 ++ resources/translations/zh.json | 7 ++ src/webdrop_bridge/core/branding_manager.py | 18 ++++ src/webdrop_bridge/ui/settings_dialog.py | 97 +++++++++++++++++++-- tests/unit/test_settings_dialog.py | 29 ++++++ 9 files changed, 178 insertions(+), 8 deletions(-) diff --git a/resources/translations/de.json b/resources/translations/de.json index e3162e4..e70acca 100644 --- a/resources/translations/de.json +++ b/resources/translations/de.json @@ -94,6 +94,13 @@ "settings.branding.select_label": "Branding-Vorlage:", "settings.branding.select_tooltip": "Wählen Sie die Branding-Vorlage, die beim Start automatisch geladen werden soll.", "settings.branding.help_text": "Branding steuert die visuelle Identität der App, zum Beispiel Name und Icons. Es ist klar von den gespeicherten Setups getrennt.", + "settings.branding.display_name_label": "Anzeigename:", + "settings.branding.app_name_label": "Anwendungsname:", + "settings.branding.window_title_label": "Fenstertitel (optional):", + "settings.branding.logo_path_label": "Logopfad (optional):", + "settings.branding.save_as_btn": "Als Vorlage speichern", + "settings.branding.save_as_title": "Branding-Vorlage speichern", + "settings.branding.save_as_prompt": "Vorlagen-ID eingeben (z.B. kunde_a):", "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", diff --git a/resources/translations/en.json b/resources/translations/en.json index d92c546..9e0b46e 100644 --- a/resources/translations/en.json +++ b/resources/translations/en.json @@ -94,6 +94,13 @@ "settings.branding.select_label": "Branding template:", "settings.branding.select_tooltip": "Choose the branding template that should be loaded automatically on startup.", "settings.branding.help_text": "Branding controls the visual identity of the app, such as name and icons. It is kept separate from your saved setups.", + "settings.branding.display_name_label": "Display name:", + "settings.branding.app_name_label": "Application name:", + "settings.branding.window_title_label": "Window title (optional):", + "settings.branding.logo_path_label": "Logo path (optional):", + "settings.branding.save_as_btn": "Save as Template", + "settings.branding.save_as_title": "Save Branding Template", + "settings.branding.save_as_prompt": "Enter a template ID (e.g. customer_a):", "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", diff --git a/resources/translations/fr.json b/resources/translations/fr.json index 92c970a..3032138 100644 --- a/resources/translations/fr.json +++ b/resources/translations/fr.json @@ -94,6 +94,13 @@ "settings.branding.select_label": "Modèle de branding :", "settings.branding.select_tooltip": "Choisissez le modèle de branding qui doit être chargé automatiquement au démarrage.", "settings.branding.help_text": "Le branding contrôle l’identité visuelle de l’application, comme le nom et les icônes. Il reste séparé de vos configurations enregistrées.", + "settings.branding.display_name_label": "Nom d’affichage :", + "settings.branding.app_name_label": "Nom de l’application :", + "settings.branding.window_title_label": "Titre de la fenêtre (facultatif) :", + "settings.branding.logo_path_label": "Chemin du logo (facultatif) :", + "settings.branding.save_as_btn": "Enregistrer comme modèle", + "settings.branding.save_as_title": "Enregistrer le modèle de branding", + "settings.branding.save_as_prompt": "Entrez un identifiant de modèle (par ex. client_a) :", "settings.branding.restart_note": "Les changements de branding sont enregistrés de façon persistante et seront entièrement appliqués après le redémarrage de l’application.", "settings.web_url.label": "URL de l'application web\u00a0:", "settings.web_url.placeholder": "p.ex. http://localhost:8080 ou file:///./webapp/index.html", diff --git a/resources/translations/it.json b/resources/translations/it.json index 58e54f7..8046651 100644 --- a/resources/translations/it.json +++ b/resources/translations/it.json @@ -94,6 +94,13 @@ "settings.branding.select_label": "Modello di branding:", "settings.branding.select_tooltip": "Scegli il modello di branding da caricare automaticamente all’avvio.", "settings.branding.help_text": "Il branding controlla l’identità visiva dell’app, come nome e icone. Rimane separato dalle configurazioni salvate.", + "settings.branding.display_name_label": "Nome visualizzato:", + "settings.branding.app_name_label": "Nome applicazione:", + "settings.branding.window_title_label": "Titolo finestra (opzionale):", + "settings.branding.logo_path_label": "Percorso logo (opzionale):", + "settings.branding.save_as_btn": "Salva come modello", + "settings.branding.save_as_title": "Salva modello di branding", + "settings.branding.save_as_prompt": "Inserisci un ID modello (es. cliente_a):", "settings.branding.restart_note": "Le modifiche al branding vengono salvate in modo persistente e saranno applicate completamente dopo il riavvio dell’applicazione.", "settings.web_url.label": "URL applicazione web:", "settings.web_url.placeholder": "es. http://localhost:8080 o file:///./webapp/index.html", diff --git a/resources/translations/ru.json b/resources/translations/ru.json index 93e82dc..7e4d462 100644 --- a/resources/translations/ru.json +++ b/resources/translations/ru.json @@ -94,6 +94,13 @@ "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.save_as_title": "Сохранить шаблон брендинга", + "settings.branding.save_as_prompt": "Введите ID шаблона (например, client_a):", "settings.branding.restart_note": "Изменения брендинга сохраняются постоянно и будут полностью применены после перезапуска приложения.", "settings.web_url.label": "URL веб-приложения:", "settings.web_url.placeholder": "например, http://localhost:8080 или file:///./webapp/index.html", diff --git a/resources/translations/zh.json b/resources/translations/zh.json index f54d1fb..ea3ce6b 100644 --- a/resources/translations/zh.json +++ b/resources/translations/zh.json @@ -94,6 +94,13 @@ "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.save_as_title": "保存品牌模板", + "settings.branding.save_as_prompt": "输入模板 ID(例如 customer_a):", "settings.branding.restart_note": "品牌更改会被持久保存,并将在应用重启后完整生效。", "settings.web_url.label": "Web 应用 URL:", "settings.web_url.placeholder": "例如: http://localhost:8080 或 file:///./webapp/index.html", diff --git a/src/webdrop_bridge/core/branding_manager.py b/src/webdrop_bridge/core/branding_manager.py index 6fad03e..8408f33 100644 --- a/src/webdrop_bridge/core/branding_manager.py +++ b/src/webdrop_bridge/core/branding_manager.py @@ -168,6 +168,24 @@ class BrandingManager: logger.info("Branding template saved: %s", 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.""" + return BrandingTemplate( + template_id=template_id.strip(), + display_name=display_name.strip(), + app_name=app_name.strip(), + window_title=window_title.strip(), + logo_path=logo_path.strip(), + ) + def delete_template(self, template_id: str) -> None: """Delete a user template while protecting built-ins.""" if template_id in BUILTIN_BRANDING_TEMPLATES: diff --git a/src/webdrop_bridge/ui/settings_dialog.py b/src/webdrop_bridge/ui/settings_dialog.py index c83d65a..201349e 100644 --- a/src/webdrop_bridge/ui/settings_dialog.py +++ b/src/webdrop_bridge/ui/settings_dialog.py @@ -10,6 +10,7 @@ from PySide6.QtWidgets import ( QDialogButtonBox, QFileDialog, QHBoxLayout, + QInputDialog, QLabel, QLineEdit, QListWidget, @@ -184,16 +185,42 @@ class SettingsDialog(QDialog): self.branding_combo = QComboBox() self.branding_combo.setToolTip(tr("settings.branding.select_tooltip")) - for template in self.branding_manager.list_templates(): - self.branding_combo.addItem(template.display_name, template.template_id) - - idx = self.branding_combo.findData(self.config.active_branding_id) - if idx < 0: - idx = self.branding_combo.findData("default") - if idx >= 0: - self.branding_combo.setCurrentIndex(idx) + 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") + ) + 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")) + 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") + ) + layout.addWidget(QLabel(tr("settings.branding.window_title_label"))) + layout.addWidget(self.branding_window_title_input) + + self.branding_logo_path_input = QLineEdit() + self.branding_logo_path_input.setPlaceholderText(tr("settings.branding.logo_path_label")) + layout.addWidget(QLabel(tr("settings.branding.logo_path_label"))) + layout.addWidget(self.branding_logo_path_input) + + 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) + 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;") @@ -203,6 +230,60 @@ class SettingsDialog(QDialog): 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 template 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) + self.branding_logo_path_input.setText(template.logo_path) + + 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 _save_branding_as(self) -> None: + """Save the edited branding as a new reusable template.""" + template_id, ok = QInputDialog.getText( + self, + tr("settings.branding.save_as_title"), + tr("settings.branding.save_as_prompt"), + ) + + if not ok or not template_id: + return + + try: + template = self.branding_manager.build_template( + template_id=template_id, + display_name=self.branding_display_name_input.text(), + app_name=self.branding_app_name_input.text(), + window_title=self.branding_window_title_input.text(), + 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 _create_web_source_tab(self) -> QWidget: """Create web source configuration tab.""" widget = QWidget() diff --git a/tests/unit/test_settings_dialog.py b/tests/unit/test_settings_dialog.py index b63796e..80df965 100644 --- a/tests/unit/test_settings_dialog.py +++ b/tests/unit/test_settings_dialog.py @@ -1,6 +1,7 @@ """Tests for settings dialog.""" from pathlib import Path +from unittest.mock import patch import pytest @@ -110,6 +111,34 @@ 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" + + 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) + + dialog.branding_display_name_input.setText("Customer A") + dialog.branding_app_name_input.setText("Customer A Bridge") + + 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.""" From e1dbc2ee84de8aa0222c7d011a5b5ce24294dc45 Mon Sep 17 00:00:00 2001 From: claudi Date: Wed, 15 Apr 2026 14:27:56 +0200 Subject: [PATCH 06/10] feat: Update branding terminology and improve settings dialog for logo management --- resources/translations/de.json | 16 +++---- resources/translations/en.json | 16 +++---- resources/translations/fr.json | 2 +- resources/translations/it.json | 2 +- resources/translations/ru.json | 2 +- resources/translations/zh.json | 2 +- src/webdrop_bridge/core/branding_manager.py | 40 ++++++++++++----- src/webdrop_bridge/ui/settings_dialog.py | 49 +++++++++++---------- tests/unit/test_branding_manager.py | 25 +++++++++++ tests/unit/test_settings_dialog.py | 7 +-- 10 files changed, 104 insertions(+), 57 deletions(-) diff --git a/resources/translations/de.json b/resources/translations/de.json index e70acca..a041f31 100644 --- a/resources/translations/de.json +++ b/resources/translations/de.json @@ -91,16 +91,16 @@ "settings.tab.profiles": "Setups", "settings.tab.general": "Allgemein", "settings.tab.branding": "Branding", - "settings.branding.select_label": "Branding-Vorlage:", - "settings.branding.select_tooltip": "Wählen Sie die Branding-Vorlage, die beim Start automatisch geladen werden soll.", - "settings.branding.help_text": "Branding steuert die visuelle Identität der App, zum Beispiel Name und Icons. Es ist klar von den gespeicherten Setups getrennt.", - "settings.branding.display_name_label": "Anzeigename:", + "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": "Logopfad (optional):", - "settings.branding.save_as_btn": "Als Vorlage speichern", - "settings.branding.save_as_title": "Branding-Vorlage speichern", - "settings.branding.save_as_prompt": "Vorlagen-ID eingeben (z.B. kunde_a):", + "settings.branding.logo_path_label": "Logo/Icon-Datei (optional):", + "settings.branding.save_as_btn": "Branding speichern", + "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", diff --git a/resources/translations/en.json b/resources/translations/en.json index 9e0b46e..1a471f6 100644 --- a/resources/translations/en.json +++ b/resources/translations/en.json @@ -91,16 +91,16 @@ "settings.tab.profiles": "Setups", "settings.tab.general": "General", "settings.tab.branding": "Branding", - "settings.branding.select_label": "Branding template:", - "settings.branding.select_tooltip": "Choose the branding template that should be loaded automatically on startup.", - "settings.branding.help_text": "Branding controls the visual identity of the app, such as name and icons. It is kept separate from your saved setups.", - "settings.branding.display_name_label": "Display name:", + "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 path (optional):", - "settings.branding.save_as_btn": "Save as Template", - "settings.branding.save_as_title": "Save Branding Template", - "settings.branding.save_as_prompt": "Enter a template ID (e.g. customer_a):", + "settings.branding.logo_path_label": "Logo/Icon file (optional):", + "settings.branding.save_as_btn": "Save Branding", + "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", diff --git a/resources/translations/fr.json b/resources/translations/fr.json index 3032138..ae6b260 100644 --- a/resources/translations/fr.json +++ b/resources/translations/fr.json @@ -91,7 +91,7 @@ "settings.tab.profiles": "Configs", "settings.tab.general": "G\u00e9n\u00e9ral", "settings.tab.branding": "Branding", - "settings.branding.select_label": "Modèle de branding :", + "settings.branding.select_label": "Branding :", "settings.branding.select_tooltip": "Choisissez le modèle de branding qui doit être chargé automatiquement au démarrage.", "settings.branding.help_text": "Le branding contrôle l’identité visuelle de l’application, comme le nom et les icônes. Il reste séparé de vos configurations enregistrées.", "settings.branding.display_name_label": "Nom d’affichage :", diff --git a/resources/translations/it.json b/resources/translations/it.json index 8046651..3d1cb5f 100644 --- a/resources/translations/it.json +++ b/resources/translations/it.json @@ -91,7 +91,7 @@ "settings.tab.profiles": "Config", "settings.tab.general": "Generale", "settings.tab.branding": "Branding", - "settings.branding.select_label": "Modello di branding:", + "settings.branding.select_label": "Branding:", "settings.branding.select_tooltip": "Scegli il modello di branding da caricare automaticamente all’avvio.", "settings.branding.help_text": "Il branding controlla l’identità visiva dell’app, come nome e icone. Rimane separato dalle configurazioni salvate.", "settings.branding.display_name_label": "Nome visualizzato:", diff --git a/resources/translations/ru.json b/resources/translations/ru.json index 7e4d462..7e17cf0 100644 --- a/resources/translations/ru.json +++ b/resources/translations/ru.json @@ -91,7 +91,7 @@ "settings.tab.profiles": "Наборы", "settings.tab.general": "Общие настройки", "settings.tab.branding": "Брендинг", - "settings.branding.select_label": "Шаблон брендинга:", + "settings.branding.select_label": "Брендинг:", "settings.branding.select_tooltip": "Выберите шаблон брендинга, который должен автоматически загружаться при запуске.", "settings.branding.help_text": "Брендинг управляет визуальной идентичностью приложения, например названием и иконками. Он отделен от сохраненных наборов настроек.", "settings.branding.display_name_label": "Отображаемое имя:", diff --git a/resources/translations/zh.json b/resources/translations/zh.json index ea3ce6b..85c305b 100644 --- a/resources/translations/zh.json +++ b/resources/translations/zh.json @@ -91,7 +91,7 @@ "settings.tab.profiles": "设置", "settings.tab.general": "通用", "settings.tab.branding": "品牌", - "settings.branding.select_label": "品牌模板:", + "settings.branding.select_label": "品牌:", "settings.branding.select_tooltip": "选择应用启动时应自动加载的品牌模板。", "settings.branding.help_text": "品牌控制应用的视觉标识,例如名称和图标,并与已保存的设置保持分离。", "settings.branding.display_name_label": "显示名称:", diff --git a/src/webdrop_bridge/core/branding_manager.py b/src/webdrop_bridge/core/branding_manager.py index 8408f33..d682938 100644 --- a/src/webdrop_bridge/core/branding_manager.py +++ b/src/webdrop_bridge/core/branding_manager.py @@ -161,6 +161,8 @@ class BrandingManager: """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}") self.templates_dir.mkdir(parents=True, exist_ok=True) template_path = self.templates_dir / f"{template.template_id}.json" @@ -173,19 +175,31 @@ class BrandingManager: *, template_id: str, display_name: str, - app_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() + return BrandingTemplate( - template_id=template_id.strip(), - display_name=display_name.strip(), - app_name=app_name.strip(), + template_id=safe_id, + display_name=safe_name, + app_name=resolved_app_name, window_title=window_title.strip(), - logo_path=logo_path.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("_") + def delete_template(self, template_id: str) -> None: """Delete a user template while protecting built-ins.""" if template_id in BUILTIN_BRANDING_TEMPLATES: @@ -229,20 +243,24 @@ class BrandingManager: requested_id = self.get_active_branding_id() template = self.load_template(requested_id) - is_non_default_branding = template.template_id != DEFAULT_BRANDING_TEMPLATE_ID default_app_name = BUILTIN_BRANDING_TEMPLATES[DEFAULT_BRANDING_TEMPLATE_ID].app_name + 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 is_non_default_branding or not config.app_name or config.app_name == default_app_name: - config.app_name = template.app_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 ( - is_non_default_branding + template.template_id != DEFAULT_BRANDING_TEMPLATE_ID or not config.window_title - or config.window_title.startswith(f"{default_app_name} v") - or config.window_title.startswith(f"{config.app_name} v") + 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}" diff --git a/src/webdrop_bridge/ui/settings_dialog.py b/src/webdrop_bridge/ui/settings_dialog.py index 201349e..cf2d555 100644 --- a/src/webdrop_bridge/ui/settings_dialog.py +++ b/src/webdrop_bridge/ui/settings_dialog.py @@ -196,22 +196,16 @@ class SettingsDialog(QDialog): 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")) - 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") - ) - 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")) - layout.addWidget(QLabel(tr("settings.branding.logo_path_label"))) - layout.addWidget(self.branding_logo_path_input) + 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) branding_button_layout = QHBoxLayout() self.save_branding_as_btn = QPushButton(tr("settings.branding.save_as_btn")) @@ -249,8 +243,6 @@ class SettingsDialog(QDialog): """Load the selected branding template 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) self.branding_logo_path_input.setText(template.logo_path) def _on_branding_selection_changed(self) -> None: @@ -259,23 +251,34 @@ class SettingsDialog(QDialog): 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 template.""" - template_id, ok = QInputDialog.getText( + """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 template_id: + if not ok or not branding_name: return try: template = self.branding_manager.build_template( - template_id=template_id, - display_name=self.branding_display_name_input.text(), - app_name=self.branding_app_name_input.text(), - window_title=self.branding_window_title_input.text(), + template_id=branding_name, + display_name=branding_name, + app_name=branding_name, logo_path=self.branding_logo_path_input.text(), ) self.branding_manager.save_template(template) diff --git a/tests/unit/test_branding_manager.py b/tests/unit/test_branding_manager.py index 891ce93..2f08abe 100644 --- a/tests/unit/test_branding_manager.py +++ b/tests/unit/test_branding_manager.py @@ -72,3 +72,28 @@ def test_config_from_env_uses_persisted_active_branding(tmp_path, monkeypatch): 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" diff --git a/tests/unit/test_settings_dialog.py b/tests/unit/test_settings_dialog.py index 80df965..e7a973d 100644 --- a/tests/unit/test_settings_dialog.py +++ b/tests/unit/test_settings_dialog.py @@ -120,7 +120,8 @@ class TestSettingsDialogInitialization: qtbot.addWidget(dialog) assert dialog.branding_display_name_input.text() == "Default" - assert dialog.branding_app_name_input.text() == "WebDrop Bridge" + assert dialog.branding_logo_path_input is not None + assert dialog.browse_branding_logo_btn is not None def test_save_branding_as_creates_custom_template( self, qtbot, sample_config, monkeypatch, tmp_path @@ -131,9 +132,9 @@ class TestSettingsDialogInitialization: qtbot.addWidget(dialog) dialog.branding_display_name_input.setText("Customer A") - dialog.branding_app_name_input.setText("Customer A Bridge") + dialog.branding_logo_path_input.setText("/tmp/customer-logo.png") - with patch("PySide6.QtWidgets.QInputDialog.getText", return_value=("customer_a", True)): + with patch("PySide6.QtWidgets.QInputDialog.getText", return_value=("Customer A", True)): dialog._save_branding_as() assert dialog.branding_manager.has_template("customer_a") From b826bd9b20907ea8c013d0e266e9b194175082b2 Mon Sep 17 00:00:00 2001 From: claudi Date: Wed, 15 Apr 2026 15:15:56 +0200 Subject: [PATCH 07/10] feat: Add delete and preview functionality for branding in settings dialog and update translations --- resources/translations/de.json | 4 ++ resources/translations/en.json | 4 ++ resources/translations/fr.json | 10 +++- resources/translations/it.json | 10 +++- resources/translations/ru.json | 10 +++- resources/translations/zh.json | 10 +++- src/webdrop_bridge/core/branding_manager.py | 14 +++++ src/webdrop_bridge/ui/settings_dialog.py | 62 ++++++++++++++++++++- tests/unit/test_branding_manager.py | 29 +++++++++- tests/unit/test_settings_dialog.py | 7 ++- 10 files changed, 143 insertions(+), 17 deletions(-) diff --git a/resources/translations/de.json b/resources/translations/de.json index a041f31..b6fc844 100644 --- a/resources/translations/de.json +++ b/resources/translations/de.json @@ -99,6 +99,10 @@ "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.delete_btn": "Branding löschen", + "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.", diff --git a/resources/translations/en.json b/resources/translations/en.json index 1a471f6..c4e8009 100644 --- a/resources/translations/en.json +++ b/resources/translations/en.json @@ -99,6 +99,10 @@ "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.delete_btn": "Delete 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.", diff --git a/resources/translations/fr.json b/resources/translations/fr.json index ae6b260..67d9bb3 100644 --- a/resources/translations/fr.json +++ b/resources/translations/fr.json @@ -98,9 +98,13 @@ "settings.branding.app_name_label": "Nom de l’application :", "settings.branding.window_title_label": "Titre de la fenêtre (facultatif) :", "settings.branding.logo_path_label": "Chemin du logo (facultatif) :", - "settings.branding.save_as_btn": "Enregistrer comme modèle", - "settings.branding.save_as_title": "Enregistrer le modèle de branding", - "settings.branding.save_as_prompt": "Entrez un identifiant de modèle (par ex. client_a) :", + "settings.branding.save_as_btn": "Enregistrer le branding", + "settings.branding.delete_btn": "Supprimer le branding", + "settings.branding.preview_label": "Aperçu :", + "settings.branding.no_icon_selected": "Aucune icône sélectionnée", + "settings.branding.preview_default_name": "Default", + "settings.branding.save_as_title": "Enregistrer le branding", + "settings.branding.save_as_prompt": "Entrez un nom pour le branding :", "settings.branding.restart_note": "Les changements de branding sont enregistrés de façon persistante et seront entièrement appliqués après le redémarrage de l’application.", "settings.web_url.label": "URL de l'application web\u00a0:", "settings.web_url.placeholder": "p.ex. http://localhost:8080 ou file:///./webapp/index.html", diff --git a/resources/translations/it.json b/resources/translations/it.json index 3d1cb5f..4c0dcf6 100644 --- a/resources/translations/it.json +++ b/resources/translations/it.json @@ -98,9 +98,13 @@ "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 come modello", - "settings.branding.save_as_title": "Salva modello di branding", - "settings.branding.save_as_prompt": "Inserisci un ID modello (es. cliente_a):", + "settings.branding.save_as_btn": "Salva branding", + "settings.branding.delete_btn": "Elimina branding", + "settings.branding.preview_label": "Anteprima:", + "settings.branding.no_icon_selected": "Nessuna icona selezionata", + "settings.branding.preview_default_name": "Default", + "settings.branding.save_as_title": "Salva branding", + "settings.branding.save_as_prompt": "Inserisci un nome per il branding:", "settings.branding.restart_note": "Le modifiche al branding vengono salvate in modo persistente e saranno applicate completamente dopo il riavvio dell’applicazione.", "settings.web_url.label": "URL applicazione web:", "settings.web_url.placeholder": "es. http://localhost:8080 o file:///./webapp/index.html", diff --git a/resources/translations/ru.json b/resources/translations/ru.json index 7e17cf0..938956e 100644 --- a/resources/translations/ru.json +++ b/resources/translations/ru.json @@ -98,9 +98,13 @@ "settings.branding.app_name_label": "Имя приложения:", "settings.branding.window_title_label": "Заголовок окна (необязательно):", "settings.branding.logo_path_label": "Путь к логотипу (необязательно):", - "settings.branding.save_as_btn": "Сохранить как шаблон", - "settings.branding.save_as_title": "Сохранить шаблон брендинга", - "settings.branding.save_as_prompt": "Введите ID шаблона (например, client_a):", + "settings.branding.save_as_btn": "Сохранить брендинг", + "settings.branding.delete_btn": "Удалить брендинг", + "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", diff --git a/resources/translations/zh.json b/resources/translations/zh.json index 85c305b..b263d7e 100644 --- a/resources/translations/zh.json +++ b/resources/translations/zh.json @@ -98,9 +98,13 @@ "settings.branding.app_name_label": "应用名称:", "settings.branding.window_title_label": "窗口标题(可选):", "settings.branding.logo_path_label": "Logo 路径(可选):", - "settings.branding.save_as_btn": "另存为模板", - "settings.branding.save_as_title": "保存品牌模板", - "settings.branding.save_as_prompt": "输入模板 ID(例如 customer_a):", + "settings.branding.save_as_btn": "保存品牌配置", + "settings.branding.delete_btn": "删除品牌配置", + "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", diff --git a/src/webdrop_bridge/core/branding_manager.py b/src/webdrop_bridge/core/branding_manager.py index d682938..9c01376 100644 --- a/src/webdrop_bridge/core/branding_manager.py +++ b/src/webdrop_bridge/core/branding_manager.py @@ -15,6 +15,7 @@ from webdrop_bridge.config import DEFAULT_CONFIG_DIR_NAME, Config, Configuration logger = logging.getLogger(__name__) DEFAULT_BRANDING_TEMPLATE_ID = "default" +SUPPORTED_LOGO_SUFFIXES = {".png", ".jpg", ".jpeg", ".bmp", ".svg", ".ico", ".icns"} @dataclass(frozen=True) @@ -185,6 +186,19 @@ class BrandingManager: 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 = Path(logo) + if 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, diff --git a/src/webdrop_bridge/ui/settings_dialog.py b/src/webdrop_bridge/ui/settings_dialog.py index cf2d555..9e61e93 100644 --- a/src/webdrop_bridge/ui/settings_dialog.py +++ b/src/webdrop_bridge/ui/settings_dialog.py @@ -4,6 +4,7 @@ import logging from pathlib import Path from typing import Any, Dict, Optional +from PySide6.QtGui import QIcon, QPixmap from PySide6.QtWidgets import ( QComboBox, QDialog, @@ -193,6 +194,7 @@ class SettingsDialog(QDialog): 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) @@ -200,6 +202,7 @@ class SettingsDialog(QDialog): 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")) @@ -207,10 +210,26 @@ class SettingsDialog(QDialog): 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_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.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") @@ -240,10 +259,33 @@ class SettingsDialog(QDialog): self.branding_combo.blockSignals(False) def _load_branding_into_editor(self, template_id: str) -> None: - """Load the selected branding template into the editable fields.""" + """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_logo_path_input.setText(template.logo_path) + self._update_branding_preview() + + 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) + + logo_path = self.branding_logo_path_input.text().strip() + if logo_path and Path(logo_path).exists(): + pixmap = QPixmap(logo_path) + if pixmap.isNull(): + icon = QIcon(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.""" @@ -275,10 +317,11 @@ class SettingsDialog(QDialog): return try: + display_name = self.branding_display_name_input.text().strip() or branding_name template = self.branding_manager.build_template( template_id=branding_name, - display_name=branding_name, - app_name=branding_name, + display_name=display_name, + app_name=display_name, logo_path=self.branding_logo_path_input.text(), ) self.branding_manager.save_template(template) @@ -287,6 +330,19 @@ class SettingsDialog(QDialog): except ConfigurationError as e: self._show_error(f"Failed to save 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() diff --git a/tests/unit/test_branding_manager.py b/tests/unit/test_branding_manager.py index 2f08abe..f989365 100644 --- a/tests/unit/test_branding_manager.py +++ b/tests/unit/test_branding_manager.py @@ -1,6 +1,8 @@ """Tests for runtime branding template management.""" -from webdrop_bridge.config import Config +import pytest + +from webdrop_bridge.config import Config, ConfigurationError from webdrop_bridge.core.branding_manager import BrandingManager @@ -97,3 +99,28 @@ def test_switching_back_to_default_restores_default_branding(tmp_path): 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_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"), + ) diff --git a/tests/unit/test_settings_dialog.py b/tests/unit/test_settings_dialog.py index e7a973d..e2cfd8f 100644 --- a/tests/unit/test_settings_dialog.py +++ b/tests/unit/test_settings_dialog.py @@ -122,6 +122,8 @@ class TestSettingsDialogInitialization: assert dialog.branding_display_name_input.text() == "Default" 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.delete_branding_btn is not None def test_save_branding_as_creates_custom_template( self, qtbot, sample_config, monkeypatch, tmp_path @@ -131,8 +133,11 @@ class TestSettingsDialogInitialization: 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("/tmp/customer-logo.png") + dialog.branding_logo_path_input.setText(str(logo_path)) with patch("PySide6.QtWidgets.QInputDialog.getText", return_value=("Customer A", True)): dialog._save_branding_as() From 55f2ddf4b13331a5809c25a6330653af42a2cb2c Mon Sep 17 00:00:00 2001 From: claudi Date: Wed, 15 Apr 2026 16:26:38 +0200 Subject: [PATCH 08/10] feat: Add branding import/export functionality and enhance settings dialog with new fields --- resources/translations/de.json | 4 + resources/translations/en.json | 4 + resources/translations/fr.json | 4 + resources/translations/it.json | 4 + resources/translations/ru.json | 4 + resources/translations/zh.json | 4 + src/webdrop_bridge/core/branding_manager.py | 126 +++++++++++++++++++- src/webdrop_bridge/ui/settings_dialog.py | 104 +++++++++++++++- tests/unit/test_branding_manager.py | 46 +++++++ tests/unit/test_settings_dialog.py | 6 + 10 files changed, 296 insertions(+), 10 deletions(-) diff --git a/resources/translations/de.json b/resources/translations/de.json index b6fc844..5be5e9e 100644 --- a/resources/translations/de.json +++ b/resources/translations/de.json @@ -99,7 +99,11 @@ "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", diff --git a/resources/translations/en.json b/resources/translations/en.json index c4e8009..799c546 100644 --- a/resources/translations/en.json +++ b/resources/translations/en.json @@ -99,7 +99,11 @@ "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", diff --git a/resources/translations/fr.json b/resources/translations/fr.json index 67d9bb3..9369246 100644 --- a/resources/translations/fr.json +++ b/resources/translations/fr.json @@ -99,7 +99,11 @@ "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", diff --git a/resources/translations/it.json b/resources/translations/it.json index 4c0dcf6..0ad9c7d 100644 --- a/resources/translations/it.json +++ b/resources/translations/it.json @@ -99,7 +99,11 @@ "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", diff --git a/resources/translations/ru.json b/resources/translations/ru.json index 938956e..da0b804 100644 --- a/resources/translations/ru.json +++ b/resources/translations/ru.json @@ -99,7 +99,11 @@ "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", diff --git a/resources/translations/zh.json b/resources/translations/zh.json index b263d7e..f3e61fe 100644 --- a/resources/translations/zh.json +++ b/resources/translations/zh.json @@ -99,7 +99,11 @@ "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", diff --git a/src/webdrop_bridge/core/branding_manager.py b/src/webdrop_bridge/core/branding_manager.py index 9c01376..ae1726f 100644 --- a/src/webdrop_bridge/core/branding_manager.py +++ b/src/webdrop_bridge/core/branding_manager.py @@ -6,6 +6,7 @@ import json import logging import os import platform +import shutil from dataclasses import asdict, dataclass from pathlib import Path from typing import Any @@ -15,6 +16,7 @@ from webdrop_bridge.config import DEFAULT_CONFIG_DIR_NAME, Config, Configuration 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"} @@ -83,6 +85,7 @@ BUILTIN_BRANDING_TEMPLATES: dict[str, BrandingTemplate] = { display_name="Default", app_name="WebDrop Bridge", window_title="", + logo_path=DEFAULT_LOGO_PATH, accent_color="#667eea", ), "agravity": BrandingTemplate( @@ -90,6 +93,7 @@ BUILTIN_BRANDING_TEMPLATES: dict[str, BrandingTemplate] = { display_name="Agravity", app_name="Agravity Bridge", window_title="", + logo_path=DEFAULT_LOGO_PATH, accent_color="#2d7d6e", ), } @@ -103,6 +107,7 @@ class BrandingManager: 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() @@ -114,6 +119,7 @@ class BrandingManager: 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" @@ -165,10 +171,29 @@ class BrandingManager: 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"{template.template_id}.json" - template_path.write_text(json.dumps(template.to_dict(), indent=2), encoding="utf-8") - logger.info("Branding template saved: %s", template.template_id) + 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( @@ -191,8 +216,8 @@ class BrandingManager: if not safe_name: raise ConfigurationError("Branding requires a display name") if logo: - logo_file = Path(logo) - if not logo_file.exists() or not logo_file.is_file(): + 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( @@ -214,6 +239,97 @@ class BrandingManager: """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: diff --git a/src/webdrop_bridge/ui/settings_dialog.py b/src/webdrop_bridge/ui/settings_dialog.py index 9e61e93..9830cff 100644 --- a/src/webdrop_bridge/ui/settings_dialog.py +++ b/src/webdrop_bridge/ui/settings_dialog.py @@ -198,6 +198,20 @@ class SettingsDialog(QDialog): 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() @@ -215,6 +229,10 @@ class SettingsDialog(QDialog): 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( @@ -227,6 +245,14 @@ class SettingsDialog(QDialog): 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) @@ -262,9 +288,30 @@ class SettingsDialog(QDialog): """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_logo_path_input.setText(template.logo_path) + 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( @@ -272,11 +319,17 @@ class SettingsDialog(QDialog): ) 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() - if logo_path and Path(logo_path).exists(): - pixmap = QPixmap(logo_path) + 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(logo_path) + icon = QIcon(str(resolved_logo_path)) pixmap = icon.pixmap(64, 64) if not pixmap.isNull(): @@ -318,10 +371,13 @@ class SettingsDialog(QDialog): 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=display_name, + app_name=app_name, + window_title=window_title, logo_path=self.branding_logo_path_input.text(), ) self.branding_manager.save_template(template) @@ -330,6 +386,44 @@ class SettingsDialog(QDialog): 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() diff --git a/tests/unit/test_branding_manager.py b/tests/unit/test_branding_manager.py index f989365..60997d4 100644 --- a/tests/unit/test_branding_manager.py +++ b/tests/unit/test_branding_manager.py @@ -1,5 +1,7 @@ """Tests for runtime branding template management.""" +from pathlib import Path + import pytest from webdrop_bridge.config import Config, ConfigurationError @@ -114,6 +116,21 @@ def test_delete_custom_branding_removes_it(tmp_path): 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) @@ -124,3 +141,32 @@ def test_invalid_logo_file_is_rejected(tmp_path): display_name="Customer C", logo_path=str(tmp_path / "missing-logo.png"), ) + + +def test_exported_branding_can_be_imported_for_another_user(tmp_path): + """Exported brandings should be shareable and importable by another user.""" + source_logo = tmp_path / "shared-logo.png" + source_logo.write_bytes(b"fake-png-data") + + source_manager = BrandingManager(base_dir=tmp_path / "source") + template = source_manager.build_template( + template_id="Customer D", + display_name="Customer D", + app_name="Customer Bridge", + window_title="Customer Window", + logo_path=str(source_logo), + ) + source_manager.save_template(template) + + export_path = tmp_path / "export" / "customer_d.json" + source_manager.export_template("customer_d", export_path) + + target_manager = BrandingManager(base_dir=tmp_path / "target") + imported = target_manager.import_template(export_path) + + assert imported.template_id == "customer_d" + assert imported.display_name == "Customer D" + assert imported.app_name == "Customer Bridge" + assert imported.window_title == "Customer Window" + assert Path(imported.logo_path).exists() + assert target_manager.has_template("customer_d") diff --git a/tests/unit/test_settings_dialog.py b/tests/unit/test_settings_dialog.py index e2cfd8f..b05239a 100644 --- a/tests/unit/test_settings_dialog.py +++ b/tests/unit/test_settings_dialog.py @@ -120,9 +120,15 @@ class TestSettingsDialogInitialization: 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( From 1054266d0e1f519633dbc399b179aa044fd5cf31 Mon Sep 17 00:00:00 2001 From: claudi Date: Wed, 15 Apr 2026 17:00:28 +0200 Subject: [PATCH 09/10] feat: Update version to 0.9.1, enhance release notes generation, and add changelog --- .env.example | 2 +- CHANGELOG.md | 15 ++++++++ build/scripts/create_release.ps1 | 15 +++++++- build/scripts/create_release.sh | 26 +++++++++----- build/scripts/version_utils.py | 61 +++++++++++++++++++++++++++++--- src/webdrop_bridge/__init__.py | 2 +- tests/unit/test_version_utils.py | 46 ++++++++++++++++++++++++ 7 files changed, 150 insertions(+), 17 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 tests/unit/test_version_utils.py diff --git a/.env.example b/.env.example index 1aaa923..7a5efe9 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ # Application APP_NAME=WebDrop Bridge -APP_VERSION=0.9.0 +APP_VERSION=0.9.1 # Web App WEBAPP_URL=file:///./webapp/index.html diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e29cab5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.9.1] - 2026-04-15 + +### Changed +- Improved release publishing so release descriptions can be generated from the changelog. +- Updated the release workflow to use a clearer, user-facing summary for update information. +- Added "in App" Branding Management instead of using separated Brand Builds. + +### Fixed +- Removed the generic placeholder release description from published releases. +- Added a reliable fallback message when no detailed notes are available. +- instead of using "Profiles" and "Configurations" use setups for more clarity. diff --git a/build/scripts/create_release.ps1 b/build/scripts/create_release.ps1 index 187c9aa..1cb5940 100644 --- a/build/scripts/create_release.ps1 +++ b/build/scripts/create_release.ps1 @@ -34,6 +34,18 @@ function Get-CurrentVersion { return (& $pythonExe -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$projectRoot/build/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())").Trim() } +function Get-ReleaseNotes { + param([string]$Version) + + $notes = & $pythonExe -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$projectRoot/build/scripts').resolve())); from version_utils import get_release_notes; print(get_release_notes('$Version'))" + + if ($LASTEXITCODE -ne 0) { + return "## WebDrop Bridge v$Version`n`nThis release package contains the latest improvements, fixes, and installer updates for this version." + } + + return ($notes | Out-String).Trim() +} + function Get-LocalReleaseData { $arguments = @($brandHelper, "local-release-data", "--platform", "windows", "--version", $Version) if ($Brands) { @@ -127,10 +139,11 @@ $headers = @{ $releaseLookupUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/tags/v$Version" $releaseUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases" +$releaseBody = Get-ReleaseNotes -Version $Version $releaseData = @{ tag_name = "v$Version" name = "WebDropBridge v$Version" - body = "Shared branded release for WebDrop Bridge v$Version" + body = $releaseBody draft = $false prerelease = $false } | ConvertTo-Json diff --git a/build/scripts/create_release.sh b/build/scripts/create_release.sh index 2ce39f8..218de20 100644 --- a/build/scripts/create_release.sh +++ b/build/scripts/create_release.sh @@ -40,6 +40,10 @@ if [ -z "$VERSION" ]; then VERSION="$(python3 -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$PROJECT_ROOT/build/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())")" fi +get_release_notes() { + python3 -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$PROJECT_ROOT/build/scripts').resolve())); from version_utils import get_release_notes; print(get_release_notes('$VERSION'))" +} + LOCAL_ARGS=("$BRAND_HELPER" "local-release-data" "--platform" "macos" "--version" "$VERSION") if [ ${#BRANDS[@]} -gt 0 ]; then LOCAL_ARGS+=("--brands" "${BRANDS[@]}") @@ -186,15 +190,19 @@ else fi if [ -z "$RELEASE_ID" ]; then - RELEASE_DATA=$(cat < str: init_file = project_root / "src" / "webdrop_bridge" / "__init__.py" if not init_file.exists(): - raise FileNotFoundError( - f"Cannot find __init__.py at {init_file}" - ) + raise FileNotFoundError(f"Cannot find __init__.py at {init_file}") content = init_file.read_text(encoding="utf-8") match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content) if not match: raise ValueError( - f"Could not find __version__ in {init_file}. " - "Expected: __version__ = \"X.Y.Z\"" + f"Could not find __version__ in {init_file}. " 'Expected: __version__ = "X.Y.Z"' ) return match.group(1) + + +def extract_release_notes(changelog_content: str, version: str) -> str | None: + """Extract the notes for a specific version from changelog content. + + Args: + changelog_content: Full text of CHANGELOG.md + version: Version to extract, e.g. "0.9.1" + + Returns: + The section content for that version, or None if not found. + """ + version_header = re.compile( + rf"^##\s*\[?{re.escape(version)}\]?(?:\s*-\s*.+)?\s*$", + re.MULTILINE, + ) + match = version_header.search(changelog_content) + if not match: + return None + + section_start = match.end() + next_header = re.search(r"^##\s+", changelog_content[section_start:], re.MULTILINE) + section_end = section_start + next_header.start() if next_header else len(changelog_content) + section = changelog_content[section_start:section_end].strip() + return section or None + + +def get_release_notes(version: str, project_root: Path | None = None) -> str: + """Build a readable release body for publishing. + + Prefers the matching version section from CHANGELOG.md. If no changelog + entry exists yet, falls back to a generic but user-facing description. + + Args: + version: Release version string. + project_root: Optional project root override for testing. + + Returns: + Release notes text suitable for Forgejo/GitHub release bodies. + """ + root = project_root or get_project_root() + changelog_file = root / "CHANGELOG.md" + + if changelog_file.exists(): + content = changelog_file.read_text(encoding="utf-8") + notes = extract_release_notes(content, version) + if notes: + return f"## WebDrop Bridge v{version}\n\n{notes}" + + return ( + f"## WebDrop Bridge v{version}\n\n" + "This release package contains the latest improvements, fixes, " + "and installer updates for this version." + ) diff --git a/src/webdrop_bridge/__init__.py b/src/webdrop_bridge/__init__.py index 0178dab..afd4e03 100644 --- a/src/webdrop_bridge/__init__.py +++ b/src/webdrop_bridge/__init__.py @@ -1,6 +1,6 @@ """WebDrop Bridge - Qt-based desktop application for intelligent drag-and-drop file handling.""" -__version__ = "0.9.0" +__version__ = "0.9.1" __author__ = "WebDrop Team" __license__ = "MIT" diff --git a/tests/unit/test_version_utils.py b/tests/unit/test_version_utils.py new file mode 100644 index 0000000..cedf2fc --- /dev/null +++ b/tests/unit/test_version_utils.py @@ -0,0 +1,46 @@ +"""Tests for build script version utilities.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "build" / "scripts")) + +from version_utils import get_release_notes + + +class TestReleaseNotes: + """Test release note extraction for published releases.""" + + def test_get_release_notes_from_changelog(self, tmp_path): + """Extract only the selected version section from the changelog.""" + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text( + """## [0.9.1] - 2026-04-15 + +### Added +- Better update text +- New installer checks + +### Fixed +- Upload retries + +## [0.9.0] - 2026-04-01 + +### Added +- Older changes +""", + encoding="utf-8", + ) + + notes = get_release_notes("0.9.1", project_root=tmp_path) + + assert "Better update text" in notes + assert "New installer checks" in notes + assert "Older changes" not in notes + + def test_get_release_notes_uses_fallback_when_missing(self, tmp_path): + """Return a readable fallback when no changelog entry exists.""" + notes = get_release_notes("0.9.1", project_root=tmp_path) + + assert "WebDrop Bridge v0.9.1" in notes + assert "release package" in notes.lower() From ac10fdcbdd0dd95599f0b2d83bca5c20541041d2 Mon Sep 17 00:00:00 2001 From: claudi Date: Thu, 16 Apr 2026 08:38:41 +0200 Subject: [PATCH 10/10] feat: Update documentation for version 0.9.1, including changelog, configuration, and package manager support --- CHANGELOG.md | 4 +- CONFIG_README.md | 26 +++++- CONTRIBUTING.md | 2 +- DEVELOPMENT_PLAN.md | 2 +- QUICKSTART.md | 18 ++-- README.md | 95 +++++++++++---------- build/package-managers/README.md | 6 +- build/scripts/README.md | 10 +-- docs/CONFIGURATION_BUILD.md | 136 ++++++++++++++++--------------- docs/CUSTOMER_BUILD_EXAMPLES.md | 10 +-- docs/PACKAGE_MANAGER_SUPPORT.md | 46 +++++------ 11 files changed, 196 insertions(+), 159 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e29cab5..9735517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,9 @@ All notable changes to this project will be documented in this file. ### Changed - Improved release publishing so release descriptions can be generated from the changelog. - Updated the release workflow to use a clearer, user-facing summary for update information. -- Added "in App" Branding Management instead of using separated Brand Builds. +- Added "in App" Branding Management instead of using separated Brand Builds. ### Fixed - Removed the generic placeholder release description from published releases. - Added a reliable fallback message when no detailed notes are available. -- instead of using "Profiles" and "Configurations" use setups for more clarity. +- Instead of using "Profiles" and "Configurations" use "Setups" for more clarity. diff --git a/CONFIG_README.md b/CONFIG_README.md index d830dbd..6572acd 100644 --- a/CONFIG_README.md +++ b/CONFIG_README.md @@ -36,7 +36,10 @@ Create a `config.json` file with the following structure: "log_file": "logs/webdrop_bridge.log", "window_width": 1024, "window_height": 768, - "enable_logging": true + "enable_logging": true, + "language": "auto", + "active_branding_id": "default", + "brand_id": "agravity" } ``` @@ -99,6 +102,19 @@ Z:\aN5PysnXIuRECzcRbvHkjL7g0\Hintergrund_Agravity.png - **`window_width`**, **`window_height`** (number): Initial window size in pixels - Default: `1024` x `768` +### Language and Branding Settings + +- **`language`** (string): UI language code + - Use `"auto"` to follow the system locale automatically + - Bundled translations currently include `en`, `de`, `fr`, `it`, `ru`, and `zh` + +- **`active_branding_id`** (string): Runtime branding template selected in the Settings dialog + - Default: `"default"` + - Useful when switching between saved branding templates without rebuilding the app + +- **`brand_id`** (string): Stable packaging/update identifier for branded variants + - Usually injected during packaging and normally left unchanged by end users + - **`log_level`** (string): Logging verbosity - Options: `"DEBUG"`, `"INFO"`, `"WARNING"`, `"ERROR"`, `"CRITICAL"` - Default: `"INFO"` @@ -149,6 +165,14 @@ You can configure multiple Azure storage accounts: } ``` +## Configuration Priority + +At startup, WebDrop Bridge first loads any bootstrap `.env` defaults and then prefers the persisted JSON config if it exists. This means: + +1. packaged or development defaults can still come from `.env`, +2. the Settings dialog saves the active runtime configuration to JSON, and +3. the JSON file becomes the main configuration source after first save. + ## Environment Variable Fallback If no JSON config exists, WebDrop Bridge will load from `.env`: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8112350..57dedbf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ Please be respectful and constructive in all interactions. We're building a welc ## Getting Started ### Prerequisites -- Python 3.10+ +- Python 3.9+ - Git - Familiarity with Qt/PySide6 or willingness to learn diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md index e5460e6..3e025ce 100644 --- a/DEVELOPMENT_PLAN.md +++ b/DEVELOPMENT_PLAN.md @@ -1051,7 +1051,7 @@ Help Menu **Core:** - PySide6 6.6.0+ -- Python 3.10+ +- Python 3.9+ **Optional:** - PyInstaller (building) diff --git a/QUICKSTART.md b/QUICKSTART.md index 5c9611e..32b4f7e 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -143,19 +143,19 @@ brew upgrade webdrop-bridge brew uninstall webdrop-bridge ``` -For more package manager details and internal hosting options, see [docs/PACKAGE_MANAGER_SUPPORT.md](../docs/PACKAGE_MANAGER_SUPPORT.md) +For more package manager details and internal hosting options, see [docs/PACKAGE_MANAGER_SUPPORT.md](docs/PACKAGE_MANAGER_SUPPORT.md) #### Simplest: Direct wget (if you know the version) ```bash -# Replace VERSION with release tag (e.g., v0.8.0) -wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/VERSION/WebDropBridge_Setup.msi +# Replace X.Y.Z with a release version (e.g., 0.9.1) +wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/vX.Y.Z/WebDropBridge-X.Y.Z-win-x64.msi -# Real example - download v0.8.0 MSI -wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.msi +# Real example - download v0.9.1 MSI +wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.9.1/WebDropBridge-0.9.1-win-x64.msi -# macOS - download v0.8.0 DMG -wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.dmg +# macOS - download v0.9.1 DMG +wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.9.1/WebDropBridge-0.9.1-macos-universal.dmg ``` #### Windows (PowerShell) - Full Control Script @@ -168,7 +168,7 @@ wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0 .\build\scripts\download_release.ps1 -OutputDir "C:\Installers" # Download specific version -.\build\scripts\download_release.ps1 -Version "0.8.0" +.\build\scripts\download_release.ps1 -Version "0.9.1" # Skip checksum verification .\build\scripts\download_release.ps1 -Verify $false @@ -186,7 +186,7 @@ wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0 ./build/scripts/download_release.sh latest ~/Downloads # Download specific version -./build/scripts/download_release.sh 0.8.0 +./build/scripts/download_release.sh 0.9.1 # Skip checksum verification ./build/scripts/download_release.sh latest --no-verify diff --git a/README.md b/README.md index 976485f..21eb28f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > Professional Qt-based desktop application for intelligent drag-and-drop file handling between web applications and desktop clients (InDesign, Word, Notepad++, etc.) -![Status](https://img.shields.io/badge/Status-Phase%204%20Complete-green) ![License](https://img.shields.io/badge/License-MIT-blue) ![Python](https://img.shields.io/badge/Python-3.10%2B-blue) +![Status](https://img.shields.io/badge/Status-Phase%205%20RC%20In%20Progress-green) ![License](https://img.shields.io/badge/License-MIT-blue) ![Python](https://img.shields.io/badge/Python-3.9%2B-blue) ## Overview @@ -19,23 +19,25 @@ WebDrop Bridge embeds a web application in a Qt container with full filesystem a ## Features -- ✅ **Qt-based Architecture** - Professional Windows support via PySide6 (macOS support planned) +- ✅ **Qt-based Architecture** - Professional cross-platform desktop app via PySide6 for Windows and macOS - ✅ **Embedded Web App** - QtWebEngine provides Chromium without browser limitations - ✅ **Drag Interception** - Converts text paths to native file operations - ✅ **Path Whitelist** - Security-conscious file system access control -- ✅ **Configuration Management** - Profile-based settings with validation -- ✅ **Settings Dialog** - Professional UI for path, URL, logging, and window configuration +- ✅ **Configuration Management** - JSON config, profile import/export, and validation +- ✅ **Runtime Branding** - Switch branding templates and packaged variants without code changes +- ✅ **Multilingual UI** - Built-in translations for English, German, French, Italian, Russian, and Chinese +- ✅ **Settings Dialog** - Language, branding, web source, path, URL, logging, and window configuration - ✅ **Auto-Update System** - Automatic release detection via Forgejo API - ✅ **Professional Build Pipeline** - MSI for Windows, DMG for macOS -- ✅ **Comprehensive Testing** - Unit, integration, and end-to-end tests (80%+ coverage) -- ✅ **Continuous Testing** - GitHub Actions test automation +- ✅ **Comprehensive Testing** - Unit and integration coverage across core modules +- ✅ **Continuous Testing** - Automated CI validation - ✅ **Structured Logging** - File-based logging with configurable levels ## Quick Start ### Requirements -- Python 3.10+ -- Windows 10/11 +- Python 3.9+ +- Windows 10/11 or macOS 12+ - 200 MB disk space (includes Chromium from PyInstaller) ### Installation from Pre-Built Release (Recommended) @@ -58,11 +60,11 @@ brew upgrade webdrop-bridge # Update to latest version **Option 2: Direct wget (if you know the version)** ```bash -# Replace VERSION with release tag (e.g., v0.8.0) -wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/VERSION/WebDropBridge_Setup.msi +# Replace X.Y.Z with a release version (e.g., 0.9.1) +wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/vX.Y.Z/WebDropBridge-X.Y.Z-win-x64.msi -# Example for v0.8.0: -wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.msi +# Example for v0.9.1: +wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.9.1/WebDropBridge-0.9.1-win-x64.msi ``` **Option 3: Automated script (auto-detects platform)** @@ -93,6 +95,7 @@ python -m venv venv # Install dependencies pip install -r requirements.txt +pip install -e . # Run application python -m webdrop_bridge.main @@ -103,6 +106,7 @@ python -m webdrop_bridge.main ```bash # Install development dependencies pip install -r requirements-dev.txt +pip install -e . # Run tests pytest tests -v @@ -178,42 +182,36 @@ webdrop-bridge/ ## Configuration -WebDrop Bridge supports two configuration methods: +WebDrop Bridge supports persisted JSON configuration plus optional bootstrap environment defaults. -### 1. Settings Dialog (Recommended) +### 1. Settings Dialog / JSON Config (Recommended) Launch the application and access the Settings menu to configure: -- **Paths Tab** - Add/remove allowed root directories -- **URLs Tab** - Configure allowed web URLs (whitelist mode) -- **Logging Tab** - Set log level and file location -- **Window Tab** - Configure window dimensions -- **Profiles Tab** - Save/load/export-import configuration profiles +- **General Tab** - Select the UI language or follow the system locale automatically +- **Branding Tab** - Switch, import, export, and preview runtime branding templates +- **Web Source Tab** - Configure the embedded web application URL +- **Paths / URLs / Logging / Window Tabs** - Control filesystem access, allowed sites, log output, and initial window size +- **Profiles Tab** - Save, load, import, and export complete configuration profiles -Profiles are saved in `~/.webdrop_bridge/profiles/` +Saved settings are written to the brand-specific application config directory as `config.json`. -### 2. Environment Variables -Create a `.env` file in the project root. Available settings: +### 2. Bootstrap Environment Variables (`.env`) +A `.env` file is still supported for local development and branded packaged defaults. It is used when no JSON config exists yet. ```bash -# Application APP_NAME=WebDrop Bridge -APP_VERSION=1.0.0 - -# Paths (comma-separated) +BRAND_ID=webdrop_bridge +WEBAPP_URL=https://dev.agravity.io/ ALLOWED_ROOTS=Z:/,C:/Users/Public - -# Web URLs (empty = no restriction, items = kiosk mode) ALLOWED_URLS= - -# Interface -WEBAPP_URL=file:///./webapp/index.html +LANGUAGE=auto +LOG_LEVEL=INFO WINDOW_WIDTH=1024 WINDOW_HEIGHT=768 - -# Logging -LOG_LEVEL=INFO ENABLE_LOGGING=true ``` +For the full JSON structure and branding workflow, see [CONFIG_README.md](CONFIG_README.md) and [BRANDING_AND_RELEASES.md](docs/BRANDING_AND_RELEASES.md). + ## Testing WebDrop Bridge includes comprehensive test coverage with unit, integration, and end-to-end tests. @@ -272,11 +270,20 @@ python build/scripts/build_windows.py --msi --code-sign ``` Output: -- Portable executable: `build/dist/windows/WebDropBridge/WebDropBridge.exe` (~195 MB) -- Professional MSI installer: `build/dist/windows/WebDropBridge-{version}-Setup.msi` -- SHA256 checksum: `build/dist/windows/WebDropBridge/WebDropBridge.exe.sha256` +- Portable executable: `build/dist/windows/webdrop_bridge/WebDropBridge/WebDropBridge.exe` +- Professional MSI installer: `build/dist/windows/webdrop_bridge/WebDropBridge--win-x64.msi` +- SHA256 checksum: `build/dist/windows/webdrop_bridge/WebDropBridge--win-x64.msi.sha256` -**Note on macOS**: Build scripts exist for macOS (DMG generation), but have never been built or tested. macOS support is theoretical at this point. The Qt/PySide6 architecture should support macOS, but platform-specific testing and validation would be required. +### macOS DMG Installer + +```bash +bash build/scripts/build_macos.sh +``` + +Output: +- Application bundle: `build/dist/macos/webdrop_bridge/WebDropBridge.app` +- DMG installer: `build/dist/macos/webdrop_bridge/WebDropBridge--macos-universal.dmg` +- SHA256 checksum: `build/dist/macos/webdrop_bridge/WebDropBridge--macos-universal.dmg.sha256` ### Creating Releases @@ -314,8 +321,8 @@ powershell -ExecutionPolicy Bypass -File build/scripts/create_release.ps1 ## Troubleshooting ### Application won't start -- Ensure Python 3.10+ is installed -- Check `logs/webdrop_bridge.log` for errors +- Ensure Python 3.9+ is installed +- Check the application log in your platform-specific app data directory - Verify all dependencies: `pip list` ### Drag-and-drop not working @@ -332,10 +339,10 @@ powershell -ExecutionPolicy Bypass -File build/scripts/create_release.ps1 | Platform | Version | Status | Notes | |----------|---------|--------|-------| -| Windows | 10, 11 | ✅ Full | Tested on x64, MSI installer support | -| macOS | 12+ | ⚠️ **Untested** | Possible via Qt/PySide6, but never built or tested. Theoretical support only. | +| Windows | 10, 11 | ✅ Full | Primary target with MSI packaging and update support | +| macOS | 12, 13, 14 | ✅ Supported | Universal DMG builds for Intel and Apple Silicon | -**Note**: WebDrop Bridge is currently developed and tested exclusively on Windows. While the Qt/PySide6 framework supports macOS, we cannot guarantee functionality without actual macOS testing and validation. Contributions for macOS support validation are welcome. +**Note**: Release candidates currently target both Windows and macOS. For branded production releases, validate signing assets and installer behavior on the target platform before shipping. ## Contributing @@ -367,4 +374,4 @@ MIT License - see [LICENSE](LICENSE) file for details --- -**Development Phase**: Phase 4 Complete | **Last Updated**: February 18, 2026 | **Current Version**: 1.0.0 | **Python**: 3.10+ | **Qt**: PySide6 (Qt 6) +**Development Phase**: Phase 5 Release Candidates | **Last Updated**: April 16, 2026 | **Current Version**: 0.9.1 | **Python**: 3.9+ | **Qt**: PySide6 (Qt 6) diff --git a/build/package-managers/README.md b/build/package-managers/README.md index 7f0a400..62e5572 100644 --- a/build/package-managers/README.md +++ b/build/package-managers/README.md @@ -27,7 +27,7 @@ build/ 2. **Get SHA256 checksum**: ```powershell - certutil -hashfile build/dist/windows/WebDropBridge_Setup.msi SHA256 + certutil -hashfile build/dist/windows/webdrop_bridge/WebDropBridge--win-x64.msi SHA256 ``` 3. **Update package files**: @@ -42,7 +42,7 @@ build/ 5. **Publish** (requires Chocolatey API key): ```powershell - choco push webdrop-bridge.0.8.0.nupkg --api-key YOUR_KEY + choco push webdrop-bridge..nupkg --api-key YOUR_KEY ``` ### Homebrew Formula (macOS) @@ -54,7 +54,7 @@ build/ 2. **Get SHA256 checksum**: ```bash - shasum -a 256 build/dist/macos/WebDropBridge_Setup.dmg + shasum -a 256 build/dist/macos/webdrop_bridge/WebDropBridge--macos-universal.dmg ``` 3. **Update formula**: diff --git a/build/scripts/README.md b/build/scripts/README.md index c4fba4c..51cac7c 100644 --- a/build/scripts/README.md +++ b/build/scripts/README.md @@ -75,8 +75,8 @@ The `download_release.ps1` (Windows) and `download_release.sh` (macOS/Linux) scr ```bash # Download directly by version tag -wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.msi -wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.dmg +wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.9.1/WebDropBridge-0.9.1-win-x64.msi +wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.9.1/WebDropBridge-0.9.1-macos-universal.dmg ``` **If you need to auto-detect latest (with grep/cut, no jq needed)** @@ -111,7 +111,7 @@ sha256sum -c installer.sha256 .\download_release.ps1 # Specific version to Downloads folder -.\download_release.ps1 -Version "0.8.0" -OutputDir "$env:USERPROFILE\Downloads" +.\download_release.ps1 -Version "0.9.1" -OutputDir "$env:USERPROFILE\Downloads" # Skip checksum verification .\download_release.ps1 -Verify $false @@ -124,7 +124,7 @@ sha256sum -c installer.sha256 ./build/scripts/download_release.sh # Specific version to Downloads -./build/scripts/download_release.sh 0.8.0 ~/Downloads +./build/scripts/download_release.sh 0.9.1 ~/Downloads # Skip checksum verification ./build/scripts/download_release.sh latest --no-verify @@ -165,7 +165,7 @@ Automated release creation with versioning and asset uploads. Manages consistent versioning across the project. ```bash -python build/scripts/sync_version.py --version 0.8.0 +python build/scripts/sync_version.py --version 0.9.1 ``` ## Integration Flow diff --git a/docs/CONFIGURATION_BUILD.md b/docs/CONFIGURATION_BUILD.md index d69abfa..dedb0af 100644 --- a/docs/CONFIGURATION_BUILD.md +++ b/docs/CONFIGURATION_BUILD.md @@ -1,33 +1,60 @@ # Configuration Management for Builds -This document explains how configuration is handled when building executables and installers for WebDrop Bridge. +This document explains how configuration and branding work for development builds, packaged installers, and installed applications. -## Overview +## Current Configuration Model -WebDrop Bridge uses `.env` files for runtime configuration. When building distributable packages (exe, MSI, or DMG), the `.env` file is **bundled into the application** so that users receive pre-configured settings. +WebDrop Bridge now uses a **JSON-first runtime configuration** with optional `.env` bootstrap defaults: -## Configuration File +1. **Bootstrap `.env`** (optional) + - Loaded very early during startup + - Useful for packaged defaults such as `APP_NAME`, `BRAND_ID`, update channel, and default web source + - Commonly used by branded Windows/MSI and macOS/DMG builds -The configuration file must be named `.env` and contains settings like: +2. **Persisted JSON config** (preferred) + - Windows: `%APPDATA%\\config.json` + - macOS/Linux: `~/.config//config.json` + - Created and maintained by the Settings dialog + - Takes precedence for day-to-day user settings after first launch + +In practice, installers can ship with a curated `.env`, while user changes are saved into `config.json`. + +## What Belongs Where? + +### Use JSON config for: +- `url_mappings` +- `allowed_roots` +- `allowed_urls` +- window size and logging settings +- `language` +- `active_branding_id` + +### Use `.env` bootstrap defaults for: +- `APP_NAME` +- `BRAND_ID` +- `APP_CONFIG_DIR_NAME` +- update channel and repository defaults +- packaged first-launch defaults for customer-specific builds + +## Example Bootstrap `.env` ```dotenv APP_NAME=WebDrop Bridge -APP_VERSION=0.7.1 +BRAND_ID=webdrop_bridge +APP_CONFIG_DIR_NAME=webdrop_bridge WEBAPP_URL=https://example.com ALLOWED_ROOTS=Z:/,C:/Users/Public ALLOWED_URLS= +LANGUAGE=auto LOG_LEVEL=INFO -LOG_FILE=logs/webdrop_bridge.log ENABLE_LOGGING=true WINDOW_WIDTH=1024 WINDOW_HEIGHT=768 ``` -See `.env.example` for a template with all available options. - ## Building with Default Configuration -If you want to use the project's `.env` file (in the project root), simply run: +If you want to use the project's default `.env` file from the repository root: ### Windows ```bash @@ -39,11 +66,11 @@ python build/scripts/build_windows.py --msi bash build/scripts/build_macos.sh ``` -**Important:** The build will **fail** if `.env` doesn't exist. This prevents accidentally shipping without configuration. +> The build scripts currently expect a `.env` file to exist. This is intentional so packaged builds always have explicit bootstrap defaults. -## Building with Custom Configuration +## Building with Custom Customer Defaults -For different customers or deployments, you can specify a custom `.env` file: +For customer-specific or branded releases, provide a different `.env` file during packaging: ### Windows ```bash @@ -55,86 +82,73 @@ python build/scripts/build_windows.py --msi --env-file path/to/customer1.env bash build/scripts/build_macos.sh --env-file path/to/customer1.env ``` -The custom `.env` file will be bundled into the executable and users will receive those pre-configured settings. +This bundles those bootstrap defaults into the packaged app while still allowing the installed application to persist later changes in JSON. ## Example: Multi-Customer Setup -If you have different customer configurations: - -``` +```text webdrop_bridge/ -├── .env # Default project configuration -├── .env.example # Template +├── .env ├── build/ │ └── scripts/ │ ├── build_windows.py │ └── build_macos.sh -├── customer_configs/ # Create this for customer-specific settings +├── customer_configs/ │ ├── acme_corp.env │ ├── globex_corporation.env │ └── initech.env -└── ... +└── config.example.json ``` -Then build for each customer: +Then build per customer or brand: ```bash -# ACME Corp python build/scripts/build_windows.py --msi --env-file customer_configs/acme_corp.env - -# Globex Corporation python build/scripts/build_windows.py --msi --env-file customer_configs/globex_corporation.env - -# Initech -python build/scripts/build_windows.py --msi --env-file customer_configs/initech.env +bash build/scripts/build_macos.sh --env-file customer_configs/initech.env ``` -Each MSI will include that customer's specific configuration (URLs, allowed paths, etc.). +## What Gets Bundled into Installers? -## What Gets Bundled +During packaging, the supplied `.env` file is bundled so the application can resolve: +- app display name +- brand/config directory name +- update channel defaults +- initial web source and logging defaults -When building, the `.env` file is: -1. ✅ Copied into the PyInstaller bundle -2. ✅ Extracted to the application's working directory when the app starts -3. ✅ Automatically loaded by `Config.from_env()` at startup +After installation, the application normally saves user-controlled settings to the JSON config file in the app data directory. -Users **do not** need to create their own `.env` files. +## Recommended Runtime Workflow -## After Installation - -When users run the installed application: -1. The embedded `.env` is automatically available -2. Settings are loaded and applied -3. Users can optionally create a custom `.env` in the installation directory to override settings - -This allows: -- **Pre-configured deployments** for your customers -- **Easy customization** by users (just edit the `.env` file) -- **No manual setup** required after installation +1. Package the app with the correct `.env` bootstrap defaults. +2. Launch the app once. +3. Configure URLs, mappings, language, and branding in the Settings dialog. +4. Let the app save `config.json` in the brand-specific config directory. +5. Reuse exported profiles or branding templates for future setups. ## Build Command Reference ### Windows ```bash -# Default (.env from project root) +# Default build using the repository root .env python build/scripts/build_windows.py --msi -# Custom .env file +# Customer-specific defaults python build/scripts/build_windows.py --msi --env-file customer_configs/acme.env -# Without MSI (just EXE) +# Without MSI (just the packaged executable) python build/scripts/build_windows.py -# Sign executable (requires CODE_SIGN_CERT env var) +# Sign executable (requires signing setup) python build/scripts/build_windows.py --msi --code-sign ``` ### macOS ```bash -# Default (.env from project root) +# Default build using the repository root .env bash build/scripts/build_macos.sh -# Custom .env file +# Customer-specific defaults bash build/scripts/build_macos.sh --env-file customer_configs/acme.env # Sign app (requires Apple developer certificate) @@ -144,19 +158,11 @@ bash build/scripts/build_macos.sh --sign bash build/scripts/build_macos.sh --notarize ``` -## Configuration Validation +## Validation Notes The build process validates that: -1. ✅ The specified `.env` file exists -2. ✅ All required environment variables are present -3. ✅ Values are valid (LOG_LEVEL is valid, paths exist for ALLOWED_ROOTS, etc.) +1. the specified `.env` file exists, +2. packaging metadata can be resolved, and +3. the resulting installer assets are created successfully. -If validation fails, the build stops with a clear error message. - -## Version Management - -The `APP_VERSION` is read from two places (in order): -1. `.env` file (if specified) -2. `src/webdrop_bridge/__init__.py` (as fallback) - -This allows you to override the version per customer if needed. +If you need the full runtime JSON schema, see `CONFIG_README.md`. diff --git a/docs/CUSTOMER_BUILD_EXAMPLES.md b/docs/CUSTOMER_BUILD_EXAMPLES.md index de97cf3..20146f9 100644 --- a/docs/CUSTOMER_BUILD_EXAMPLES.md +++ b/docs/CUSTOMER_BUILD_EXAMPLES.md @@ -20,7 +20,7 @@ webdrop_bridge/ python build/scripts/build_windows.py --msi ``` -**Result:** `WebDropBridge-x.x.x-Setup.msi` with your `.env` configuration bundled. +**Result:** `WebDropBridge--win-x64.msi` with your packaged bootstrap defaults bundled. --- @@ -47,7 +47,7 @@ webdrop_bridge/ **Customer Config Example:** `deploy/customer_configs/acme_corp.env` ```dotenv APP_NAME=WebDrop Bridge - ACME Corp Edition -APP_VERSION=1.0.0 +BRAND_ID=acme_corp WEBAPP_URL=https://acme-drop.example.com/drop ALLOWED_ROOTS=Z:/acme_files/,C:/Users/Public/ACME LOG_LEVEL=INFO @@ -72,9 +72,9 @@ python build/scripts/build_windows.py --msi --env-file deploy/customer_configs/i python build/scripts/build_windows.py --msi --env-file deploy/customer_configs/wayne_enterprises.env ``` -**Result:** Four separate MSI files: -- `WebDropBridge-1.0.0-Setup.msi` (ACME - says "ACME Corp Edition") -- `WebDropBridge-1.0.0-Setup.msi` (Globex - say "Globex Edition") +**Result:** Four separate MSI files, for example: +- `WebDropBridge--win-x64.msi` (default brand) +- `AcmeBridge--win-x64.msi` (if the customer build uses its own asset prefix) - etc. --- diff --git a/docs/PACKAGE_MANAGER_SUPPORT.md b/docs/PACKAGE_MANAGER_SUPPORT.md index e49d914..022fbdf 100644 --- a/docs/PACKAGE_MANAGER_SUPPORT.md +++ b/docs/PACKAGE_MANAGER_SUPPORT.md @@ -20,16 +20,16 @@ WebDropBridge supports installation via package managers, making it easier for u # 1. Build the Chocolatey package cd build/chocolatey python ../../build/scripts/build_windows.py --msi -certutil -hashfile "../../build/dist/windows/WebDropBridge_Setup.msi" SHA256 +certutil -hashfile "../../build/dist/windows/webdrop_bridge/WebDropBridge--win-x64.msi" SHA256 # Update checksum in tools/chocolateyInstall.ps1 choco pack webdrop-bridge.nuspec -# 2. Share webdrop-bridge.0.8.0.nupkg +# 2. Share webdrop-bridge..nupkg # File share: \\server\packages\ # USB drive, email, Forgejo releases, etc. # 3. Users install it -# choco install webdrop-bridge.0.8.0.nupkg -s "\\server\packages" +# choco install webdrop-bridge..nupkg -s "\\server\packages" ``` **Advantages:** @@ -56,21 +56,21 @@ choco pack webdrop-bridge.nuspec python build/scripts/build_windows.py --msi # 2. Calculate SHA256 checksum of the MSI -certutil -hashfile "build/dist/windows/WebDropBridge_Setup.msi" SHA256 +certutil -hashfile "build/dist/windows/webdrop_bridge/WebDropBridge--win-x64.msi" SHA256 # 3. Update the checksum in build/chocolatey/tools/chocolateyInstall.ps1 # Replace: $Checksum = '' # With: $Checksum = 'YOUR_SHA256_HASH' # 4. Update version in chocolatey/webdrop-bridge.nuspec -# 0.8.0 +# X.Y.Z # 5. Create the package cd build/chocolatey choco pack webdrop-bridge.nuspec ``` -This creates `webdrop-bridge.0.8.0.nupkg` +This creates `webdrop-bridge..nupkg` ### Publishing to Chocolatey @@ -83,7 +83,7 @@ Host on your own NuGet server (Azure Artifacts, Artifactory, ProGet, etc.): choco source add -n=internal-repo -s "https://your-artifactory.internal/nuget/chocolatey/" # Push package to internal repo -nuget push webdrop-bridge.0.8.0.nupkg -Source https://your-artifactory.internal/nuget/chocolatey/ -ApiKey YOUR_API_KEY +nuget push webdrop-bridge..nupkg -Source https://your-artifactory.internal/nuget/chocolatey/ -ApiKey YOUR_API_KEY # Users install from internal repo (already configured) choco install webdrop-bridge @@ -95,7 +95,7 @@ If you want public distribution (requires community maintainer account): ```bash # Push to community repo -choco push webdrop-bridge.0.8.0.nupkg --api-key YOUR_CHOCOLATEY_API_KEY +choco push webdrop-bridge..nupkg --api-key YOUR_CHOCOLATEY_API_KEY ``` **Option 3: No Repository (Direct Distribution)** @@ -103,8 +103,8 @@ choco push webdrop-bridge.0.8.0.nupkg --api-key YOUR_CHOCOLATEY_API_KEY Share the `.nupkg` file directly, users install locally: ```powershell -# User downloads webdrop-bridge.0.8.0.nupkg and runs: -choco install webdrop-bridge.0.8.0.nupkg -s C:\path\to\package\folder +# User downloads webdrop-bridge..nupkg and runs: +choco install webdrop-bridge..nupkg -s C:\path\to\package\folder ``` ### User Installation @@ -119,7 +119,7 @@ choco install webdrop-bridge choco install webdrop-bridge # If distributing directly -choco install webdrop-bridge.0.8.0.nupkg -s "\\network\share\packages" +choco install webdrop-bridge..nupkg -s "\\network\share\packages" ``` ## Homebrew (macOS) @@ -183,11 +183,11 @@ Submit to `homebrew/casks` (requires more maintenance but no separate tap): bash build/scripts/build_macos.sh # 2. Calculate SHA256 checksum -shasum -a 256 "build/dist/macos/WebDropBridge_Setup.dmg" +shasum -a 256 "build/dist/macos/webdrop_bridge/WebDropBridge--macos-universal.dmg" # 3. Update formula with checksum and URL # build/homebrew/webdrop-bridge.rb -# - url: https://git.him-tools.de/...releases/download/vX.X.X/WebDropBridge_Setup.dmg +# - url: https://git.him-tools.de/...releases/download/vX.Y.Z/WebDropBridge-X.Y.Z-macos-universal.dmg # - sha256: YOUR_SHA256_HASH ``` @@ -210,7 +210,7 @@ webdrop-bridge --version # If CLI exists, or check Applications folder ### Step 1: Build Release ```bash -# Release v0.8.0 +# Release vX.Y.Z # Windows MSI python build/scripts/build_windows.py --msi @@ -224,8 +224,8 @@ bash build/scripts/build_macos.sh Tag and upload installers to Forgejo: ```bash -git tag -a v0.8.0 -m "Release 0.8.0" -git push upstream v0.8.0 +git tag -a vX.Y.Z -m "Release X.Y.Z" +git push upstream vX.Y.Z # Upload MSI and DMG to Forgejo release page ``` @@ -234,10 +234,10 @@ git push upstream v0.8.0 ```bash # Windows -certutil -hashfile WebDropBridge_Setup.msi SHA256 +certutil -hashfile WebDropBridge--win-x64.msi SHA256 # macOS -shasum -a 256 WebDropBridge_Setup.dmg +shasum -a 256 WebDropBridge--macos-universal.dmg ``` ### Step 4: Update Package Manager Files @@ -258,7 +258,7 @@ sha256 "MACOS_SHA256_HASH" ```powershell cd build/chocolatey choco pack -choco install webdrop-bridge.0.8.0.nupkg -s . +choco install webdrop-bridge..nupkg -s . ``` **Homebrew (with tap):** @@ -270,7 +270,7 @@ brew install ./build/homebrew/webdrop-bridge.rb **Chocolatey:** ```powershell -choco push webdrop-bridge.0.8.0.nupkg --api-key YOUR_KEY +choco push webdrop-bridge..nupkg --api-key YOUR_KEY ``` **Homebrew:** @@ -343,7 +343,7 @@ This works for: ### Chocolatey Issues **Package won't install:** -- Verify checksum: `certutil -hashfile WebDropBridge_Setup.msi SHA256` +- Verify checksum: `certutil -hashfile WebDropBridge--win-x64.msi SHA256` - Check MSI exists at URL: `wget URL` - Verify SHA256 matches in `chocolateyInstall.ps1` @@ -356,7 +356,7 @@ This works for: **Formula won't install:** - Validate syntax: `brew audit --formula webdrop-bridge.rb` - Check URL is accessible: `curl -I URL` -- Verify SHA256: `shasum -a 256 WebDropBridge_Setup.dmg` +- Verify SHA256: `shasum -a 256 WebDropBridge--macos-universal.dmg` **Upgrade fails:** - Remove old version: `brew uninstall webdrop-bridge` @@ -375,7 +375,7 @@ This works for: 1. **Easiest: Direct Distribution** ✅ - Share `.nupkg` file via file share or email - - Users: `choco install webdrop-bridge.0.8.0.nupkg -s "\\share\packages"` + - Users: `choco install webdrop-bridge..nupkg -s "\\share\packages"` - No infrastructure needed - No maintainer account required