feat: Add branding import/export functionality and enhance settings dialog with new fields
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions

This commit is contained in:
claudi 2026-04-15 16:26:38 +02:00
parent b826bd9b20
commit 55f2ddf4b1
10 changed files with 296 additions and 10 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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