feat: implement brand-specific configuration and update management for Agravity Bridge
This commit is contained in:
parent
baf56e040f
commit
b988532aaa
9 changed files with 461 additions and 48 deletions
|
|
@ -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})"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue