From b988532aaa97b7ff01cde776614da45490bcb1b3 Mon Sep 17 00:00:00 2001 From: claudi Date: Tue, 10 Mar 2026 16:02:24 +0100 Subject: [PATCH 1/2] feat: implement brand-specific configuration and update management for Agravity Bridge --- config.example.json | 8 +- src/webdrop_bridge/config.py | 119 ++++++++++++++-- src/webdrop_bridge/core/config_manager.py | 19 ++- src/webdrop_bridge/core/updater.py | 162 +++++++++++++++++++--- src/webdrop_bridge/main.py | 2 + src/webdrop_bridge/ui/main_window.py | 28 +++- src/webdrop_bridge/ui/settings_dialog.py | 4 +- tests/unit/test_config.py | 53 ++++++- tests/unit/test_updater.py | 114 +++++++++++++++ 9 files changed, 461 insertions(+), 48 deletions(-) diff --git a/config.example.json b/config.example.json index c97367e..d93d339 100644 --- a/config.example.json +++ b/config.example.json @@ -1,6 +1,12 @@ { - "app_name": "WebDrop Bridge", + "brand_id": "agravity", + "config_dir_name": "agravity_bridge", + "app_name": "Agravity Bridge", "webapp_url": "https://dev.agravity.io/", + "update_base_url": "https://git.him-tools.de", + "update_repo": "HIM-public/webdrop-bridge", + "update_channel": "stable", + "update_manifest_name": "release-manifest.json", "url_mappings": [ { "url_prefix": "https://devagravitystg.file.core.windows.net/devagravitysync/", diff --git a/src/webdrop_bridge/config.py b/src/webdrop_bridge/config.py index c20c93d..f4c035f 100644 --- a/src/webdrop_bridge/config.py +++ b/src/webdrop_bridge/config.py @@ -3,6 +3,7 @@ import json import logging import os +import sys from dataclasses import dataclass, field from pathlib import Path from typing import List @@ -11,6 +12,13 @@ from dotenv import load_dotenv logger = logging.getLogger(__name__) +DEFAULT_BRAND_ID = "webdrop_bridge" +DEFAULT_CONFIG_DIR_NAME = "webdrop_bridge" +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" + class ConfigurationError(Exception): """Raised when configuration is invalid.""" @@ -60,6 +68,12 @@ class Config: enable_logging: Whether to write logs to file enable_checkout: Whether to check asset checkout status and show checkout dialog on drag. Disabled by default as checkout support is optional. + brand_id: Stable brand identifier used for packaging and update selection + config_dir_name: AppData/config directory name for this branded variant + update_base_url: Base Forgejo URL used for release checks + update_repo: Forgejo repository containing shared releases + update_channel: Update channel name used by release manifest selection + update_manifest_name: Asset name of the shared release manifest Raises: ConfigurationError: If configuration values are invalid @@ -82,6 +96,12 @@ class Config: enable_logging: bool = True enable_checkout: bool = False language: str = "auto" + brand_id: str = DEFAULT_BRAND_ID + config_dir_name: str = DEFAULT_CONFIG_DIR_NAME + update_base_url: str = DEFAULT_UPDATE_BASE_URL + update_repo: str = DEFAULT_UPDATE_REPO + update_channel: str = DEFAULT_UPDATE_CHANNEL + update_manifest_name: str = DEFAULT_UPDATE_MANIFEST_NAME @classmethod def from_file(cls, config_path: Path) -> "Config": @@ -124,6 +144,9 @@ class Config: elif not root.is_dir(): raise ConfigurationError(f"Allowed root is not a directory: {root}") + brand_id = data.get("brand_id", DEFAULT_BRAND_ID) + config_dir_name = data.get("config_dir_name", cls._slugify_config_dir_name(brand_id)) + # Get log file path log_file = None if data.get("enable_logging", True): @@ -132,10 +155,10 @@ class Config: log_file = Path(log_file_str) # If relative path, resolve relative to app data directory instead of cwd if not log_file.is_absolute(): - log_file = Config.get_default_log_dir() / log_file + log_file = Config.get_default_log_dir(config_dir_name) / log_file else: # Use default log path in app data - log_file = Config.get_default_log_path() + log_file = Config.get_default_log_path(config_dir_name) app_name = data.get("app_name", "WebDrop Bridge") stored_window_title = data.get("window_title", "") @@ -174,6 +197,12 @@ class Config: enable_logging=data.get("enable_logging", True), enable_checkout=data.get("enable_checkout", False), language=data.get("language", "auto"), + brand_id=brand_id, + config_dir_name=config_dir_name, + update_base_url=data.get("update_base_url", DEFAULT_UPDATE_BASE_URL), + update_repo=data.get("update_repo", DEFAULT_UPDATE_REPO), + update_channel=data.get("update_channel", DEFAULT_UPDATE_CHANNEL), + update_manifest_name=data.get("update_manifest_name", DEFAULT_UPDATE_MANIFEST_NAME), ) @classmethod @@ -201,6 +230,8 @@ class Config: from webdrop_bridge import __version__ app_version = __version__ + brand_id = os.getenv("BRAND_ID", DEFAULT_BRAND_ID) + config_dir_name = os.getenv("APP_CONFIG_DIR_NAME", cls._slugify_config_dir_name(brand_id)) log_level = os.getenv("LOG_LEVEL", "INFO").upper() log_file_str = os.getenv("LOG_FILE", None) @@ -215,6 +246,10 @@ 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") + 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) + update_manifest_name = os.getenv("UPDATE_MANIFEST_NAME", DEFAULT_UPDATE_MANIFEST_NAME) # Validate log level valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} @@ -254,10 +289,10 @@ class Config: log_file = Path(log_file_str) # If relative path, resolve relative to app data directory instead of cwd if not log_file.is_absolute(): - log_file = Config.get_default_log_dir() / log_file + log_file = Config.get_default_log_dir(config_dir_name) / log_file else: # Use default log path in app data - log_file = Config.get_default_log_path() + log_file = Config.get_default_log_path(config_dir_name) # Validate webapp URL is not empty if not webapp_url: @@ -308,6 +343,12 @@ class Config: enable_logging=enable_logging, enable_checkout=enable_checkout, language=language, + brand_id=brand_id, + config_dir_name=config_dir_name, + update_base_url=update_base_url, + update_repo=update_repo, + update_channel=update_channel, + update_manifest_name=update_manifest_name, ) def to_file(self, config_path: Path) -> None: @@ -337,6 +378,12 @@ class Config: "enable_logging": self.enable_logging, "enable_checkout": self.enable_checkout, "language": self.language, + "brand_id": self.brand_id, + "config_dir_name": self.config_dir_name, + "update_base_url": self.update_base_url, + "update_repo": self.update_repo, + "update_channel": self.update_channel, + "update_manifest_name": self.update_manifest_name, } config_path.parent.mkdir(parents=True, exist_ok=True) @@ -344,7 +391,49 @@ class Config: json.dump(data, f, indent=2) @staticmethod - def get_default_config_path() -> Path: + def load_bootstrap_env(env_file: str | None = None) -> Path | None: + """Load a bootstrap .env before configuration path lookup. + + This lets branded builds decide their config directory before the main + config file is loaded. + + Args: + env_file: Optional explicit .env path + + Returns: + Path to the loaded .env file, or None if nothing was loaded + """ + candidate_paths: list[Path] = [] + if env_file: + candidate_paths.append(Path(env_file).resolve()) + else: + if getattr(sys, "frozen", False): + candidate_paths.append(Path(sys.executable).resolve().parent / ".env") + + candidate_paths.append(Path.cwd() / ".env") + candidate_paths.append(Path(__file__).resolve().parents[2] / ".env") + + for path in candidate_paths: + if path.exists(): + load_dotenv(path, override=False) + logger.debug(f"Loaded bootstrap environment from {path}") + return path + + return None + + @staticmethod + def _slugify_config_dir_name(value: str) -> str: + """Convert brand-like identifiers into a filesystem-safe directory name.""" + sanitized = "".join(c.lower() if c.isalnum() else "_" for c in value).strip("_") + return sanitized or DEFAULT_CONFIG_DIR_NAME + + @staticmethod + def get_default_config_dir_name() -> str: + """Get the default config directory name from environment or fallback.""" + return os.getenv("APP_CONFIG_DIR_NAME", DEFAULT_CONFIG_DIR_NAME) + + @staticmethod + def get_default_config_path(config_dir_name: str | None = None) -> Path: """Get the default configuration file path. Returns: @@ -356,10 +445,10 @@ class Config: base = Path.home() / "AppData" / "Roaming" else: base = Path.home() / ".config" - return base / "webdrop_bridge" / "config.json" + return base / (config_dir_name or Config.get_default_config_dir_name()) / "config.json" @staticmethod - def get_default_log_dir() -> Path: + def get_default_log_dir(config_dir_name: str | None = None) -> Path: """Get the default directory for log files. Always uses user's AppData directory to ensure permissions work @@ -374,21 +463,31 @@ class Config: base = Path.home() / "AppData" / "Roaming" else: base = Path.home() / ".local" / "share" - return base / "webdrop_bridge" / "logs" + return base / (config_dir_name or Config.get_default_config_dir_name()) / "logs" @staticmethod - def get_default_log_path() -> Path: + def get_default_log_path(config_dir_name: str | None = None) -> Path: """Get the default log file path. Returns: Path to default log file in user's AppData/Roaming/webdrop_bridge/logs """ - return Config.get_default_log_dir() / "webdrop_bridge.log" + dir_name = config_dir_name or Config.get_default_config_dir_name() + return Config.get_default_log_dir(dir_name) / f"{dir_name}.log" + + def get_config_path(self) -> Path: + """Get the default config file path for this configured brand.""" + return self.get_default_config_path(self.config_dir_name) + + def get_cache_dir(self) -> Path: + """Get the update/cache directory for this configured brand.""" + return self.get_default_config_path(self.config_dir_name).parent / "cache" def __repr__(self) -> str: """Return developer-friendly representation.""" return ( f"Config(app={self.app_name} v{self.app_version}, " + f"brand={self.brand_id}, " f"log_level={self.log_level}, " f"allowed_roots={len(self.allowed_roots)} dirs, " f"window={self.window_width}x{self.window_height})" diff --git a/src/webdrop_bridge/core/config_manager.py b/src/webdrop_bridge/core/config_manager.py index 52798ee..4c4be27 100644 --- a/src/webdrop_bridge/core/config_manager.py +++ b/src/webdrop_bridge/core/config_manager.py @@ -101,14 +101,13 @@ class ConfigValidator: class ConfigProfile: """Manages named configuration profiles. - Profiles are stored in ~/.webdrop_bridge/profiles/ directory as JSON files. + Profiles are stored in the brand-specific app config directory. """ - PROFILES_DIR = Path.home() / ".webdrop_bridge" / "profiles" - - def __init__(self) -> None: + def __init__(self, config_dir_name: str = "webdrop_bridge") -> None: """Initialize profile manager.""" - self.PROFILES_DIR.mkdir(parents=True, exist_ok=True) + self.profiles_dir = Config.get_default_config_path(config_dir_name).parent / "profiles" + self.profiles_dir.mkdir(parents=True, exist_ok=True) def save_profile(self, profile_name: str, config: Config) -> Path: """Save configuration as a named profile. @@ -126,7 +125,7 @@ class ConfigProfile: if not profile_name or "/" in profile_name or "\\" in profile_name: raise ConfigurationError(f"Invalid profile name: {profile_name}") - profile_path = self.PROFILES_DIR / f"{profile_name}.json" + profile_path = self.profiles_dir / f"{profile_name}.json" config_data = { "app_name": config.app_name, @@ -160,7 +159,7 @@ class ConfigProfile: Raises: ConfigurationError: If profile not found or invalid """ - profile_path = self.PROFILES_DIR / f"{profile_name}.json" + profile_path = self.profiles_dir / f"{profile_name}.json" if not profile_path.exists(): raise ConfigurationError(f"Profile not found: {profile_name}") @@ -179,10 +178,10 @@ class ConfigProfile: Returns: List of profile names (without .json extension) """ - if not self.PROFILES_DIR.exists(): + if not self.profiles_dir.exists(): return [] - return sorted([p.stem for p in self.PROFILES_DIR.glob("*.json")]) + return sorted([p.stem for p in self.profiles_dir.glob("*.json")]) def delete_profile(self, profile_name: str) -> None: """Delete a profile. @@ -193,7 +192,7 @@ class ConfigProfile: Raises: ConfigurationError: If profile not found """ - profile_path = self.PROFILES_DIR / f"{profile_name}.json" + profile_path = self.profiles_dir / f"{profile_name}.json" if not profile_path.exists(): raise ConfigurationError(f"Profile not found: {profile_name}") diff --git a/src/webdrop_bridge/core/updater.py b/src/webdrop_bridge/core/updater.py index 2f2b3b6..92fe794 100644 --- a/src/webdrop_bridge/core/updater.py +++ b/src/webdrop_bridge/core/updater.py @@ -5,9 +5,11 @@ verifying checksums from Forgejo releases. """ import asyncio +import fnmatch import hashlib import json import logging +import platform import socket from dataclasses import dataclass from datetime import datetime, timedelta @@ -34,7 +36,16 @@ class Release: class UpdateManager: """Manages auto-updates via Forgejo releases API.""" - def __init__(self, current_version: str, config_dir: Optional[Path] = None): + def __init__( + self, + current_version: str, + config_dir: Optional[Path] = None, + brand_id: str = "webdrop_bridge", + forgejo_url: str = "https://git.him-tools.de", + repo: str = "HIM-public/webdrop-bridge", + update_channel: str = "stable", + manifest_name: str = "release-manifest.json", + ): """Initialize update manager. Args: @@ -42,8 +53,11 @@ class UpdateManager: config_dir: Directory for storing update cache. Defaults to temp. """ self.current_version = current_version - self.forgejo_url = "https://git.him-tools.de" - self.repo = "HIM-public/webdrop-bridge" + self.brand_id = brand_id + self.forgejo_url = forgejo_url.rstrip("/") + self.repo = repo + self.update_channel = update_channel + self.manifest_name = manifest_name self.api_endpoint = f"{self.forgejo_url}/api/v1/repos/{self.repo}/releases/latest" # Cache management @@ -52,6 +66,128 @@ class UpdateManager: self.cache_file = self.cache_dir / "update_check.json" self.cache_ttl = timedelta(hours=24) + def _get_platform_key(self) -> str: + """Return the release-manifest platform key for the current system.""" + system = platform.system() + machine = platform.machine().lower() + + if system == "Windows": + arch = "x64" if machine in {"amd64", "x86_64"} else machine + return f"windows-{arch}" + if system == "Darwin": + return "macos-universal" + return f"{system.lower()}-{machine}" + + def _find_asset(self, assets: list[dict], asset_name: str) -> Optional[dict]: + """Find an asset by exact name.""" + for asset in assets: + if asset.get("name") == asset_name: + return asset + return None + + def _find_manifest_asset(self, release: Release) -> Optional[dict]: + """Find the shared release manifest asset if present.""" + return self._find_asset(release.assets, self.manifest_name) + + def _download_json_asset(self, url: str) -> Optional[dict]: + """Download and parse a JSON asset from a release.""" + try: + with urlopen(url, timeout=10) as response: + return json.loads(response.read().decode("utf-8")) + except (URLError, json.JSONDecodeError) as e: + logger.error(f"Failed to download JSON asset: {e}") + return None + + async def _load_release_manifest(self, release: Release) -> Optional[dict]: + """Load the shared release manifest if present.""" + manifest_asset = self._find_manifest_asset(release) + if not manifest_asset: + return None + + loop = asyncio.get_event_loop() + return await asyncio.wait_for( + loop.run_in_executor( + None, self._download_json_asset, manifest_asset["browser_download_url"] + ), + timeout=15, + ) + + def _resolve_assets_from_manifest( + self, release: Release, manifest: dict + ) -> tuple[Optional[dict], Optional[dict]]: + """Resolve installer and checksum assets from a shared release manifest.""" + if manifest.get("channel") not in {None, "", self.update_channel}: + logger.info( + "Release manifest channel %s does not match configured channel %s", + manifest.get("channel"), + self.update_channel, + ) + return None, None + + brand_entry = manifest.get("brands", {}).get(self.brand_id, {}) + platform_entry = brand_entry.get(self._get_platform_key(), {}) + installer_name = platform_entry.get("installer") + checksum_name = platform_entry.get("checksum") + + if not installer_name: + logger.warning( + "No installer entry found for brand=%s platform=%s in release manifest", + self.brand_id, + self._get_platform_key(), + ) + return None, None + + return self._find_asset(release.assets, installer_name), self._find_asset( + release.assets, checksum_name + ) + + def _resolve_assets_legacy(self, release: Release) -> tuple[Optional[dict], Optional[dict]]: + """Resolve installer and checksum assets using legacy filename matching.""" + is_windows = platform.system() == "Windows" + extension = ".msi" if is_windows else ".dmg" + brand_prefix = f"{self.brand_id}-*" + + installer_asset = None + for asset in release.assets: + asset_name = asset.get("name", "") + if not asset_name.endswith(extension): + continue + + if self.brand_id != "webdrop_bridge" and fnmatch.fnmatch( + asset_name.lower(), brand_prefix.lower() + ): + installer_asset = asset + break + + if self.brand_id == "webdrop_bridge": + installer_asset = asset + break + + if not installer_asset: + return None, None + + checksum_asset = self._find_asset(release.assets, f"{installer_asset['name']}.sha256") + return installer_asset, checksum_asset + + async def _resolve_release_assets( + self, release: Release + ) -> tuple[Optional[dict], Optional[dict]]: + """Resolve installer and checksum assets for the configured brand.""" + try: + manifest = await self._load_release_manifest(release) + except asyncio.TimeoutError: + logger.warning( + "Timed out while loading release manifest, falling back to legacy lookup" + ) + manifest = None + + if manifest: + installer_asset, checksum_asset = self._resolve_assets_from_manifest(release, manifest) + if installer_asset: + return installer_asset, checksum_asset + + return self._resolve_assets_legacy(release) + def _parse_version(self, version_str: str) -> tuple[int, int, int]: """Parse semantic version string to tuple. @@ -253,12 +389,7 @@ class UpdateManager: logger.error("No assets found in release") return None - # Find .msi or .dmg file - installer_asset = None - for asset in release.assets: - if asset["name"].endswith((".msi", ".dmg")): - installer_asset = asset - break + installer_asset, _ = await self._resolve_release_assets(release) if not installer_asset: logger.error("No installer found in release assets") @@ -345,14 +476,11 @@ class UpdateManager: Returns: True if checksum matches, False otherwise """ - # Find .sha256 file matching the installer name (e.g. Setup.msi.sha256) - # Fall back to any .sha256 only if no specific match exists - installer_name = file_path.name - checksum_asset = None - for asset in release.assets: - if asset["name"] == f"{installer_name}.sha256": - checksum_asset = asset - break + installer_asset, checksum_asset = await self._resolve_release_assets(release) + installer_name = installer_asset["name"] if installer_asset else file_path.name + + if not checksum_asset: + checksum_asset = self._find_asset(release.assets, f"{installer_name}.sha256") if not checksum_asset: logger.warning("No checksum file found in release") diff --git a/src/webdrop_bridge/main.py b/src/webdrop_bridge/main.py index 4e90a7b..1194d69 100644 --- a/src/webdrop_bridge/main.py +++ b/src/webdrop_bridge/main.py @@ -30,6 +30,8 @@ def main() -> int: int: Exit code (0 for success, non-zero for error) """ try: + Config.load_bootstrap_env() + # Load configuration from file if it exists, otherwise from environment config_path = Config.get_default_config_path() if config_path.exists(): diff --git a/src/webdrop_bridge/ui/main_window.py b/src/webdrop_bridge/ui/main_window.py index 6462ca6..c4f9967 100644 --- a/src/webdrop_bridge/ui/main_window.py +++ b/src/webdrop_bridge/ui/main_window.py @@ -1872,8 +1872,16 @@ class MainWindow(QMainWindow): try: # Create update manager - cache_dir = Path.home() / ".webdrop_bridge" - manager = UpdateManager(current_version=self.config.app_version, config_dir=cache_dir) + cache_dir = self.config.get_cache_dir() + manager = UpdateManager( + current_version=self.config.app_version, + config_dir=cache_dir, + brand_id=self.config.brand_id, + forgejo_url=self.config.update_base_url, + repo=self.config.update_repo, + update_channel=self.config.update_channel, + manifest_name=self.config.update_manifest_name, + ) # Run async check in background self._run_async_check(manager) @@ -2090,7 +2098,13 @@ class MainWindow(QMainWindow): # Create update manager manager = UpdateManager( - current_version=self.config.app_version, config_dir=Path.home() / ".webdrop_bridge" + current_version=self.config.app_version, + config_dir=self.config.get_cache_dir(), + brand_id=self.config.brand_id, + forgejo_url=self.config.update_base_url, + repo=self.config.update_repo, + update_channel=self.config.update_channel, + manifest_name=self.config.update_manifest_name, ) # Create and start background thread @@ -2229,7 +2243,13 @@ class MainWindow(QMainWindow): from webdrop_bridge.core.updater import UpdateManager manager = UpdateManager( - current_version=self.config.app_version, config_dir=Path.home() / ".webdrop_bridge" + current_version=self.config.app_version, + config_dir=self.config.get_cache_dir(), + brand_id=self.config.brand_id, + forgejo_url=self.config.update_base_url, + repo=self.config.update_repo, + update_channel=self.config.update_channel, + manifest_name=self.config.update_manifest_name, ) if manager.install_update(installer_path): diff --git a/src/webdrop_bridge/ui/settings_dialog.py b/src/webdrop_bridge/ui/settings_dialog.py index 935aee1..99f5241 100644 --- a/src/webdrop_bridge/ui/settings_dialog.py +++ b/src/webdrop_bridge/ui/settings_dialog.py @@ -42,7 +42,7 @@ class SettingsDialog(QDialog): """ super().__init__(parent) self.config = config - self.profile_manager = ConfigProfile() + self.profile_manager = ConfigProfile(config.config_dir_name) self.setWindowTitle(tr("settings.title")) self.setGeometry(100, 100, 600, 500) @@ -96,7 +96,7 @@ class SettingsDialog(QDialog): self.config.window_width = config_data["window_width"] self.config.window_height = config_data["window_height"] - config_path = Config.get_default_config_path() + config_path = self.config.get_config_path() self.config.to_file(config_path) logger.info(f"Configuration saved to {config_path}") diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index fdeda3d..2c2e9ce 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -12,14 +12,26 @@ def clear_env(): """Clear environment variables before each test to avoid persistence.""" # Save current env saved_env = os.environ.copy() - + # Clear relevant variables for key in list(os.environ.keys()): - if key.startswith(('APP_', 'LOG_', 'ALLOWED_', 'WEBAPP_', 'WINDOW_', 'ENABLE_')): + if key.startswith( + ( + "APP_", + "LOG_", + "ALLOWED_", + "WEBAPP_", + "WINDOW_", + "ENABLE_", + "BRAND_", + "UPDATE_", + "LANGUAGE", + ) + ): del os.environ[key] - + yield - + # Restore env (cleanup) os.environ.clear() os.environ.update(saved_env) @@ -64,6 +76,28 @@ class TestConfigFromEnv: assert config.window_width == 1200 assert config.window_height == 800 + def test_from_env_with_branding_values(self, tmp_path): + """Test loading branding and update metadata from environment.""" + env_file = tmp_path / ".env" + root1 = tmp_path / "root1" + root1.mkdir() + env_file.write_text( + f"BRAND_ID=agravity\n" + f"APP_CONFIG_DIR_NAME=agravity_bridge\n" + f"UPDATE_REPO=HIM-public/webdrop-bridge\n" + f"UPDATE_CHANNEL=stable\n" + f"UPDATE_MANIFEST_NAME=release-manifest.json\n" + f"ALLOWED_ROOTS={root1}\n" + ) + + config = Config.from_env(str(env_file)) + + assert config.brand_id == "agravity" + assert config.config_dir_name == "agravity_bridge" + assert config.update_repo == "HIM-public/webdrop-bridge" + assert config.update_channel == "stable" + assert config.update_manifest_name == "release-manifest.json" + def test_from_env_with_defaults(self, tmp_path): """Test loading config uses defaults when env vars not set.""" # Create empty .env file @@ -73,8 +107,11 @@ class TestConfigFromEnv: config = Config.from_env(str(env_file)) assert config.app_name == "WebDrop Bridge" + assert config.brand_id == "webdrop_bridge" + assert config.config_dir_name == "webdrop_bridge" # Version should come from __init__.py (dynamic, not hardcoded) from webdrop_bridge import __version__ + assert config.app_version == __version__ assert config.log_level == "INFO" assert config.window_width == 1024 @@ -187,3 +224,11 @@ class TestConfigValidation: config = Config.from_env(str(env_file)) assert config.allowed_urls == ["example.com", "test.org"] + + def test_brand_specific_default_paths(self): + """Test brand-specific config and log directories.""" + config_path = Config.get_default_config_path("agravity_bridge") + log_path = Config.get_default_log_path("agravity_bridge") + + assert config_path.parts[-2:] == ("agravity_bridge", "config.json") + assert log_path.parts[-2:] == ("logs", "agravity_bridge.log") diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py index 1685f20..f3f09a4 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -16,6 +16,17 @@ def update_manager(tmp_path): return UpdateManager(current_version="0.0.1", config_dir=tmp_path) +@pytest.fixture +def agravity_update_manager(tmp_path): + """Create a brand-aware UpdateManager instance for Agravity Bridge.""" + return UpdateManager( + current_version="0.0.1", + config_dir=tmp_path, + brand_id="agravity", + update_channel="stable", + ) + + @pytest.fixture def sample_release(): """Sample release data from API.""" @@ -252,6 +263,109 @@ class TestDownloading: assert result is None + @pytest.mark.asyncio + async def test_download_update_uses_release_manifest(self, agravity_update_manager, tmp_path): + """Test branded download selection from a shared release manifest.""" + release = Release( + tag_name="v0.0.2", + name="WebDropBridge v0.0.2", + version="0.0.2", + body="Release notes", + assets=[ + { + "name": "AgravityBridge-0.0.2-win-x64.msi", + "browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi", + }, + { + "name": "AgravityBridge-0.0.2-win-x64.msi.sha256", + "browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi.sha256", + }, + { + "name": "OtherBridge-0.0.2-win-x64.msi", + "browser_download_url": "https://example.com/OtherBridge-0.0.2-win-x64.msi", + }, + { + "name": "release-manifest.json", + "browser_download_url": "https://example.com/release-manifest.json", + }, + ], + published_at="2026-01-29T10:00:00Z", + ) + + manifest = { + "version": "0.0.2", + "channel": "stable", + "brands": { + "agravity": { + "windows-x64": { + "installer": "AgravityBridge-0.0.2-win-x64.msi", + "checksum": "AgravityBridge-0.0.2-win-x64.msi.sha256", + } + } + }, + } + + with ( + patch.object(UpdateManager, "_download_json_asset", return_value=manifest), + patch.object(UpdateManager, "_download_file", return_value=True) as mock_download, + ): + result = await agravity_update_manager.download_update(release, tmp_path) + + assert result is not None + assert result.name == "AgravityBridge-0.0.2-win-x64.msi" + mock_download.assert_called_once() + + @pytest.mark.asyncio + async def test_verify_checksum_uses_release_manifest(self, agravity_update_manager, tmp_path): + """Test branded checksum selection from a shared release manifest.""" + test_file = tmp_path / "AgravityBridge-0.0.2-win-x64.msi" + test_file.write_bytes(b"test content") + + import hashlib + + checksum = hashlib.sha256(b"test content").hexdigest() + release = Release( + tag_name="v0.0.2", + name="WebDropBridge v0.0.2", + version="0.0.2", + body="Release notes", + assets=[ + { + "name": "AgravityBridge-0.0.2-win-x64.msi", + "browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi", + }, + { + "name": "AgravityBridge-0.0.2-win-x64.msi.sha256", + "browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi.sha256", + }, + { + "name": "release-manifest.json", + "browser_download_url": "https://example.com/release-manifest.json", + }, + ], + published_at="2026-01-29T10:00:00Z", + ) + manifest = { + "version": "0.0.2", + "channel": "stable", + "brands": { + "agravity": { + "windows-x64": { + "installer": "AgravityBridge-0.0.2-win-x64.msi", + "checksum": "AgravityBridge-0.0.2-win-x64.msi.sha256", + } + } + }, + } + + with ( + patch.object(UpdateManager, "_download_json_asset", return_value=manifest), + patch.object(UpdateManager, "_download_checksum", return_value=checksum), + ): + result = await agravity_update_manager.verify_checksum(test_file, release) + + assert result is True + class TestChecksumVerification: """Test checksum verification.""" From fd69996c53712141262e50feb36e74a2b49536ed Mon Sep 17 00:00:00 2001 From: claudi Date: Tue, 10 Mar 2026 16:18:28 +0100 Subject: [PATCH 2/2] feat: Implement brand-aware release creation for Agravity - Added support for multiple brands in release scripts, allowing for branded artifacts. - Introduced brand configuration management with JSON files for each brand. - Created a new `brand_config.py` script to handle brand-specific logic and asset resolution. - Updated `create_release.ps1` and `create_release.sh` scripts to utilize brand configurations and generate release manifests. - Added unit tests for brand configuration loading and release manifest generation. - Introduced `agravity` brand with its specific configuration in `agravity.json`. --- build/WebDropBridge.wxs | 40 +++--- build/brands/agravity.json | 18 +++ build/scripts/brand_config.py | 236 ++++++++++++++++++++++++++++++ build/scripts/build_macos.sh | 25 +++- build/scripts/build_windows.py | 158 +++++++------------- build/scripts/create_release.ps1 | 239 +++++++------------------------ build/scripts/create_release.sh | 168 +++++++++------------- tests/unit/test_brand_config.py | 77 ++++++++++ 8 files changed, 552 insertions(+), 409 deletions(-) create mode 100644 build/brands/agravity.json create mode 100644 build/scripts/brand_config.py create mode 100644 tests/unit/test_brand_config.py diff --git a/build/WebDropBridge.wxs b/build/WebDropBridge.wxs index 6ce5b67..38af7a1 100644 --- a/build/WebDropBridge.wxs +++ b/build/WebDropBridge.wxs @@ -2,23 +2,23 @@ - + - + - + - - - + + + @@ -26,12 +26,12 @@ - + @@ -39,10 +39,10 @@ - + - + @@ -50,16 +50,16 @@ str: + return f"{self.asset_prefix}-{version}-win-x64.msi" + + def macos_installer_name(self, version: str) -> str: + return f"{self.asset_prefix}-{version}-macos-universal.dmg" + + @property + def app_bundle_name(self) -> str: + return f"{self.asset_prefix}.app" + + +DEFAULT_BRAND_VALUES: dict[str, Any] = { + "brand_id": "webdrop_bridge", + "display_name": "WebDrop Bridge", + "asset_prefix": "WebDropBridge", + "exe_name": "WebDropBridge", + "manufacturer": "HIM-Tools", + "install_dir_name": "WebDrop Bridge", + "shortcut_description": "Web Drag-and-Drop Bridge", + "bundle_identifier": "de.him_tools.webdrop-bridge", + "config_dir_name": "webdrop_bridge", + "msi_upgrade_code": "12345678-1234-1234-1234-123456789012", + "update_channel": "stable", + "icon_ico": "resources/icons/app.ico", + "icon_icns": "resources/icons/app.icns", + "dialog_bmp": "resources/icons/background.bmp", + "banner_bmp": "resources/icons/banner.bmp", + "license_rtf": "resources/license.rtf", +} + + +def project_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def brands_dir(root: Path | None = None) -> Path: + base = root or project_root() + return base / "build" / "brands" + + +def load_brand_config( + brand: str | None = None, + *, + root: Path | None = None, + manifest_path: Path | None = None, +) -> BrandConfig: + """Load a brand manifest with defaults and asset fallbacks.""" + base = root or project_root() + values = dict(DEFAULT_BRAND_VALUES) + + if manifest_path is None and brand: + manifest_path = brands_dir(base) / f"{brand}.json" + + if manifest_path and manifest_path.exists(): + values.update(json.loads(manifest_path.read_text(encoding="utf-8"))) + elif manifest_path and not manifest_path.exists(): + raise FileNotFoundError(f"Brand manifest not found: {manifest_path}") + + def resolve_asset(key: str) -> Path: + candidate = base / str(values.get(key, DEFAULT_BRAND_VALUES[key])) + if candidate.exists(): + return candidate + return base / str(DEFAULT_BRAND_VALUES[key]) + + return BrandConfig( + brand_id=str(values["brand_id"]), + display_name=str(values["display_name"]), + asset_prefix=str(values["asset_prefix"]), + exe_name=str(values["exe_name"]), + manufacturer=str(values["manufacturer"]), + install_dir_name=str(values["install_dir_name"]), + shortcut_description=str(values["shortcut_description"]), + bundle_identifier=str(values["bundle_identifier"]), + config_dir_name=str(values["config_dir_name"]), + msi_upgrade_code=str(values["msi_upgrade_code"]), + update_channel=str(values.get("update_channel", "stable")), + icon_ico=resolve_asset("icon_ico"), + icon_icns=resolve_asset("icon_icns"), + dialog_bmp=resolve_asset("dialog_bmp"), + banner_bmp=resolve_asset("banner_bmp"), + license_rtf=resolve_asset("license_rtf"), + ) + + +def generate_release_manifest( + version: str, + brands: list[str], + *, + output_path: Path, + root: Path | None = None, +) -> Path: + """Generate a shared release-manifest.json from local build outputs.""" + base = root or project_root() + manifest: dict[str, Any] = { + "version": version, + "channel": "stable", + "brands": {}, + } + + for brand_name in brands: + brand = load_brand_config(brand_name, root=base) + manifest["channel"] = brand.update_channel + entries: dict[str, dict[str, str]] = {} + + windows_dir = base / "build" / "dist" / "windows" / brand.brand_id + windows_installer = windows_dir / brand.windows_installer_name(version) + windows_checksum = windows_dir / f"{windows_installer.name}.sha256" + if windows_installer.exists(): + entries["windows-x64"] = { + "installer": windows_installer.name, + "checksum": windows_checksum.name if windows_checksum.exists() else "", + } + + macos_dir = base / "build" / "dist" / "macos" / brand.brand_id + macos_installer = macos_dir / brand.macos_installer_name(version) + macos_checksum = macos_dir / f"{macos_installer.name}.sha256" + if macos_installer.exists(): + entries["macos-universal"] = { + "installer": macos_installer.name, + "checksum": macos_checksum.name if macos_checksum.exists() else "", + } + + if entries: + manifest["brands"][brand.brand_id] = entries + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + return output_path + + +def cli_env(args: argparse.Namespace) -> int: + brand = load_brand_config(args.brand) + assignments = { + "WEBDROP_BRAND_ID": brand.brand_id, + "WEBDROP_APP_DISPLAY_NAME": brand.display_name, + "WEBDROP_ASSET_PREFIX": brand.asset_prefix, + "WEBDROP_EXE_NAME": brand.exe_name, + "WEBDROP_BUNDLE_ID": brand.bundle_identifier, + "WEBDROP_CONFIG_DIR_NAME": brand.config_dir_name, + "WEBDROP_ICON_ICO": str(brand.icon_ico), + "WEBDROP_ICON_ICNS": str(brand.icon_icns), + } + for key, value in assignments.items(): + print(f'export {key}="{value}"') + return 0 + + +def cli_manifest(args: argparse.Namespace) -> int: + output = generate_release_manifest( + args.version, + args.brands, + output_path=Path(args.output).resolve(), + ) + print(output) + return 0 + + +def cli_show(args: argparse.Namespace) -> int: + brand = load_brand_config(args.brand) + print( + json.dumps( + { + "brand_id": brand.brand_id, + "display_name": brand.display_name, + "asset_prefix": brand.asset_prefix, + "exe_name": brand.exe_name, + "manufacturer": brand.manufacturer, + "install_dir_name": brand.install_dir_name, + "shortcut_description": brand.shortcut_description, + "bundle_identifier": brand.bundle_identifier, + "config_dir_name": brand.config_dir_name, + "msi_upgrade_code": brand.msi_upgrade_code, + "update_channel": brand.update_channel, + }, + indent=2, + ) + ) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Brand-aware build configuration") + subparsers = parser.add_subparsers(dest="command", required=True) + + env_parser = subparsers.add_parser("env") + env_parser.add_argument("--brand", required=True) + env_parser.set_defaults(func=cli_env) + + manifest_parser = subparsers.add_parser("release-manifest") + manifest_parser.add_argument("--version", required=True) + manifest_parser.add_argument("--output", required=True) + manifest_parser.add_argument("--brands", nargs="+", required=True) + manifest_parser.set_defaults(func=cli_manifest) + + show_parser = subparsers.add_parser("show") + show_parser.add_argument("--brand", required=True) + show_parser.set_defaults(func=cli_show) + + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/scripts/build_macos.sh b/build/scripts/build_macos.sh index 661df12..432f8fe 100644 --- a/build/scripts/build_macos.sh +++ b/build/scripts/build_macos.sh @@ -28,10 +28,13 @@ DIST_DIR="$BUILD_DIR/dist/macos" TEMP_BUILD="$BUILD_DIR/temp/macos" SPECS_DIR="$BUILD_DIR/specs" SPEC_FILE="$BUILD_DIR/webdrop_bridge.spec" +BRAND_HELPER="$BUILD_DIR/scripts/brand_config.py" +BRAND="" APP_NAME="WebDropBridge" DMG_VOLUME_NAME="WebDrop Bridge" -VERSION="1.0.0" +BUNDLE_IDENTIFIER="de.him_tools.webdrop-bridge" +VERSION="" # Default .env file ENV_FILE="$PROJECT_ROOT/.env" @@ -54,6 +57,10 @@ while [[ $# -gt 0 ]]; do ENV_FILE="$2" shift 2 ;; + --brand) + BRAND="$2" + shift 2 + ;; *) echo "Unknown option: $1" exit 1 @@ -70,6 +77,18 @@ fi echo "📋 Using configuration: $ENV_FILE" +if [ -n "$BRAND" ]; then + eval "$(python3 "$BRAND_HELPER" env --brand "$BRAND")" + APP_NAME="$WEBDROP_ASSET_PREFIX" + DMG_VOLUME_NAME="$WEBDROP_APP_DISPLAY_NAME" + BUNDLE_IDENTIFIER="$WEBDROP_BUNDLE_ID" + DIST_DIR="$BUILD_DIR/dist/macos/$WEBDROP_BRAND_ID" + TEMP_BUILD="$BUILD_DIR/temp/macos/$WEBDROP_BRAND_ID" + echo "🏷️ Building brand: $WEBDROP_APP_DISPLAY_NAME ($WEBDROP_BRAND_ID)" +fi + +VERSION="$(python3 -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$BUILD_DIR/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())")" + # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' @@ -178,6 +197,8 @@ build_executable() { # Export env file for spec file to pick up export WEBDROP_ENV_FILE="$ENV_FILE" + export WEBDROP_VERSION="$VERSION" + export WEBDROP_BUNDLE_ID="$BUNDLE_IDENTIFIER" python3 -m PyInstaller \ --distpath="$DIST_DIR" \ @@ -252,6 +273,8 @@ create_dmg() { SIZE=$(du -h "$DMG_FILE" | cut -f1) log_success "DMG created successfully" log_info "Output: $DMG_FILE (Size: $SIZE)" + shasum -a 256 "$DMG_FILE" | awk '{print $1}' > "$DMG_FILE.sha256" + log_info "Checksum: $DMG_FILE.sha256" echo "" } diff --git a/build/scripts/build_windows.py b/build/scripts/build_windows.py index 42d68fa..a507531 100644 --- a/build/scripts/build_windows.py +++ b/build/scripts/build_windows.py @@ -39,13 +39,14 @@ from pathlib import Path from datetime import datetime # Import shared version utilities +from brand_config import load_brand_config from sync_version import get_current_version, do_sync_version class WindowsBuilder: """Build Windows installer using PyInstaller.""" - def __init__(self, env_file: Path | None = None): + def __init__(self, env_file: Path | None = None, brand: str | None = None): """Initialize builder paths. Args: @@ -53,10 +54,12 @@ class WindowsBuilder: If that doesn't exist, raises error. """ self.project_root = Path(__file__).parent.parent.parent + self.brand = load_brand_config(brand, root=self.project_root) self.build_dir = self.project_root / "build" - self.dist_dir = self.build_dir / "dist" / "windows" - self.temp_dir = self.build_dir / "temp" / "windows" + self.dist_dir = self.build_dir / "dist" / "windows" / self.brand.brand_id + self.temp_dir = self.build_dir / "temp" / "windows" / self.brand.brand_id self.spec_file = self.build_dir / "webdrop_bridge.spec" + self.wix_template = self.build_dir / "WebDropBridge.wxs" self.version = get_current_version() # Validate and set env file @@ -74,6 +77,7 @@ class WindowsBuilder: self.env_file = env_file print(f"📋 Using configuration: {self.env_file}") + print(f"🏷️ Building brand: {self.brand.display_name} ({self.brand.brand_id})") def _get_version(self) -> str: """Get version from __init__.py. @@ -116,6 +120,15 @@ class WindowsBuilder: # Set environment variable for spec file to use env = os.environ.copy() env["WEBDROP_ENV_FILE"] = str(self.env_file) + env["WEBDROP_BRAND_ID"] = self.brand.brand_id + env["WEBDROP_APP_DISPLAY_NAME"] = self.brand.display_name + env["WEBDROP_ASSET_PREFIX"] = self.brand.asset_prefix + env["WEBDROP_EXE_NAME"] = self.brand.exe_name + env["WEBDROP_BUNDLE_ID"] = self.brand.bundle_identifier + env["WEBDROP_CONFIG_DIR_NAME"] = self.brand.config_dir_name + env["WEBDROP_ICON_ICO"] = str(self.brand.icon_ico) + env["WEBDROP_ICON_ICNS"] = str(self.brand.icon_icns) + env["WEBDROP_VERSION"] = self.version result = subprocess.run(cmd, cwd=str(self.project_root), text=True, env=env) @@ -123,8 +136,8 @@ class WindowsBuilder: print("❌ PyInstaller build failed") return False - # Check if executable exists (now in WebDropBridge/WebDropBridge.exe due to COLLECT) - exe_path = self.dist_dir / "WebDropBridge" / "WebDropBridge.exe" + # Check if executable exists (inside the COLLECT directory) + exe_path = self.dist_dir / self.brand.exe_name / f"{self.brand.exe_name}.exe" if not exe_path.exists(): print(f"❌ Executable not found at {exe_path}") return False @@ -134,7 +147,9 @@ class WindowsBuilder: # Calculate total dist size total_size = sum( - f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file() + f.stat().st_size + for f in self.dist_dir.glob(f"{self.brand.exe_name}/**/*") + if f.is_file() ) if total_size > 0: print(f" Total size: {total_size / 1024 / 1024:.1f} MB") @@ -249,7 +264,7 @@ class WindowsBuilder: # Harvest application files using Heat print(f" Harvesting application files...") - dist_folder = self.dist_dir / "WebDropBridge" + dist_folder = self.dist_dir / self.brand.exe_name if not dist_folder.exists(): print(f"❌ Distribution folder not found: {dist_folder}") return False @@ -291,7 +306,7 @@ class WindowsBuilder: # Compile both WiX files wix_obj = self.build_dir / "WebDropBridge.wixobj" wix_files_obj = self.build_dir / "WebDropBridge_Files.wixobj" - msi_output = self.dist_dir / f"WebDropBridge-{self.version}-Setup.msi" + msi_output = self.dist_dir / self.brand.windows_installer_name(self.version) # Run candle compiler - make sure to use correct source directory candle_cmd = [ @@ -301,11 +316,11 @@ class WindowsBuilder: "-ext", "WixUtilExtension", f"-dDistDir={self.dist_dir}", - f"-dSourceDir={self.dist_dir}\\WebDropBridge", # Set SourceDir for Heat-generated files + f"-dSourceDir={self.dist_dir}\{self.brand.exe_name}", # Set SourceDir for Heat-generated files f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets "-o", str(self.build_dir) + "\\", - str(self.build_dir / "WebDropBridge.wxs"), + str(self.build_dir / "WebDropBridge.generated.wxs"), ] if harvest_file.exists(): @@ -325,7 +340,7 @@ class WindowsBuilder: "-ext", "WixUtilExtension", "-b", - str(self.dist_dir / "WebDropBridge"), # Base path for source files + str(self.dist_dir / self.brand.exe_name), # Base path for source files "-o", str(msi_output), str(wix_obj), @@ -353,6 +368,7 @@ class WindowsBuilder: print("✅ MSI installer created successfully") print(f"📦 Output: {msi_output}") print(f" Size: {msi_output.stat().st_size / 1024 / 1024:.1f} MB") + self.generate_checksum(msi_output) return True @@ -363,7 +379,7 @@ class WindowsBuilder: even if a previous PyInstaller run omitted them. """ src_icons_dir = self.project_root / "resources" / "icons" - bundle_icons_dir = self.dist_dir / "WebDropBridge" / "_internal" / "resources" / "icons" + bundle_icons_dir = self.dist_dir / self.brand.exe_name / "_internal" / "resources" / "icons" required_icons = ["home.ico", "reload.ico", "open.ico", "openwith.ico"] try: @@ -392,97 +408,23 @@ class WindowsBuilder: Creates per-machine installation (Program Files). Installation requires admin rights, but the app does not. """ - wix_content = f""" - - + wix_template = self.wix_template.read_text(encoding="utf-8") + wix_content = wix_template.format( + product_name=self.brand.display_name, + version=self.version, + manufacturer=self.brand.manufacturer, + upgrade_code=self.brand.msi_upgrade_code, + asset_prefix=self.brand.asset_prefix, + icon_ico=str(self.brand.icon_ico), + dialog_bmp=str(self.brand.dialog_bmp), + banner_bmp=str(self.brand.banner_bmp), + license_rtf=str(self.brand.license_rtf), + exe_name=self.brand.exe_name, + install_dir_name=self.brand.install_dir_name, + shortcut_description=self.brand.shortcut_description, + ) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -""" - - wix_file = self.build_dir / "WebDropBridge.wxs" + wix_file = self.build_dir / "WebDropBridge.generated.wxs" wix_file.write_text(wix_content) print(f" Created WiX source: {wix_file}") return True @@ -573,7 +515,7 @@ class WindowsBuilder: print(" Skipping code signing") return True - exe_path = self.dist_dir / "WebDropBridge.exe" + exe_path = self.dist_dir / self.brand.exe_name / f"{self.brand.exe_name}.exe" cmd = [ signtool, "sign", @@ -606,7 +548,7 @@ class WindowsBuilder: """ start_time = datetime.now() print("=" * 60) - print("🚀 WebDrop Bridge Windows Build") + print(f"🚀 {self.brand.display_name} Windows Build") print("=" * 60) self.clean() @@ -650,6 +592,12 @@ def main() -> int: default=None, help="Path to .env file to bundle (default: project root .env)", ) + parser.add_argument( + "--brand", + type=str, + default=None, + help="Brand manifest name from build/brands (e.g. agravity)", + ) args = parser.parse_args() @@ -657,7 +605,7 @@ def main() -> int: do_sync_version() try: - builder = WindowsBuilder(env_file=args.env_file) + builder = WindowsBuilder(env_file=args.env_file, brand=args.brand) except FileNotFoundError as e: print(f"❌ Build failed: {e}") return 1 diff --git a/build/scripts/create_release.ps1 b/build/scripts/create_release.ps1 index e23159b..82702e0 100644 --- a/build/scripts/create_release.ps1 +++ b/build/scripts/create_release.ps1 @@ -1,70 +1,36 @@ -# Create Forgejo Release with Binary Assets -# Usage: .\create_release.ps1 [-Version 1.0.0] -# If -Version is not provided, it will be read from src/webdrop_bridge/__init__.py -# Uses your Forgejo credentials (same as git) -# First run will prompt for credentials and save them to this session - param( - [Parameter(Mandatory=$false)] + [Parameter(Mandatory = $false)] [string]$Version, - - [Parameter(Mandatory=$false)] + + [Parameter(Mandatory = $false)] + [string[]]$Brands = @("agravity"), + + [Parameter(Mandatory = $false)] [string]$ForgejoUser, - - [Parameter(Mandatory=$false)] + + [Parameter(Mandatory = $false)] [string]$ForgejoPW, - + [switch]$ClearCredentials, - - [switch]$SkipExe, - + [string]$ForgejoUrl = "https://git.him-tools.de", - [string]$Repo = "HIM-public/webdrop-bridge", - [string]$ExePath = "build\dist\windows\WebDropBridge\WebDropBridge.exe", - [string]$ChecksumPath = "build\dist\windows\WebDropBridge\WebDropBridge.exe.sha256" + [string]$Repo = "HIM-public/webdrop-bridge" ) $ErrorActionPreference = "Stop" - -# Get project root (PSScriptRoot is build/scripts, go up to project root with ..\..) $projectRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") - -# Resolve file paths relative to project root -$ExePath = Join-Path $projectRoot $ExePath -$ChecksumPath = Join-Path $projectRoot $ChecksumPath -$MsiPath = Join-Path $projectRoot $MsiPath - -# Function to read version from .env or .env.example -function Get-VersionFromEnv { - # Use already resolved project root - - # Try .env first (runtime config), then .env.example (template) - $envFile = Join-Path $projectRoot ".env" - $envExampleFile = Join-Path $projectRoot ".env.example" - - # Check .env first - if (Test-Path $envFile) { - $content = Get-Content $envFile -Raw - if ($content -match 'APP_VERSION=([^\r\n]+)') { - Write-Host "Version read from .env" -ForegroundColor Gray - return $matches[1].Trim() - } - } - - # Fall back to .env.example - if (Test-Path $envExampleFile) { - $content = Get-Content $envExampleFile -Raw - if ($content -match 'APP_VERSION=([^\r\n]+)') { - Write-Host "Version read from .env.example" -ForegroundColor Gray - return $matches[1].Trim() - } - } - - Write-Host "ERROR: Could not find APP_VERSION in .env or .env.example" -ForegroundColor Red - exit 1 +$pythonExe = Join-Path $projectRoot ".venv\Scripts\python.exe" +if (-not (Test-Path $pythonExe)) { + $pythonExe = "python" +} + +$brandHelper = Join-Path $projectRoot "build\scripts\brand_config.py" +$manifestOutput = Join-Path $projectRoot "build\dist\release-manifest.json" + +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() } -# Handle --ClearCredentials flag if ($ClearCredentials) { Remove-Item env:FORGEJO_USER -ErrorAction SilentlyContinue Remove-Item env:FORGEJO_PASS -ErrorAction SilentlyContinue @@ -72,190 +38,95 @@ if ($ClearCredentials) { exit 0 } -# Get credentials from sources (in order of priority) if (-not $ForgejoUser) { $ForgejoUser = $env:FORGEJO_USER } - if (-not $ForgejoPW) { $ForgejoPW = $env:FORGEJO_PASS } -# If still no credentials, prompt user interactively if (-not $ForgejoUser -or -not $ForgejoPW) { Write-Host "Forgejo credentials not found. Enter your credentials:" -ForegroundColor Yellow - if (-not $ForgejoUser) { $ForgejoUser = Read-Host "Username" } - if (-not $ForgejoPW) { $securePass = Read-Host "Password" -AsSecureString $ForgejoPW = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUnicode($securePass)) } - - # Save credentials to environment for this session $env:FORGEJO_USER = $ForgejoUser $env:FORGEJO_PASS = $ForgejoPW - Write-Host "[OK] Credentials saved to this PowerShell session" -ForegroundColor Green - Write-Host "Tip: Credentials will persist until you close PowerShell or run: .\create_release.ps1 -ClearCredentials" -ForegroundColor Gray } -# Verify Version parameter - if not provided, read from .env.example if (-not $Version) { - Write-Host "Version not provided, reading from .env.example..." -ForegroundColor Cyan - $Version = Get-VersionFromEnv - Write-Host "Using version: $Version" -ForegroundColor Green + $Version = Get-CurrentVersion } -# Define MSI path with resolved version -$MsiPath = Join-Path $projectRoot "build\dist\windows\WebDropBridge-$Version-Setup.msi" +$artifactPaths = New-Object System.Collections.Generic.List[string] +foreach ($brand in $Brands) { + $brandJson = & $pythonExe $brandHelper show --brand $brand | ConvertFrom-Json + $msiPath = Join-Path $projectRoot "build\dist\windows\$($brandJson.brand_id)\$($brandJson.asset_prefix)-$Version-win-x64.msi" + $checksumPath = "$msiPath.sha256" -# Verify files exist (exe/checksum optional, MSI required) -if (-not $SkipExe) { - if (-not (Test-Path $ExePath)) { - Write-Host "WARNING: Executable not found at $ExePath" -ForegroundColor Yellow - Write-Host " Use -SkipExe flag to skip exe upload" -ForegroundColor Gray - $SkipExe = $true - } - - if (-not $SkipExe -and -not (Test-Path $ChecksumPath)) { - Write-Host "WARNING: Checksum file not found at $ChecksumPath" -ForegroundColor Yellow - Write-Host " Exe will not be uploaded" -ForegroundColor Gray - $SkipExe = $true + if (Test-Path $msiPath) { + $artifactPaths.Add($msiPath) + if (Test-Path $checksumPath) { + $artifactPaths.Add($checksumPath) + } + $msiSize = (Get-Item $msiPath).Length / 1MB + Write-Host "Windows artifact: $([System.IO.Path]::GetFileName($msiPath)) ($([math]::Round($msiSize, 2)) MB)" } } -# MSI is the primary release artifact -if (-not (Test-Path $MsiPath)) { - Write-Host "ERROR: MSI installer not found at $MsiPath" -ForegroundColor Red - Write-Host "Please build with MSI support:" -ForegroundColor Yellow - Write-Host " python build\scripts\build_windows.py --msi" -ForegroundColor Cyan +& $pythonExe $brandHelper release-manifest --version $Version --output $manifestOutput --brands $Brands | Out-Null +if (Test-Path $manifestOutput) { + $artifactPaths.Add($manifestOutput) +} + +if ($artifactPaths.Count -eq 0) { + Write-Host "ERROR: No Windows artifacts found for the requested brands" -ForegroundColor Red exit 1 } -Write-Host "Creating WebDropBridge $Version release on Forgejo..." -ForegroundColor Cyan - -# Get file info -$msiSize = (Get-Item $MsiPath).Length / 1MB -Write-Host "Primary Artifact: WebDropBridge-$Version-Setup.msi ($([math]::Round($msiSize, 2)) MB)" - -if (-not $SkipExe) { - $exeSize = (Get-Item $ExePath).Length / 1MB - $checksum = Get-Content $ChecksumPath -Raw - Write-Host "Optional Artifact: WebDropBridge.exe ($([math]::Round($exeSize, 2)) MB)" - Write-Host " Checksum: $($checksum.Substring(0, 16))..." -} - -# Create basic auth header $auth = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("${ForgejoUser}:${ForgejoPW}")) - $headers = @{ "Authorization" = "Basic $auth" "Content-Type" = "application/json" } -# Step 1: Create release -Write-Host "`nCreating release v$Version..." -ForegroundColor Yellow +$releaseLookupUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/tags/v$Version" $releaseUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases" - -# Build release body with checksum info if exe is being uploaded -$releaseBody = "WebDropBridge v$Version`n`n**Release Artifacts:**`n- MSI Installer (Windows Setup)`n" -if (-not $SkipExe) { - $checksum = Get-Content $ChecksumPath -Raw - $releaseBody += "- Portable Executable`n`n**Checksum:**`n$checksum`n" -} - $releaseData = @{ tag_name = "v$Version" name = "WebDropBridge v$Version" - body = $releaseBody + body = "Shared branded release for WebDrop Bridge v$Version" draft = $false prerelease = $false } | ConvertTo-Json try { - $response = Invoke-WebRequest -Uri $releaseUrl ` - -Method POST ` - -Headers $headers ` - -Body $releaseData ` - -TimeoutSec 30 ` - -UseBasicParsing ` - -ErrorAction Stop - + $lookupResponse = Invoke-WebRequest -Uri $releaseLookupUrl -Method GET -Headers $headers -TimeoutSec 30 -UseBasicParsing -ErrorAction Stop + $releaseInfo = $lookupResponse.Content | ConvertFrom-Json + $releaseId = $releaseInfo.id + Write-Host "[OK] Using existing release (ID: $releaseId)" -ForegroundColor Green +} +catch { + $response = Invoke-WebRequest -Uri $releaseUrl -Method POST -Headers $headers -Body $releaseData -TimeoutSec 30 -UseBasicParsing -ErrorAction Stop $releaseInfo = $response.Content | ConvertFrom-Json $releaseId = $releaseInfo.id Write-Host "[OK] Release created (ID: $releaseId)" -ForegroundColor Green } -catch { - Write-Host "ERROR creating release: $_" -ForegroundColor Red - exit 1 -} -# Setup curl authentication $curlAuth = "$ForgejoUser`:$ForgejoPW" $uploadUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets" -# Step 2: Upload MSI installer as primary artifact -Write-Host "`nUploading MSI installer (primary artifact)..." -ForegroundColor Yellow - -try { - $response = curl.exe -s -X POST ` - -u $curlAuth ` - -F "attachment=@$MsiPath" ` - $uploadUrl - +foreach ($artifact in $artifactPaths) { + $response = curl.exe -s -X POST -u $curlAuth -F "attachment=@$artifact" $uploadUrl if ($response -like "*error*" -or $response -like "*404*") { - Write-Host "ERROR uploading MSI: $response" -ForegroundColor Red - exit 1 + Write-Host "WARNING: Could not upload $artifact : $response" -ForegroundColor Yellow } - - Write-Host "[OK] MSI installer uploaded" -ForegroundColor Green -} -catch { - Write-Host "ERROR uploading MSI: $_" -ForegroundColor Red - exit 1 -} - -# Step 3: Upload executable as optional artifact (if available) -if (-not $SkipExe) { - Write-Host "`nUploading executable (optional portable version)..." -ForegroundColor Yellow - - try { - $response = curl.exe -s -X POST ` - -u $curlAuth ` - -F "attachment=@$ExePath" ` - $uploadUrl - - if ($response -like "*error*" -or $response -like "*404*") { - Write-Host "WARNING: Could not upload executable: $response" -ForegroundColor Yellow - } - else { - Write-Host "[OK] Executable uploaded" -ForegroundColor Green - } - } - catch { - Write-Host "WARNING: Could not upload executable: $_" -ForegroundColor Yellow - } - - # Step 4: Upload checksum as asset - Write-Host "Uploading checksum..." -ForegroundColor Yellow - - try { - $response = curl.exe -s -X POST ` - -u $curlAuth ` - -F "attachment=@$ChecksumPath" ` - $uploadUrl - - if ($response -like "*error*" -or $response -like "*404*") { - Write-Host "WARNING: Could not upload checksum: $response" -ForegroundColor Yellow - } - else { - Write-Host "[OK] Checksum uploaded" -ForegroundColor Green - } - } - catch { - Write-Host "WARNING: Could not upload checksum: $_" -ForegroundColor Yellow + else { + Write-Host "[OK] Uploaded $([System.IO.Path]::GetFileName($artifact))" -ForegroundColor Green } } diff --git a/build/scripts/create_release.sh b/build/scripts/create_release.sh index 1c1838f..ea71bd1 100644 --- a/build/scripts/create_release.sh +++ b/build/scripts/create_release.sh @@ -1,31 +1,33 @@ #!/bin/bash -# Create Forgejo Release with Binary Assets -# Usage: ./create_release.sh -v 1.0.0 -# Uses your Forgejo credentials (same as git) -# First run will prompt for credentials and save them to this session +# Create or update a shared Forgejo release with branded macOS assets. set -e -# Parse arguments VERSION="" -FORGEJO_USER="" -FORGEJO_PASS="" +BRANDS=("agravity") +FORGEJO_USER="${FORGEJO_USER}" +FORGEJO_PASS="${FORGEJO_PASS}" FORGEJO_URL="https://git.him-tools.de" REPO="HIM-public/webdrop-bridge" -DMG_PATH="build/dist/macos/WebDropBridge.dmg" -CHECKSUM_PATH="build/dist/macos/WebDropBridge.dmg.sha256" CLEAR_CREDS=false +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BRAND_HELPER="$PROJECT_ROOT/build/scripts/brand_config.py" +MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.json" while [[ $# -gt 0 ]]; do case $1 in - -v|--version) VERSION="$2"; shift 2;; - -u|--url) FORGEJO_URL="$2"; shift 2;; - --clear-credentials) CLEAR_CREDS=true; shift;; - *) echo "Unknown option: $1"; exit 1;; + -v|--version) VERSION="$2"; shift 2 ;; + -u|--url) FORGEJO_URL="$2"; shift 2 ;; + --brand) BRANDS+=("$2"); shift 2 ;; + --clear-credentials) CLEAR_CREDS=true; shift ;; + *) echo "Unknown option: $1"; exit 1 ;; esac done -# Handle --clear-credentials flag +if [ ${#BRANDS[@]} -gt 1 ] && [ "${BRANDS[0]}" = "agravity" ]; then + BRANDS=("${BRANDS[@]:1}") +fi + if [ "$CLEAR_CREDS" = true ]; then unset FORGEJO_USER unset FORGEJO_PASS @@ -33,127 +35,95 @@ if [ "$CLEAR_CREDS" = true ]; then exit 0 fi -# Load credentials from environment -FORGEJO_USER="${FORGEJO_USER}" -FORGEJO_PASS="${FORGEJO_PASS}" - -# Verify required parameters if [ -z "$VERSION" ]; then - echo "ERROR: Version parameter required" >&2 - echo "Usage: $0 -v VERSION [-u FORGEJO_URL]" >&2 - echo "Example: $0 -v 1.0.0" >&2 - exit 1 + 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 -# If no credentials, prompt user interactively if [ -z "$FORGEJO_USER" ] || [ -z "$FORGEJO_PASS" ]; then echo "Forgejo credentials not found. Enter your credentials:" - if [ -z "$FORGEJO_USER" ]; then - read -p "Username: " FORGEJO_USER + read -r -p "Username: " FORGEJO_USER fi - if [ -z "$FORGEJO_PASS" ]; then - read -sp "Password: " FORGEJO_PASS + read -r -s -p "Password: " FORGEJO_PASS echo "" fi - - # Export for this session export FORGEJO_USER export FORGEJO_PASS - echo "[OK] Credentials saved to this shell session" - echo "Tip: Credentials will persist until you close the terminal or run: $0 --clear-credentials" fi -# Verify files exist -if [ ! -f "$DMG_PATH" ]; then - echo "ERROR: DMG file not found at $DMG_PATH" +ARTIFACTS=() +for BRAND in "${BRANDS[@]}"; do + BRAND_JSON=$(python3 "$BRAND_HELPER" show --brand "$BRAND") + BRAND_ID=$(printf '%s' "$BRAND_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["brand_id"])') + ASSET_PREFIX=$(printf '%s' "$BRAND_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["asset_prefix"])') + DMG_PATH="$PROJECT_ROOT/build/dist/macos/$BRAND_ID/${ASSET_PREFIX}-${VERSION}-macos-universal.dmg" + CHECKSUM_PATH="$DMG_PATH.sha256" + + if [ -f "$DMG_PATH" ]; then + ARTIFACTS+=("$DMG_PATH") + [ -f "$CHECKSUM_PATH" ] && ARTIFACTS+=("$CHECKSUM_PATH") + DMG_SIZE=$(du -m "$DMG_PATH" | cut -f1) + echo "macOS artifact: $(basename "$DMG_PATH") ($DMG_SIZE MB)" + fi +done + +python3 "$BRAND_HELPER" release-manifest --version "$VERSION" --output "$MANIFEST_OUTPUT" --brands "${BRANDS[@]}" >/dev/null +[ -f "$MANIFEST_OUTPUT" ] && ARTIFACTS+=("$MANIFEST_OUTPUT") + +if [ ${#ARTIFACTS[@]} -eq 0 ]; then + echo "ERROR: No macOS artifacts found" exit 1 fi -if [ ! -f "$CHECKSUM_PATH" ]; then - echo "ERROR: Checksum file not found at $CHECKSUM_PATH" - exit 1 -fi - -echo "Creating WebDropBridge $VERSION release on Forgejo..." - -# Get file info -DMG_SIZE=$(du -m "$DMG_PATH" | cut -f1) -CHECKSUM=$(cat "$CHECKSUM_PATH") - -echo "File: WebDropBridge.dmg ($DMG_SIZE MB)" -echo "Checksum: ${CHECKSUM:0:16}..." - -# Create basic auth BASIC_AUTH=$(echo -n "${FORGEJO_USER}:${FORGEJO_PASS}" | base64) - -# Step 1: Create release -echo "" -echo "Creating release v$VERSION..." RELEASE_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases" +RELEASE_LOOKUP_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/tags/v$VERSION" -RELEASE_DATA=$(cat <