feat: implement brand-specific configuration and update management for Agravity Bridge

This commit is contained in:
claudi 2026-03-10 16:02:24 +01:00
parent baf56e040f
commit b988532aaa
9 changed files with 461 additions and 48 deletions

View file

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