Compare commits

..

No commits in common. "fd69996c53712141262e50feb36e74a2b49536ed" and "baf56e040f0b798bd6b494d9f092369f047f62cc" have entirely different histories.

17 changed files with 471 additions and 1027 deletions

View file

@ -2,23 +2,23 @@
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" <Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui" xmlns:ui="http://schemas.microsoft.com/wix/2010/ui"
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension"> xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
<Product Id="*" Name="{product_name}" Language="1033" Version="{version}" <Product Id="*" Name="WebDrop Bridge" Language="1033" Version="0.8.3"
Manufacturer="{manufacturer}" Manufacturer="HIM-Tools"
UpgradeCode="{upgrade_code}"> UpgradeCode="12345678-1234-1234-1234-123456789012">
<Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" Platform="x64" /> <Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" Platform="x64" />
<Media Id="1" Cabinet="{asset_prefix}.cab" EmbedCab="yes" /> <Media Id="1" Cabinet="WebDropBridge.cab" EmbedCab="yes" />
<!-- Required property for WixUI_InstallDir dialog set --> <!-- Required property for WixUI_InstallDir dialog set -->
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" /> <Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
<!-- Application Icon --> <!-- Application Icon -->
<Icon Id="AppIcon.ico" SourceFile="{icon_ico}" /> <Icon Id="AppIcon.ico" SourceFile="$(var.ResourcesDir)\icons\app.ico" />
<!-- Custom branding for InstallDir dialog set --> <!-- Custom branding for InstallDir dialog set -->
<WixVariable Id="WixUIDialogBmp" Value="{dialog_bmp}" /> <WixVariable Id="WixUIDialogBmp" Value="$(var.ResourcesDir)\icons\background.bmp" />
<WixVariable Id="WixUIBannerBmp" Value="{banner_bmp}" /> <WixVariable Id="WixUIBannerBmp" Value="$(var.ResourcesDir)\icons\banner.bmp" />
<WixVariable Id="WixUILicenseRtf" Value="{license_rtf}" /> <WixVariable Id="WixUILicenseRtf" Value="$(var.ResourcesDir)\license.rtf" />
<!-- Installation UI dialogs --> <!-- Installation UI dialogs -->
<UIRef Id="WixUI_InstallDir" /> <UIRef Id="WixUI_InstallDir" />
@ -26,12 +26,12 @@
<!-- Close running application before installation --> <!-- Close running application before installation -->
<util:CloseApplication <util:CloseApplication
Target="{exe_name}.exe" Target="WebDropBridge.exe"
CloseMessage="yes" CloseMessage="yes"
RebootPrompt="no" RebootPrompt="no"
ElevatedCloseMessage="no" /> ElevatedCloseMessage="no" />
<Feature Id="ProductFeature" Title="{product_name}" Level="1"> <Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
<ComponentGroupRef Id="AppFiles" /> <ComponentGroupRef Id="AppFiles" />
<ComponentRef Id="ProgramMenuShortcut" /> <ComponentRef Id="ProgramMenuShortcut" />
<ComponentRef Id="DesktopShortcut" /> <ComponentRef Id="DesktopShortcut" />
@ -39,10 +39,10 @@
<Directory Id="TARGETDIR" Name="SourceDir"> <Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFiles64Folder"> <Directory Id="ProgramFiles64Folder">
<Directory Id="INSTALLFOLDER" Name="{install_dir_name}" /> <Directory Id="INSTALLFOLDER" Name="WebDrop Bridge" />
</Directory> </Directory>
<Directory Id="ProgramMenuFolder"> <Directory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="{product_name}"/> <Directory Id="ApplicationProgramsFolder" Name="WebDrop Bridge"/>
</Directory> </Directory>
<Directory Id="DesktopFolder" /> <Directory Id="DesktopFolder" />
</Directory> </Directory>
@ -50,16 +50,16 @@
<DirectoryRef Id="ApplicationProgramsFolder"> <DirectoryRef Id="ApplicationProgramsFolder">
<Component Id="ProgramMenuShortcut" Guid="*"> <Component Id="ProgramMenuShortcut" Guid="*">
<Shortcut Id="ApplicationStartMenuShortcut" <Shortcut Id="ApplicationStartMenuShortcut"
Name="{product_name}" Name="WebDrop Bridge"
Description="{shortcut_description}" Description="Web Drag-and-Drop Bridge"
Target="[INSTALLFOLDER]{exe_name}.exe" Target="[INSTALLFOLDER]WebDropBridge.exe"
Icon="AppIcon.ico" Icon="AppIcon.ico"
IconIndex="0" IconIndex="0"
WorkingDirectory="INSTALLFOLDER" /> WorkingDirectory="INSTALLFOLDER" />
<RemoveFolder Id="ApplicationProgramsFolderRemove" <RemoveFolder Id="ApplicationProgramsFolderRemove"
On="uninstall" /> On="uninstall" />
<RegistryValue Root="HKCU" <RegistryValue Root="HKCU"
Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\{exe_name}" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\WebDropBridge"
Name="installed" Name="installed"
Type="integer" Type="integer"
Value="1" Value="1"
@ -70,14 +70,14 @@
<DirectoryRef Id="DesktopFolder"> <DirectoryRef Id="DesktopFolder">
<Component Id="DesktopShortcut" Guid="*"> <Component Id="DesktopShortcut" Guid="*">
<Shortcut Id="DesktopApplicationShortcut" <Shortcut Id="DesktopApplicationShortcut"
Name="{product_name}" Name="WebDrop Bridge"
Description="{shortcut_description}" Description="Web Drag-and-Drop Bridge"
Target="[INSTALLFOLDER]{exe_name}.exe" Target="[INSTALLFOLDER]WebDropBridge.exe"
Icon="AppIcon.ico" Icon="AppIcon.ico"
IconIndex="0" IconIndex="0"
WorkingDirectory="INSTALLFOLDER" /> WorkingDirectory="INSTALLFOLDER" />
<RegistryValue Root="HKCU" <RegistryValue Root="HKCU"
Key="Software\{exe_name}" Key="Software\WebDropBridge"
Name="DesktopShortcut" Name="DesktopShortcut"
Type="integer" Type="integer"
Value="1" Value="1"

View file

@ -1,18 +0,0 @@
{
"brand_id": "agravity",
"display_name": "Agravity Bridge",
"asset_prefix": "AgravityBridge",
"exe_name": "AgravityBridge",
"manufacturer": "agravity",
"install_dir_name": "Agravity Bridge",
"shortcut_description": "Agravity drag-and-drop bridge",
"bundle_identifier": "io.agravity.bridge",
"config_dir_name": "agravity_bridge",
"msi_upgrade_code": "4a7c80da-6170-4d88-8efc-3f30636f6392",
"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"
}

View file

@ -1,236 +0,0 @@
"""Brand-aware build configuration helpers."""
from __future__ import annotations
import argparse
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class BrandConfig:
"""Packaging metadata for a branded build."""
brand_id: str
display_name: str
asset_prefix: str
exe_name: str
manufacturer: str
install_dir_name: str
shortcut_description: str
bundle_identifier: str
config_dir_name: str
msi_upgrade_code: str
update_channel: str
icon_ico: Path
icon_icns: Path
dialog_bmp: Path
banner_bmp: Path
license_rtf: Path
def windows_installer_name(self, version: str) -> 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())

View file

@ -28,13 +28,10 @@ DIST_DIR="$BUILD_DIR/dist/macos"
TEMP_BUILD="$BUILD_DIR/temp/macos" TEMP_BUILD="$BUILD_DIR/temp/macos"
SPECS_DIR="$BUILD_DIR/specs" SPECS_DIR="$BUILD_DIR/specs"
SPEC_FILE="$BUILD_DIR/webdrop_bridge.spec" SPEC_FILE="$BUILD_DIR/webdrop_bridge.spec"
BRAND_HELPER="$BUILD_DIR/scripts/brand_config.py"
BRAND=""
APP_NAME="WebDropBridge" APP_NAME="WebDropBridge"
DMG_VOLUME_NAME="WebDrop Bridge" DMG_VOLUME_NAME="WebDrop Bridge"
BUNDLE_IDENTIFIER="de.him_tools.webdrop-bridge" VERSION="1.0.0"
VERSION=""
# Default .env file # Default .env file
ENV_FILE="$PROJECT_ROOT/.env" ENV_FILE="$PROJECT_ROOT/.env"
@ -57,10 +54,6 @@ while [[ $# -gt 0 ]]; do
ENV_FILE="$2" ENV_FILE="$2"
shift 2 shift 2
;; ;;
--brand)
BRAND="$2"
shift 2
;;
*) *)
echo "Unknown option: $1" echo "Unknown option: $1"
exit 1 exit 1
@ -77,18 +70,6 @@ fi
echo "📋 Using configuration: $ENV_FILE" 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 # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
@ -197,8 +178,6 @@ build_executable() {
# Export env file for spec file to pick up # Export env file for spec file to pick up
export WEBDROP_ENV_FILE="$ENV_FILE" export WEBDROP_ENV_FILE="$ENV_FILE"
export WEBDROP_VERSION="$VERSION"
export WEBDROP_BUNDLE_ID="$BUNDLE_IDENTIFIER"
python3 -m PyInstaller \ python3 -m PyInstaller \
--distpath="$DIST_DIR" \ --distpath="$DIST_DIR" \
@ -273,8 +252,6 @@ create_dmg() {
SIZE=$(du -h "$DMG_FILE" | cut -f1) SIZE=$(du -h "$DMG_FILE" | cut -f1)
log_success "DMG created successfully" log_success "DMG created successfully"
log_info "Output: $DMG_FILE (Size: $SIZE)" 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 "" echo ""
} }

View file

@ -39,14 +39,13 @@ from pathlib import Path
from datetime import datetime from datetime import datetime
# Import shared version utilities # Import shared version utilities
from brand_config import load_brand_config
from sync_version import get_current_version, do_sync_version from sync_version import get_current_version, do_sync_version
class WindowsBuilder: class WindowsBuilder:
"""Build Windows installer using PyInstaller.""" """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. """Initialize builder paths.
Args: Args:
@ -54,12 +53,10 @@ class WindowsBuilder:
If that doesn't exist, raises error. If that doesn't exist, raises error.
""" """
self.project_root = Path(__file__).parent.parent.parent 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.build_dir = self.project_root / "build"
self.dist_dir = self.build_dir / "dist" / "windows" / self.brand.brand_id self.dist_dir = self.build_dir / "dist" / "windows"
self.temp_dir = self.build_dir / "temp" / "windows" / self.brand.brand_id self.temp_dir = self.build_dir / "temp" / "windows"
self.spec_file = self.build_dir / "webdrop_bridge.spec" self.spec_file = self.build_dir / "webdrop_bridge.spec"
self.wix_template = self.build_dir / "WebDropBridge.wxs"
self.version = get_current_version() self.version = get_current_version()
# Validate and set env file # Validate and set env file
@ -77,7 +74,6 @@ class WindowsBuilder:
self.env_file = env_file self.env_file = env_file
print(f"📋 Using configuration: {self.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: def _get_version(self) -> str:
"""Get version from __init__.py. """Get version from __init__.py.
@ -120,15 +116,6 @@ class WindowsBuilder:
# Set environment variable for spec file to use # Set environment variable for spec file to use
env = os.environ.copy() env = os.environ.copy()
env["WEBDROP_ENV_FILE"] = str(self.env_file) 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) result = subprocess.run(cmd, cwd=str(self.project_root), text=True, env=env)
@ -136,8 +123,8 @@ class WindowsBuilder:
print("❌ PyInstaller build failed") print("❌ PyInstaller build failed")
return False return False
# Check if executable exists (inside the COLLECT directory) # Check if executable exists (now in WebDropBridge/WebDropBridge.exe due to COLLECT)
exe_path = self.dist_dir / self.brand.exe_name / f"{self.brand.exe_name}.exe" exe_path = self.dist_dir / "WebDropBridge" / "WebDropBridge.exe"
if not exe_path.exists(): if not exe_path.exists():
print(f"❌ Executable not found at {exe_path}") print(f"❌ Executable not found at {exe_path}")
return False return False
@ -147,9 +134,7 @@ class WindowsBuilder:
# Calculate total dist size # Calculate total dist size
total_size = sum( total_size = sum(
f.stat().st_size f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file()
for f in self.dist_dir.glob(f"{self.brand.exe_name}/**/*")
if f.is_file()
) )
if total_size > 0: if total_size > 0:
print(f" Total size: {total_size / 1024 / 1024:.1f} MB") print(f" Total size: {total_size / 1024 / 1024:.1f} MB")
@ -264,7 +249,7 @@ class WindowsBuilder:
# Harvest application files using Heat # Harvest application files using Heat
print(f" Harvesting application files...") 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(): if not dist_folder.exists():
print(f"❌ Distribution folder not found: {dist_folder}") print(f"❌ Distribution folder not found: {dist_folder}")
return False return False
@ -306,7 +291,7 @@ class WindowsBuilder:
# Compile both WiX files # Compile both WiX files
wix_obj = self.build_dir / "WebDropBridge.wixobj" wix_obj = self.build_dir / "WebDropBridge.wixobj"
wix_files_obj = self.build_dir / "WebDropBridge_Files.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 # Run candle compiler - make sure to use correct source directory
candle_cmd = [ candle_cmd = [
@ -316,11 +301,11 @@ class WindowsBuilder:
"-ext", "-ext",
"WixUtilExtension", "WixUtilExtension",
f"-dDistDir={self.dist_dir}", 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 f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets
"-o", "-o",
str(self.build_dir) + "\\", str(self.build_dir) + "\\",
str(self.build_dir / "WebDropBridge.generated.wxs"), str(self.build_dir / "WebDropBridge.wxs"),
] ]
if harvest_file.exists(): if harvest_file.exists():
@ -340,7 +325,7 @@ class WindowsBuilder:
"-ext", "-ext",
"WixUtilExtension", "WixUtilExtension",
"-b", "-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", "-o",
str(msi_output), str(msi_output),
str(wix_obj), str(wix_obj),
@ -368,7 +353,6 @@ class WindowsBuilder:
print("✅ MSI installer created successfully") print("✅ MSI installer created successfully")
print(f"📦 Output: {msi_output}") print(f"📦 Output: {msi_output}")
print(f" Size: {msi_output.stat().st_size / 1024 / 1024:.1f} MB") print(f" Size: {msi_output.stat().st_size / 1024 / 1024:.1f} MB")
self.generate_checksum(msi_output)
return True return True
@ -379,7 +363,7 @@ class WindowsBuilder:
even if a previous PyInstaller run omitted them. even if a previous PyInstaller run omitted them.
""" """
src_icons_dir = self.project_root / "resources" / "icons" 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"] required_icons = ["home.ico", "reload.ico", "open.ico", "openwith.ico"]
try: try:
@ -408,23 +392,97 @@ class WindowsBuilder:
Creates per-machine installation (Program Files). Creates per-machine installation (Program Files).
Installation requires admin rights, but the app does not. Installation requires admin rights, but the app does not.
""" """
wix_template = self.wix_template.read_text(encoding="utf-8") wix_content = f"""<?xml version="1.0" encoding="UTF-8"?>
wix_content = wix_template.format( <Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
product_name=self.brand.display_name, xmlns:ui="http://schemas.microsoft.com/wix/2010/ui"
version=self.version, xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
manufacturer=self.brand.manufacturer, <Product Id="*" Name="WebDrop Bridge" Language="1033" Version="{self.version}"
upgrade_code=self.brand.msi_upgrade_code, Manufacturer="HIM-Tools"
asset_prefix=self.brand.asset_prefix, UpgradeCode="12345678-1234-1234-1234-123456789012">
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.generated.wxs" <Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" Platform="x64" />
<Media Id="1" Cabinet="WebDropBridge.cab" EmbedCab="yes" />
<!-- Required property for WixUI_InstallDir dialog set -->
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
<!-- Application Icon -->
<Icon Id="AppIcon.ico" SourceFile="$(var.ResourcesDir)\\icons\\app.ico" />
<!-- Custom branding for InstallDir dialog set -->
<WixVariable Id="WixUIDialogBmp" Value="$(var.ResourcesDir)\\icons\\background.bmp" />
<WixVariable Id="WixUIBannerBmp" Value="$(var.ResourcesDir)\\icons\\banner.bmp" />
<WixVariable Id="WixUILicenseRtf" Value="$(var.ResourcesDir)\\license.rtf" />
<!-- Installation UI dialogs -->
<UIRef Id="WixUI_InstallDir" />
<UIRef Id="WixUI_ErrorProgressText" />
<!-- Close running application before installation -->
<util:CloseApplication
Target="WebDropBridge.exe"
CloseMessage="yes"
RebootPrompt="no"
ElevatedCloseMessage="no" />
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
<ComponentGroupRef Id="AppFiles" />
<ComponentRef Id="ProgramMenuShortcut" />
<ComponentRef Id="DesktopShortcut" />
</Feature>
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFiles64Folder">
<Directory Id="INSTALLFOLDER" Name="WebDrop Bridge" />
</Directory>
<Directory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="WebDrop Bridge"/>
</Directory>
<Directory Id="DesktopFolder" />
</Directory>
<DirectoryRef Id="ApplicationProgramsFolder">
<Component Id="ProgramMenuShortcut" Guid="*">
<Shortcut Id="ApplicationStartMenuShortcut"
Name="WebDrop Bridge"
Description="Web Drag-and-Drop Bridge"
Target="[INSTALLFOLDER]WebDropBridge.exe"
Icon="AppIcon.ico"
IconIndex="0"
WorkingDirectory="INSTALLFOLDER" />
<RemoveFolder Id="ApplicationProgramsFolderRemove"
On="uninstall" />
<RegistryValue Root="HKCU"
Key="Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\WebDropBridge"
Name="installed"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</DirectoryRef>
<DirectoryRef Id="DesktopFolder">
<Component Id="DesktopShortcut" Guid="*">
<Shortcut Id="DesktopApplicationShortcut"
Name="WebDrop Bridge"
Description="Web Drag-and-Drop Bridge"
Target="[INSTALLFOLDER]WebDropBridge.exe"
Icon="AppIcon.ico"
IconIndex="0"
WorkingDirectory="INSTALLFOLDER" />
<RegistryValue Root="HKCU"
Key="Software\\WebDropBridge"
Name="DesktopShortcut"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</DirectoryRef>
</Product>
</Wix>
"""
wix_file = self.build_dir / "WebDropBridge.wxs"
wix_file.write_text(wix_content) wix_file.write_text(wix_content)
print(f" Created WiX source: {wix_file}") print(f" Created WiX source: {wix_file}")
return True return True
@ -515,7 +573,7 @@ class WindowsBuilder:
print(" Skipping code signing") print(" Skipping code signing")
return True 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 = [ cmd = [
signtool, signtool,
"sign", "sign",
@ -548,7 +606,7 @@ class WindowsBuilder:
""" """
start_time = datetime.now() start_time = datetime.now()
print("=" * 60) print("=" * 60)
print(f"🚀 {self.brand.display_name} Windows Build") print("🚀 WebDrop Bridge Windows Build")
print("=" * 60) print("=" * 60)
self.clean() self.clean()
@ -592,12 +650,6 @@ def main() -> int:
default=None, default=None,
help="Path to .env file to bundle (default: project root .env)", 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() args = parser.parse_args()
@ -605,7 +657,7 @@ def main() -> int:
do_sync_version() do_sync_version()
try: try:
builder = WindowsBuilder(env_file=args.env_file, brand=args.brand) builder = WindowsBuilder(env_file=args.env_file)
except FileNotFoundError as e: except FileNotFoundError as e:
print(f"❌ Build failed: {e}") print(f"❌ Build failed: {e}")
return 1 return 1

View file

@ -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( param(
[Parameter(Mandatory = $false)] [Parameter(Mandatory=$false)]
[string]$Version, [string]$Version,
[Parameter(Mandatory = $false)] [Parameter(Mandatory=$false)]
[string[]]$Brands = @("agravity"),
[Parameter(Mandatory = $false)]
[string]$ForgejoUser, [string]$ForgejoUser,
[Parameter(Mandatory = $false)] [Parameter(Mandatory=$false)]
[string]$ForgejoPW, [string]$ForgejoPW,
[switch]$ClearCredentials, [switch]$ClearCredentials,
[switch]$SkipExe,
[string]$ForgejoUrl = "https://git.him-tools.de", [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" $ErrorActionPreference = "Stop"
# Get project root (PSScriptRoot is build/scripts, go up to project root with ..\..)
$projectRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") $projectRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..")
$pythonExe = Join-Path $projectRoot ".venv\Scripts\python.exe"
if (-not (Test-Path $pythonExe)) { # Resolve file paths relative to project root
$pythonExe = "python" $ExePath = Join-Path $projectRoot $ExePath
} $ChecksumPath = Join-Path $projectRoot $ChecksumPath
$MsiPath = Join-Path $projectRoot $MsiPath
$brandHelper = Join-Path $projectRoot "build\scripts\brand_config.py"
$manifestOutput = Join-Path $projectRoot "build\dist\release-manifest.json" # Function to read version from .env or .env.example
function Get-VersionFromEnv {
function Get-CurrentVersion { # Use already resolved project root
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()
# 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) { if ($ClearCredentials) {
Remove-Item env:FORGEJO_USER -ErrorAction SilentlyContinue Remove-Item env:FORGEJO_USER -ErrorAction SilentlyContinue
Remove-Item env:FORGEJO_PASS -ErrorAction SilentlyContinue Remove-Item env:FORGEJO_PASS -ErrorAction SilentlyContinue
@ -38,95 +72,190 @@ if ($ClearCredentials) {
exit 0 exit 0
} }
# Get credentials from sources (in order of priority)
if (-not $ForgejoUser) { if (-not $ForgejoUser) {
$ForgejoUser = $env:FORGEJO_USER $ForgejoUser = $env:FORGEJO_USER
} }
if (-not $ForgejoPW) { if (-not $ForgejoPW) {
$ForgejoPW = $env:FORGEJO_PASS $ForgejoPW = $env:FORGEJO_PASS
} }
# If still no credentials, prompt user interactively
if (-not $ForgejoUser -or -not $ForgejoPW) { if (-not $ForgejoUser -or -not $ForgejoPW) {
Write-Host "Forgejo credentials not found. Enter your credentials:" -ForegroundColor Yellow Write-Host "Forgejo credentials not found. Enter your credentials:" -ForegroundColor Yellow
if (-not $ForgejoUser) { if (-not $ForgejoUser) {
$ForgejoUser = Read-Host "Username" $ForgejoUser = Read-Host "Username"
} }
if (-not $ForgejoPW) { if (-not $ForgejoPW) {
$securePass = Read-Host "Password" -AsSecureString $securePass = Read-Host "Password" -AsSecureString
$ForgejoPW = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUnicode($securePass)) $ForgejoPW = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUnicode($securePass))
} }
# Save credentials to environment for this session
$env:FORGEJO_USER = $ForgejoUser $env:FORGEJO_USER = $ForgejoUser
$env:FORGEJO_PASS = $ForgejoPW $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) { 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] # Define MSI path with resolved version
foreach ($brand in $Brands) { $MsiPath = Join-Path $projectRoot "build\dist\windows\WebDropBridge-$Version-Setup.msi"
$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"
if (Test-Path $msiPath) { # Verify files exist (exe/checksum optional, MSI required)
$artifactPaths.Add($msiPath) if (-not $SkipExe) {
if (Test-Path $checksumPath) { if (-not (Test-Path $ExePath)) {
$artifactPaths.Add($checksumPath) Write-Host "WARNING: Executable not found at $ExePath" -ForegroundColor Yellow
} Write-Host " Use -SkipExe flag to skip exe upload" -ForegroundColor Gray
$msiSize = (Get-Item $msiPath).Length / 1MB $SkipExe = $true
Write-Host "Windows artifact: $([System.IO.Path]::GetFileName($msiPath)) ($([math]::Round($msiSize, 2)) MB)" }
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 # MSI is the primary release artifact
if (Test-Path $manifestOutput) { if (-not (Test-Path $MsiPath)) {
$artifactPaths.Add($manifestOutput) 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
if ($artifactPaths.Count -eq 0) {
Write-Host "ERROR: No Windows artifacts found for the requested brands" -ForegroundColor Red
exit 1 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}")) $auth = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("${ForgejoUser}:${ForgejoPW}"))
$headers = @{ $headers = @{
"Authorization" = "Basic $auth" "Authorization" = "Basic $auth"
"Content-Type" = "application/json" "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" $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 = @{ $releaseData = @{
tag_name = "v$Version" tag_name = "v$Version"
name = "WebDropBridge v$Version" name = "WebDropBridge v$Version"
body = "Shared branded release for WebDrop Bridge v$Version" body = $releaseBody
draft = $false draft = $false
prerelease = $false prerelease = $false
} | ConvertTo-Json } | ConvertTo-Json
try { try {
$lookupResponse = Invoke-WebRequest -Uri $releaseLookupUrl -Method GET -Headers $headers -TimeoutSec 30 -UseBasicParsing -ErrorAction Stop $response = Invoke-WebRequest -Uri $releaseUrl `
$releaseInfo = $lookupResponse.Content | ConvertFrom-Json -Method POST `
$releaseId = $releaseInfo.id -Headers $headers `
Write-Host "[OK] Using existing release (ID: $releaseId)" -ForegroundColor Green -Body $releaseData `
} -TimeoutSec 30 `
catch { -UseBasicParsing `
$response = Invoke-WebRequest -Uri $releaseUrl -Method POST -Headers $headers -Body $releaseData -TimeoutSec 30 -UseBasicParsing -ErrorAction Stop -ErrorAction Stop
$releaseInfo = $response.Content | ConvertFrom-Json $releaseInfo = $response.Content | ConvertFrom-Json
$releaseId = $releaseInfo.id $releaseId = $releaseInfo.id
Write-Host "[OK] Release created (ID: $releaseId)" -ForegroundColor Green 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" $curlAuth = "$ForgejoUser`:$ForgejoPW"
$uploadUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets" $uploadUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets"
foreach ($artifact in $artifactPaths) { # Step 2: Upload MSI installer as primary artifact
$response = curl.exe -s -X POST -u $curlAuth -F "attachment=@$artifact" $uploadUrl 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*") { 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
} }
} }

View file

@ -1,33 +1,31 @@
#!/bin/bash #!/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 set -e
# Parse arguments
VERSION="" VERSION=""
BRANDS=("agravity") FORGEJO_USER=""
FORGEJO_USER="${FORGEJO_USER}" FORGEJO_PASS=""
FORGEJO_PASS="${FORGEJO_PASS}"
FORGEJO_URL="https://git.him-tools.de" FORGEJO_URL="https://git.him-tools.de"
REPO="HIM-public/webdrop-bridge" REPO="HIM-public/webdrop-bridge"
DMG_PATH="build/dist/macos/WebDropBridge.dmg"
CHECKSUM_PATH="build/dist/macos/WebDropBridge.dmg.sha256"
CLEAR_CREDS=false 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 while [[ $# -gt 0 ]]; do
case $1 in case $1 in
-v|--version) VERSION="$2"; shift 2 ;; -v|--version) VERSION="$2"; shift 2;;
-u|--url) FORGEJO_URL="$2"; shift 2 ;; -u|--url) FORGEJO_URL="$2"; shift 2;;
--brand) BRANDS+=("$2"); shift 2 ;; --clear-credentials) CLEAR_CREDS=true; shift;;
--clear-credentials) CLEAR_CREDS=true; shift ;; *) echo "Unknown option: $1"; exit 1;;
*) echo "Unknown option: $1"; exit 1 ;;
esac esac
done done
if [ ${#BRANDS[@]} -gt 1 ] && [ "${BRANDS[0]}" = "agravity" ]; then # Handle --clear-credentials flag
BRANDS=("${BRANDS[@]:1}")
fi
if [ "$CLEAR_CREDS" = true ]; then if [ "$CLEAR_CREDS" = true ]; then
unset FORGEJO_USER unset FORGEJO_USER
unset FORGEJO_PASS unset FORGEJO_PASS
@ -35,95 +33,127 @@ if [ "$CLEAR_CREDS" = true ]; then
exit 0 exit 0
fi fi
# Load credentials from environment
FORGEJO_USER="${FORGEJO_USER}"
FORGEJO_PASS="${FORGEJO_PASS}"
# Verify required parameters
if [ -z "$VERSION" ]; then 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())")" echo "ERROR: Version parameter required" >&2
fi echo "Usage: $0 -v VERSION [-u FORGEJO_URL]" >&2
echo "Example: $0 -v 1.0.0" >&2
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"
exit 1 exit 1
fi 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) 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_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_DATA=$(cat <<EOF
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
if [ -z "$RELEASE_ID" ]; then
RELEASE_DATA=$(cat <<EOF
{ {
"tag_name": "v$VERSION", "tag_name": "v$VERSION",
"name": "WebDropBridge v$VERSION", "name": "WebDropBridge v$VERSION",
"body": "Shared branded release for WebDrop Bridge v$VERSION", "body": "WebDropBridge v$VERSION\n\nChecksum: $CHECKSUM",
"draft": false, "draft": false,
"prerelease": false "prerelease": false
} }
EOF EOF
) )
RESPONSE=$(curl -s -X POST \
-H "Authorization: Basic $BASIC_AUTH" \ RESPONSE=$(curl -s -X POST \
-H "Content-Type: application/json" \ -H "Authorization: Basic $BASIC_AUTH" \
-d "$RELEASE_DATA" \ -H "Content-Type: application/json" \
"$RELEASE_URL") -d "$RELEASE_DATA" \
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2) "$RELEASE_URL")
fi
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
if [ -z "$RELEASE_ID" ]; then if [ -z "$RELEASE_ID" ]; then
echo "ERROR creating or finding release" echo "ERROR creating release:"
echo "$RESPONSE" echo "$RESPONSE"
exit 1 exit 1
fi fi
UPLOAD_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets" echo "[OK] Release created (ID: $RELEASE_ID)"
for ARTIFACT in "${ARTIFACTS[@]}"; do
HTTP_CODE=$(curl -s -w "%{http_code}" -X POST \
-H "Authorization: Basic $BASIC_AUTH" \
-F "attachment=@$ARTIFACT" \
"$UPLOAD_URL" \
-o /tmp/curl_response.txt)
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then # Step 2: Upload DMG as asset
echo "[OK] Uploaded $(basename "$ARTIFACT")" echo "Uploading executable asset..."
else UPLOAD_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets"
echo "ERROR uploading $(basename "$ARTIFACT") (HTTP $HTTP_CODE)"
cat /tmp/curl_response.txt HTTP_CODE=$(curl -s -w "%{http_code}" -X POST \
exit 1 -H "Authorization: Basic $BASIC_AUTH" \
fi -F "attachment=@$DMG_PATH" \
done "$UPLOAD_URL" \
-o /tmp/curl_response.txt)
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then
echo "[OK] DMG uploaded"
else
echo "ERROR uploading DMG (HTTP $HTTP_CODE)"
cat /tmp/curl_response.txt
exit 1
fi
# Step 3: Upload checksum as asset
echo "Uploading checksum asset..."
HTTP_CODE=$(curl -s -w "%{http_code}" -X POST \
-H "Authorization: Basic $BASIC_AUTH" \
-F "attachment=@$CHECKSUM_PATH" \
"$UPLOAD_URL" \
-o /tmp/curl_response.txt)
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then
echo "[OK] Checksum uploaded"
else
echo "ERROR uploading checksum (HTTP $HTTP_CODE)"
cat /tmp/curl_response.txt
exit 1
fi
echo "" echo ""
echo "[OK] Release complete!" echo "[OK] Release complete!"

View file

@ -1,12 +1,6 @@
{ {
"brand_id": "agravity", "app_name": "WebDrop Bridge",
"config_dir_name": "agravity_bridge",
"app_name": "Agravity Bridge",
"webapp_url": "https://dev.agravity.io/", "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_mappings": [
{ {
"url_prefix": "https://devagravitystg.file.core.windows.net/devagravitysync/", "url_prefix": "https://devagravitystg.file.core.windows.net/devagravitysync/",

View file

@ -3,7 +3,6 @@
import json import json
import logging import logging
import os import os
import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import List from typing import List
@ -12,13 +11,6 @@ from dotenv import load_dotenv
logger = logging.getLogger(__name__) 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): class ConfigurationError(Exception):
"""Raised when configuration is invalid.""" """Raised when configuration is invalid."""
@ -68,12 +60,6 @@ class Config:
enable_logging: Whether to write logs to file enable_logging: Whether to write logs to file
enable_checkout: Whether to check asset checkout status and show checkout dialog enable_checkout: Whether to check asset checkout status and show checkout dialog
on drag. Disabled by default as checkout support is optional. 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: Raises:
ConfigurationError: If configuration values are invalid ConfigurationError: If configuration values are invalid
@ -96,12 +82,6 @@ class Config:
enable_logging: bool = True enable_logging: bool = True
enable_checkout: bool = False enable_checkout: bool = False
language: str = "auto" 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 @classmethod
def from_file(cls, config_path: Path) -> "Config": def from_file(cls, config_path: Path) -> "Config":
@ -144,9 +124,6 @@ class Config:
elif not root.is_dir(): elif not root.is_dir():
raise ConfigurationError(f"Allowed root is not a directory: {root}") 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 # Get log file path
log_file = None log_file = None
if data.get("enable_logging", True): if data.get("enable_logging", True):
@ -155,10 +132,10 @@ class Config:
log_file = Path(log_file_str) log_file = Path(log_file_str)
# If relative path, resolve relative to app data directory instead of cwd # If relative path, resolve relative to app data directory instead of cwd
if not log_file.is_absolute(): 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: else:
# Use default log path in app data # 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") app_name = data.get("app_name", "WebDrop Bridge")
stored_window_title = data.get("window_title", "") stored_window_title = data.get("window_title", "")
@ -197,12 +174,6 @@ class Config:
enable_logging=data.get("enable_logging", True), enable_logging=data.get("enable_logging", True),
enable_checkout=data.get("enable_checkout", False), enable_checkout=data.get("enable_checkout", False),
language=data.get("language", "auto"), 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 @classmethod
@ -230,8 +201,6 @@ class Config:
from webdrop_bridge import __version__ from webdrop_bridge import __version__
app_version = __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_level = os.getenv("LOG_LEVEL", "INFO").upper()
log_file_str = os.getenv("LOG_FILE", None) log_file_str = os.getenv("LOG_FILE", None)
@ -246,10 +215,6 @@ class Config:
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true" enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true" enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true"
language = os.getenv("LANGUAGE", "auto") 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 # Validate log level
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
@ -289,10 +254,10 @@ class Config:
log_file = Path(log_file_str) log_file = Path(log_file_str)
# If relative path, resolve relative to app data directory instead of cwd # If relative path, resolve relative to app data directory instead of cwd
if not log_file.is_absolute(): 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: else:
# Use default log path in app data # 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 # Validate webapp URL is not empty
if not webapp_url: if not webapp_url:
@ -343,12 +308,6 @@ class Config:
enable_logging=enable_logging, enable_logging=enable_logging,
enable_checkout=enable_checkout, enable_checkout=enable_checkout,
language=language, 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: def to_file(self, config_path: Path) -> None:
@ -378,12 +337,6 @@ class Config:
"enable_logging": self.enable_logging, "enable_logging": self.enable_logging,
"enable_checkout": self.enable_checkout, "enable_checkout": self.enable_checkout,
"language": self.language, "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) config_path.parent.mkdir(parents=True, exist_ok=True)
@ -391,49 +344,7 @@ class Config:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
@staticmethod @staticmethod
def load_bootstrap_env(env_file: str | None = None) -> Path | None: def get_default_config_path() -> Path:
"""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. """Get the default configuration file path.
Returns: Returns:
@ -445,10 +356,10 @@ class Config:
base = Path.home() / "AppData" / "Roaming" base = Path.home() / "AppData" / "Roaming"
else: else:
base = Path.home() / ".config" 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 @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. """Get the default directory for log files.
Always uses user's AppData directory to ensure permissions work Always uses user's AppData directory to ensure permissions work
@ -463,31 +374,21 @@ class Config:
base = Path.home() / "AppData" / "Roaming" base = Path.home() / "AppData" / "Roaming"
else: else:
base = Path.home() / ".local" / "share" base = Path.home() / ".local" / "share"
return base / (config_dir_name or Config.get_default_config_dir_name()) / "logs" return base / "webdrop_bridge" / "logs"
@staticmethod @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. """Get the default log file path.
Returns: Returns:
Path to default log file in user's AppData/Roaming/webdrop_bridge/logs 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() / "webdrop_bridge.log"
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: def __repr__(self) -> str:
"""Return developer-friendly representation.""" """Return developer-friendly representation."""
return ( return (
f"Config(app={self.app_name} v{self.app_version}, " f"Config(app={self.app_name} v{self.app_version}, "
f"brand={self.brand_id}, "
f"log_level={self.log_level}, " f"log_level={self.log_level}, "
f"allowed_roots={len(self.allowed_roots)} dirs, " f"allowed_roots={len(self.allowed_roots)} dirs, "
f"window={self.window_width}x{self.window_height})" f"window={self.window_width}x{self.window_height})"

View file

@ -101,13 +101,14 @@ class ConfigValidator:
class ConfigProfile: class ConfigProfile:
"""Manages named configuration profiles. """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.""" """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: def save_profile(self, profile_name: str, config: Config) -> Path:
"""Save configuration as a named profile. """Save configuration as a named profile.
@ -125,7 +126,7 @@ class ConfigProfile:
if not profile_name or "/" in profile_name or "\\" in profile_name: if not profile_name or "/" in profile_name or "\\" in profile_name:
raise ConfigurationError(f"Invalid profile name: {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 = { config_data = {
"app_name": config.app_name, "app_name": config.app_name,
@ -159,7 +160,7 @@ class ConfigProfile:
Raises: Raises:
ConfigurationError: If profile not found or invalid 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(): if not profile_path.exists():
raise ConfigurationError(f"Profile not found: {profile_name}") raise ConfigurationError(f"Profile not found: {profile_name}")
@ -178,10 +179,10 @@ class ConfigProfile:
Returns: Returns:
List of profile names (without .json extension) List of profile names (without .json extension)
""" """
if not self.profiles_dir.exists(): if not self.PROFILES_DIR.exists():
return [] 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: def delete_profile(self, profile_name: str) -> None:
"""Delete a profile. """Delete a profile.
@ -192,7 +193,7 @@ class ConfigProfile:
Raises: Raises:
ConfigurationError: If profile not found 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(): if not profile_path.exists():
raise ConfigurationError(f"Profile not found: {profile_name}") raise ConfigurationError(f"Profile not found: {profile_name}")

View file

@ -5,11 +5,9 @@ verifying checksums from Forgejo releases.
""" """
import asyncio import asyncio
import fnmatch
import hashlib import hashlib
import json import json
import logging import logging
import platform
import socket import socket
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -36,16 +34,7 @@ class Release:
class UpdateManager: class UpdateManager:
"""Manages auto-updates via Forgejo releases API.""" """Manages auto-updates via Forgejo releases API."""
def __init__( def __init__(self, current_version: str, config_dir: Optional[Path] = None):
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. """Initialize update manager.
Args: Args:
@ -53,11 +42,8 @@ class UpdateManager:
config_dir: Directory for storing update cache. Defaults to temp. config_dir: Directory for storing update cache. Defaults to temp.
""" """
self.current_version = current_version self.current_version = current_version
self.brand_id = brand_id self.forgejo_url = "https://git.him-tools.de"
self.forgejo_url = forgejo_url.rstrip("/") self.repo = "HIM-public/webdrop-bridge"
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" self.api_endpoint = f"{self.forgejo_url}/api/v1/repos/{self.repo}/releases/latest"
# Cache management # Cache management
@ -66,128 +52,6 @@ class UpdateManager:
self.cache_file = self.cache_dir / "update_check.json" self.cache_file = self.cache_dir / "update_check.json"
self.cache_ttl = timedelta(hours=24) 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]: def _parse_version(self, version_str: str) -> tuple[int, int, int]:
"""Parse semantic version string to tuple. """Parse semantic version string to tuple.
@ -389,7 +253,12 @@ class UpdateManager:
logger.error("No assets found in release") logger.error("No assets found in release")
return None 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: if not installer_asset:
logger.error("No installer found in release assets") logger.error("No installer found in release assets")
@ -476,11 +345,14 @@ class UpdateManager:
Returns: Returns:
True if checksum matches, False otherwise True if checksum matches, False otherwise
""" """
installer_asset, checksum_asset = await self._resolve_release_assets(release) # Find .sha256 file matching the installer name (e.g. Setup.msi.sha256)
installer_name = installer_asset["name"] if installer_asset else file_path.name # Fall back to any .sha256 only if no specific match exists
installer_name = file_path.name
if not checksum_asset: checksum_asset = None
checksum_asset = self._find_asset(release.assets, f"{installer_name}.sha256") for asset in release.assets:
if asset["name"] == f"{installer_name}.sha256":
checksum_asset = asset
break
if not checksum_asset: if not checksum_asset:
logger.warning("No checksum file found in release") logger.warning("No checksum file found in release")

View file

@ -30,8 +30,6 @@ def main() -> int:
int: Exit code (0 for success, non-zero for error) int: Exit code (0 for success, non-zero for error)
""" """
try: try:
Config.load_bootstrap_env()
# Load configuration from file if it exists, otherwise from environment # Load configuration from file if it exists, otherwise from environment
config_path = Config.get_default_config_path() config_path = Config.get_default_config_path()
if config_path.exists(): if config_path.exists():

View file

@ -1872,16 +1872,8 @@ class MainWindow(QMainWindow):
try: try:
# Create update manager # Create update manager
cache_dir = self.config.get_cache_dir() cache_dir = Path.home() / ".webdrop_bridge"
manager = UpdateManager( manager = UpdateManager(current_version=self.config.app_version, config_dir=cache_dir)
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 # Run async check in background
self._run_async_check(manager) self._run_async_check(manager)
@ -2098,13 +2090,7 @@ class MainWindow(QMainWindow):
# Create update manager # Create update manager
manager = UpdateManager( manager = UpdateManager(
current_version=self.config.app_version, current_version=self.config.app_version, config_dir=Path.home() / ".webdrop_bridge"
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 # Create and start background thread
@ -2243,13 +2229,7 @@ class MainWindow(QMainWindow):
from webdrop_bridge.core.updater import UpdateManager from webdrop_bridge.core.updater import UpdateManager
manager = UpdateManager( manager = UpdateManager(
current_version=self.config.app_version, current_version=self.config.app_version, config_dir=Path.home() / ".webdrop_bridge"
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): if manager.install_update(installer_path):

View file

@ -42,7 +42,7 @@ class SettingsDialog(QDialog):
""" """
super().__init__(parent) super().__init__(parent)
self.config = config self.config = config
self.profile_manager = ConfigProfile(config.config_dir_name) self.profile_manager = ConfigProfile()
self.setWindowTitle(tr("settings.title")) self.setWindowTitle(tr("settings.title"))
self.setGeometry(100, 100, 600, 500) self.setGeometry(100, 100, 600, 500)
@ -96,7 +96,7 @@ class SettingsDialog(QDialog):
self.config.window_width = config_data["window_width"] self.config.window_width = config_data["window_width"]
self.config.window_height = config_data["window_height"] 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) self.config.to_file(config_path)
logger.info(f"Configuration saved to {config_path}") logger.info(f"Configuration saved to {config_path}")

View file

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

View file

@ -15,19 +15,7 @@ def clear_env():
# Clear relevant variables # Clear relevant variables
for key in list(os.environ.keys()): for key in list(os.environ.keys()):
if key.startswith( if key.startswith(('APP_', 'LOG_', 'ALLOWED_', 'WEBAPP_', 'WINDOW_', 'ENABLE_')):
(
"APP_",
"LOG_",
"ALLOWED_",
"WEBAPP_",
"WINDOW_",
"ENABLE_",
"BRAND_",
"UPDATE_",
"LANGUAGE",
)
):
del os.environ[key] del os.environ[key]
yield yield
@ -76,28 +64,6 @@ class TestConfigFromEnv:
assert config.window_width == 1200 assert config.window_width == 1200
assert config.window_height == 800 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): def test_from_env_with_defaults(self, tmp_path):
"""Test loading config uses defaults when env vars not set.""" """Test loading config uses defaults when env vars not set."""
# Create empty .env file # Create empty .env file
@ -107,11 +73,8 @@ class TestConfigFromEnv:
config = Config.from_env(str(env_file)) config = Config.from_env(str(env_file))
assert config.app_name == "WebDrop Bridge" 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) # Version should come from __init__.py (dynamic, not hardcoded)
from webdrop_bridge import __version__ from webdrop_bridge import __version__
assert config.app_version == __version__ assert config.app_version == __version__
assert config.log_level == "INFO" assert config.log_level == "INFO"
assert config.window_width == 1024 assert config.window_width == 1024
@ -224,11 +187,3 @@ class TestConfigValidation:
config = Config.from_env(str(env_file)) config = Config.from_env(str(env_file))
assert config.allowed_urls == ["example.com", "test.org"] 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")

View file

@ -16,17 +16,6 @@ def update_manager(tmp_path):
return UpdateManager(current_version="0.0.1", config_dir=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 @pytest.fixture
def sample_release(): def sample_release():
"""Sample release data from API.""" """Sample release data from API."""
@ -263,109 +252,6 @@ class TestDownloading:
assert result is None 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: class TestChecksumVerification:
"""Test checksum verification.""" """Test checksum verification."""