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(