diff --git a/build/WebDropBridge.wxs b/build/WebDropBridge.wxs index 38af7a1..6ce5b67 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 432f8fe..661df12 100644 --- a/build/scripts/build_macos.sh +++ b/build/scripts/build_macos.sh @@ -28,13 +28,10 @@ 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" -BUNDLE_IDENTIFIER="de.him_tools.webdrop-bridge" -VERSION="" +VERSION="1.0.0" # Default .env file ENV_FILE="$PROJECT_ROOT/.env" @@ -57,10 +54,6 @@ while [[ $# -gt 0 ]]; do ENV_FILE="$2" shift 2 ;; - --brand) - BRAND="$2" - shift 2 - ;; *) echo "Unknown option: $1" exit 1 @@ -77,18 +70,6 @@ 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' @@ -197,8 +178,6 @@ 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" \ @@ -273,8 +252,6 @@ 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 a507531..42d68fa 100644 --- a/build/scripts/build_windows.py +++ b/build/scripts/build_windows.py @@ -39,14 +39,13 @@ 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, brand: str | None = None): + def __init__(self, env_file: Path | None = None): """Initialize builder paths. Args: @@ -54,12 +53,10 @@ 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.brand.brand_id - self.temp_dir = self.build_dir / "temp" / "windows" / self.brand.brand_id + self.dist_dir = self.build_dir / "dist" / "windows" + self.temp_dir = self.build_dir / "temp" / "windows" 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 @@ -77,7 +74,6 @@ 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. @@ -120,15 +116,6 @@ 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) @@ -136,8 +123,8 @@ class WindowsBuilder: print("❌ PyInstaller build failed") return False - # Check if executable exists (inside the COLLECT directory) - exe_path = self.dist_dir / self.brand.exe_name / f"{self.brand.exe_name}.exe" + # Check if executable exists (now in WebDropBridge/WebDropBridge.exe due to COLLECT) + exe_path = self.dist_dir / "WebDropBridge" / "WebDropBridge.exe" if not exe_path.exists(): print(f"❌ Executable not found at {exe_path}") return False @@ -147,9 +134,7 @@ class WindowsBuilder: # Calculate total dist size total_size = sum( - f.stat().st_size - for f in self.dist_dir.glob(f"{self.brand.exe_name}/**/*") - if f.is_file() + f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file() ) if total_size > 0: print(f" Total size: {total_size / 1024 / 1024:.1f} MB") @@ -264,7 +249,7 @@ class WindowsBuilder: # Harvest application files using Heat print(f" Harvesting application files...") - dist_folder = self.dist_dir / self.brand.exe_name + dist_folder = self.dist_dir / "WebDropBridge" if not dist_folder.exists(): print(f"❌ Distribution folder not found: {dist_folder}") return False @@ -306,7 +291,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 / self.brand.windows_installer_name(self.version) + msi_output = self.dist_dir / f"WebDropBridge-{self.version}-Setup.msi" # Run candle compiler - make sure to use correct source directory candle_cmd = [ @@ -316,11 +301,11 @@ class WindowsBuilder: "-ext", "WixUtilExtension", f"-dDistDir={self.dist_dir}", - f"-dSourceDir={self.dist_dir}\{self.brand.exe_name}", # Set SourceDir for Heat-generated files + f"-dSourceDir={self.dist_dir}\\WebDropBridge", # 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.generated.wxs"), + str(self.build_dir / "WebDropBridge.wxs"), ] if harvest_file.exists(): @@ -340,7 +325,7 @@ class WindowsBuilder: "-ext", "WixUtilExtension", "-b", - str(self.dist_dir / self.brand.exe_name), # Base path for source files + str(self.dist_dir / "WebDropBridge"), # Base path for source files "-o", str(msi_output), str(wix_obj), @@ -368,7 +353,6 @@ 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 @@ -379,7 +363,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 / self.brand.exe_name / "_internal" / "resources" / "icons" + bundle_icons_dir = self.dist_dir / "WebDropBridge" / "_internal" / "resources" / "icons" required_icons = ["home.ico", "reload.ico", "open.ico", "openwith.ico"] try: @@ -408,23 +392,97 @@ class WindowsBuilder: Creates per-machine installation (Program Files). Installation requires admin rights, but the app does not. """ - 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_content = f""" + + - wix_file = self.build_dir / "WebDropBridge.generated.wxs" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + + wix_file = self.build_dir / "WebDropBridge.wxs" wix_file.write_text(wix_content) print(f" Created WiX source: {wix_file}") return True @@ -515,7 +573,7 @@ class WindowsBuilder: print(" Skipping code signing") return True - exe_path = self.dist_dir / self.brand.exe_name / f"{self.brand.exe_name}.exe" + exe_path = self.dist_dir / "WebDropBridge.exe" cmd = [ signtool, "sign", @@ -548,7 +606,7 @@ class WindowsBuilder: """ start_time = datetime.now() print("=" * 60) - print(f"🚀 {self.brand.display_name} Windows Build") + print("🚀 WebDrop Bridge Windows Build") print("=" * 60) self.clean() @@ -592,12 +650,6 @@ 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() @@ -605,7 +657,7 @@ def main() -> int: do_sync_version() try: - builder = WindowsBuilder(env_file=args.env_file, brand=args.brand) + builder = WindowsBuilder(env_file=args.env_file) 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 82702e0..e23159b 100644 --- a/build/scripts/create_release.ps1 +++ b/build/scripts/create_release.ps1 @@ -1,36 +1,70 @@ +# 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)] - [string[]]$Brands = @("agravity"), - - [Parameter(Mandatory = $false)] + + [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]$Repo = "HIM-public/webdrop-bridge", + [string]$ExePath = "build\dist\windows\WebDropBridge\WebDropBridge.exe", + [string]$ChecksumPath = "build\dist\windows\WebDropBridge\WebDropBridge.exe.sha256" ) $ErrorActionPreference = "Stop" + +# Get project root (PSScriptRoot is build/scripts, go up to project root with ..\..) $projectRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") -$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() + +# 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 } +# Handle --ClearCredentials flag if ($ClearCredentials) { Remove-Item env:FORGEJO_USER -ErrorAction SilentlyContinue Remove-Item env:FORGEJO_PASS -ErrorAction SilentlyContinue @@ -38,95 +72,190 @@ 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) { - $Version = Get-CurrentVersion + Write-Host "Version not provided, reading from .env.example..." -ForegroundColor Cyan + $Version = Get-VersionFromEnv + Write-Host "Using version: $Version" -ForegroundColor Green } -$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" +# Define MSI path with resolved version +$MsiPath = Join-Path $projectRoot "build\dist\windows\WebDropBridge-$Version-Setup.msi" - 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)" +# 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 } } -& $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 +# 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 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" } -$releaseLookupUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/tags/v$Version" +# Step 1: Create release +Write-Host "`nCreating release v$Version..." -ForegroundColor Yellow $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 = "Shared branded release for WebDrop Bridge v$Version" + body = $releaseBody draft = $false prerelease = $false } | ConvertTo-Json try { - $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 + $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" -foreach ($artifact in $artifactPaths) { - $response = curl.exe -s -X POST -u $curlAuth -F "attachment=@$artifact" $uploadUrl +# 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 + if ($response -like "*error*" -or $response -like "*404*") { - Write-Host "WARNING: Could not upload $artifact : $response" -ForegroundColor Yellow + Write-Host "ERROR uploading MSI: $response" -ForegroundColor Red + exit 1 } - else { - Write-Host "[OK] Uploaded $([System.IO.Path]::GetFileName($artifact))" -ForegroundColor Green + + 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 } } diff --git a/build/scripts/create_release.sh b/build/scripts/create_release.sh index ea71bd1..1c1838f 100644 --- a/build/scripts/create_release.sh +++ b/build/scripts/create_release.sh @@ -1,33 +1,31 @@ #!/bin/bash -# Create or update a shared Forgejo release with branded macOS assets. +# 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 set -e +# Parse arguments VERSION="" -BRANDS=("agravity") -FORGEJO_USER="${FORGEJO_USER}" -FORGEJO_PASS="${FORGEJO_PASS}" +FORGEJO_USER="" +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 ;; - --brand) BRANDS+=("$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;; + --clear-credentials) CLEAR_CREDS=true; shift;; + *) echo "Unknown option: $1"; exit 1;; esac done -if [ ${#BRANDS[@]} -gt 1 ] && [ "${BRANDS[0]}" = "agravity" ]; then - BRANDS=("${BRANDS[@]:1}") -fi - +# Handle --clear-credentials flag if [ "$CLEAR_CREDS" = true ]; then unset FORGEJO_USER unset FORGEJO_PASS @@ -35,95 +33,127 @@ 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 - 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 [ -z "$FORGEJO_USER" ] || [ -z "$FORGEJO_PASS" ]; then - echo "Forgejo credentials not found. Enter your credentials:" - if [ -z "$FORGEJO_USER" ]; then - read -r -p "Username: " FORGEJO_USER - fi - if [ -z "$FORGEJO_PASS" ]; then - read -r -s -p "Password: " FORGEJO_PASS - echo "" - fi - export FORGEJO_USER - export FORGEJO_PASS -fi - -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" + 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 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 + fi + + if [ -z "$FORGEJO_PASS" ]; then + read -sp "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" + 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" -RESPONSE=$(curl -s -H "Authorization: Basic $BASIC_AUTH" "$RELEASE_LOOKUP_URL") -RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2) - -if [ -z "$RELEASE_ID" ]; then - RELEASE_DATA=$(cat < "Config": @@ -144,9 +124,6 @@ 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): @@ -155,10 +132,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(config_dir_name) / log_file + log_file = Config.get_default_log_dir() / log_file else: # Use default log path in app data - log_file = Config.get_default_log_path(config_dir_name) + log_file = Config.get_default_log_path() app_name = data.get("app_name", "WebDrop Bridge") stored_window_title = data.get("window_title", "") @@ -197,12 +174,6 @@ 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 @@ -230,8 +201,6 @@ 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) @@ -246,10 +215,6 @@ 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"} @@ -289,10 +254,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(config_dir_name) / log_file + log_file = Config.get_default_log_dir() / log_file else: # Use default log path in app data - log_file = Config.get_default_log_path(config_dir_name) + log_file = Config.get_default_log_path() # Validate webapp URL is not empty if not webapp_url: @@ -343,12 +308,6 @@ 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: @@ -378,12 +337,6 @@ 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) @@ -391,49 +344,7 @@ class Config: json.dump(data, f, indent=2) @staticmethod - 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: + def get_default_config_path() -> Path: """Get the default configuration file path. Returns: @@ -445,10 +356,10 @@ class Config: base = Path.home() / "AppData" / "Roaming" else: base = Path.home() / ".config" - return base / (config_dir_name or Config.get_default_config_dir_name()) / "config.json" + return base / "webdrop_bridge" / "config.json" @staticmethod - def get_default_log_dir(config_dir_name: str | None = None) -> Path: + def get_default_log_dir() -> Path: """Get the default directory for log files. Always uses user's AppData directory to ensure permissions work @@ -463,31 +374,21 @@ class Config: base = Path.home() / "AppData" / "Roaming" else: base = Path.home() / ".local" / "share" - return base / (config_dir_name or Config.get_default_config_dir_name()) / "logs" + return base / "webdrop_bridge" / "logs" @staticmethod - def get_default_log_path(config_dir_name: str | None = None) -> Path: + def get_default_log_path() -> Path: """Get the default log file path. Returns: Path to default log file in user's AppData/Roaming/webdrop_bridge/logs """ - 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" + return Config.get_default_log_dir() / "webdrop_bridge.log" 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 4c4be27..52798ee 100644 --- a/src/webdrop_bridge/core/config_manager.py +++ b/src/webdrop_bridge/core/config_manager.py @@ -101,13 +101,14 @@ class ConfigValidator: class ConfigProfile: """Manages named configuration profiles. - Profiles are stored in the brand-specific app config directory. + Profiles are stored in ~/.webdrop_bridge/profiles/ directory as JSON files. """ - def __init__(self, config_dir_name: str = "webdrop_bridge") -> None: + PROFILES_DIR = Path.home() / ".webdrop_bridge" / "profiles" + + def __init__(self) -> None: """Initialize profile manager.""" - self.profiles_dir = Config.get_default_config_path(config_dir_name).parent / "profiles" - self.profiles_dir.mkdir(parents=True, exist_ok=True) + 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. @@ -125,7 +126,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, @@ -159,7 +160,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}") @@ -178,10 +179,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. @@ -192,7 +193,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 92fe794..2f2b3b6 100644 --- a/src/webdrop_bridge/core/updater.py +++ b/src/webdrop_bridge/core/updater.py @@ -5,11 +5,9 @@ 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 @@ -36,16 +34,7 @@ class Release: class UpdateManager: """Manages auto-updates via Forgejo releases API.""" - 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", - ): + def __init__(self, current_version: str, config_dir: Optional[Path] = None): """Initialize update manager. Args: @@ -53,11 +42,8 @@ class UpdateManager: config_dir: Directory for storing update cache. Defaults to temp. """ self.current_version = current_version - 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.forgejo_url = "https://git.him-tools.de" + self.repo = "HIM-public/webdrop-bridge" self.api_endpoint = f"{self.forgejo_url}/api/v1/repos/{self.repo}/releases/latest" # Cache management @@ -66,128 +52,6 @@ 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. @@ -389,7 +253,12 @@ class UpdateManager: logger.error("No assets found in release") return None - installer_asset, _ = await self._resolve_release_assets(release) + # Find .msi or .dmg file + installer_asset = None + for asset in release.assets: + if asset["name"].endswith((".msi", ".dmg")): + installer_asset = asset + break if not installer_asset: logger.error("No installer found in release assets") @@ -476,11 +345,14 @@ class UpdateManager: Returns: True if checksum matches, False otherwise """ - 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") + # 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 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 1194d69..4e90a7b 100644 --- a/src/webdrop_bridge/main.py +++ b/src/webdrop_bridge/main.py @@ -30,8 +30,6 @@ 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 c4f9967..6462ca6 100644 --- a/src/webdrop_bridge/ui/main_window.py +++ b/src/webdrop_bridge/ui/main_window.py @@ -1872,16 +1872,8 @@ class MainWindow(QMainWindow): try: # Create update manager - 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, - ) + cache_dir = Path.home() / ".webdrop_bridge" + manager = UpdateManager(current_version=self.config.app_version, config_dir=cache_dir) # Run async check in background self._run_async_check(manager) @@ -2098,13 +2090,7 @@ class MainWindow(QMainWindow): # Create update manager manager = UpdateManager( - 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, + current_version=self.config.app_version, config_dir=Path.home() / ".webdrop_bridge" ) # Create and start background thread @@ -2243,13 +2229,7 @@ class MainWindow(QMainWindow): from webdrop_bridge.core.updater import UpdateManager manager = UpdateManager( - 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, + current_version=self.config.app_version, config_dir=Path.home() / ".webdrop_bridge" ) 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 99f5241..935aee1 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(config.config_dir_name) + self.profile_manager = ConfigProfile() 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 = self.config.get_config_path() + config_path = Config.get_default_config_path() self.config.to_file(config_path) logger.info(f"Configuration saved to {config_path}") diff --git a/tests/unit/test_brand_config.py b/tests/unit/test_brand_config.py deleted file mode 100644 index 2f3d48b..0000000 --- a/tests/unit/test_brand_config.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Tests for brand-aware build configuration helpers.""" - -import json -import sys -from pathlib import Path - -BUILD_SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "build" / "scripts" -if str(BUILD_SCRIPTS_DIR) not in sys.path: - sys.path.insert(0, str(BUILD_SCRIPTS_DIR)) - -from brand_config import generate_release_manifest, load_brand_config - - -def test_load_agravity_brand_config(): - """Test loading the Agravity brand manifest.""" - brand = load_brand_config("agravity") - - assert brand.brand_id == "agravity" - assert brand.display_name == "Agravity Bridge" - assert brand.asset_prefix == "AgravityBridge" - assert brand.exe_name == "AgravityBridge" - assert brand.windows_installer_name("0.8.4") == "AgravityBridge-0.8.4-win-x64.msi" - - -def test_generate_release_manifest_for_agravity(tmp_path): - """Test generating a shared release manifest from local artifacts.""" - project_root = tmp_path - (project_root / "build" / "brands").mkdir(parents=True) - (project_root / "build" / "dist" / "windows" / "agravity").mkdir(parents=True) - (project_root / "build" / "dist" / "macos" / "agravity").mkdir(parents=True) - - source_manifest = Path(__file__).resolve().parents[2] / "build" / "brands" / "agravity.json" - (project_root / "build" / "brands" / "agravity.json").write_text( - source_manifest.read_text(encoding="utf-8"), - encoding="utf-8", - ) - - win_installer = ( - project_root - / "build" - / "dist" - / "windows" - / "agravity" - / "AgravityBridge-0.8.4-win-x64.msi" - ) - win_installer.write_bytes(b"msi") - (win_installer.parent / f"{win_installer.name}.sha256").write_text("abc", encoding="utf-8") - - mac_installer = ( - project_root - / "build" - / "dist" - / "macos" - / "agravity" - / "AgravityBridge-0.8.4-macos-universal.dmg" - ) - mac_installer.write_bytes(b"dmg") - (mac_installer.parent / f"{mac_installer.name}.sha256").write_text("def", encoding="utf-8") - - output_path = project_root / "build" / "dist" / "release-manifest.json" - generate_release_manifest( - "0.8.4", - ["agravity"], - output_path=output_path, - root=project_root, - ) - - manifest = json.loads(output_path.read_text(encoding="utf-8")) - assert manifest["version"] == "0.8.4" - assert ( - manifest["brands"]["agravity"]["windows-x64"]["installer"] - == "AgravityBridge-0.8.4-win-x64.msi" - ) - assert ( - manifest["brands"]["agravity"]["macos-universal"]["installer"] - == "AgravityBridge-0.8.4-macos-universal.dmg" - ) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 2c2e9ce..fdeda3d 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -12,26 +12,14 @@ 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_", - "BRAND_", - "UPDATE_", - "LANGUAGE", - ) - ): + if key.startswith(('APP_', 'LOG_', 'ALLOWED_', 'WEBAPP_', 'WINDOW_', 'ENABLE_')): del os.environ[key] - + yield - + # Restore env (cleanup) os.environ.clear() os.environ.update(saved_env) @@ -76,28 +64,6 @@ 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 @@ -107,11 +73,8 @@ 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 @@ -224,11 +187,3 @@ 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 f3f09a4..1685f20 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -16,17 +16,6 @@ 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.""" @@ -263,109 +252,6 @@ 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."""