Compare commits

..

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

19 changed files with 2984 additions and 4119 deletions

View file

@ -2,7 +2,7 @@
# Application # Application
APP_NAME=WebDrop Bridge APP_NAME=WebDrop Bridge
APP_VERSION=0.8.4 APP_VERSION=0.8.3
# Web App # Web App
WEBAPP_URL=file:///./webapp/index.html WEBAPP_URL=file:///./webapp/index.html

View file

@ -77,8 +77,6 @@ wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0
For more installation options and details, see [QUICKSTART.md](QUICKSTART.md#installing-from-release-wget) and [PACKAGE_MANAGER_SUPPORT.md](docs/PACKAGE_MANAGER_SUPPORT.md) For more installation options and details, see [QUICKSTART.md](QUICKSTART.md#installing-from-release-wget) and [PACKAGE_MANAGER_SUPPORT.md](docs/PACKAGE_MANAGER_SUPPORT.md)
For multi-brand packaging and release workflows, see [BRANDING_AND_RELEASES.md](docs/BRANDING_AND_RELEASES.md).
### Installation from Source ### Installation from Source
```bash ```bash

File diff suppressed because one or more lines are too long

View file

@ -1,88 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui"
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
<Product Id="*" Name="Agravity Bridge" Language="1033" Version="0.8.4"
Manufacturer="agravity"
UpgradeCode="4a7c80da-6170-4d88-8efc-3f30636f6392">
<Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" Platform="x64" />
<Media Id="1" Cabinet="AgravityBridge.cab" EmbedCab="yes" />
<!-- Required property for WixUI_InstallDir dialog set -->
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
<!-- Application Icon -->
<Icon Id="AppIcon.ico" SourceFile="C:\Development\VS Code Projects\webdrop_bridge\resources\icons\app.ico" />
<!-- Custom branding for InstallDir dialog set -->
<WixVariable Id="WixUIDialogBmp" Value="C:\Development\VS Code Projects\webdrop_bridge\resources\icons\background.bmp" />
<WixVariable Id="WixUIBannerBmp" Value="C:\Development\VS Code Projects\webdrop_bridge\resources\icons\banner.bmp" />
<WixVariable Id="WixUILicenseRtf" Value="C:\Development\VS Code Projects\webdrop_bridge\resources\license.rtf" />
<!-- Installation UI dialogs -->
<UIRef Id="WixUI_InstallDir" />
<UIRef Id="WixUI_ErrorProgressText" />
<!-- Close running application before installation -->
<util:CloseApplication
Target="AgravityBridge.exe"
CloseMessage="yes"
RebootPrompt="no"
ElevatedCloseMessage="no" />
<Feature Id="ProductFeature" Title="Agravity 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="Agravity Bridge" />
</Directory>
<Directory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="Agravity Bridge"/>
</Directory>
<Directory Id="DesktopFolder" />
</Directory>
<DirectoryRef Id="ApplicationProgramsFolder">
<Component Id="ProgramMenuShortcut" Guid="*">
<Shortcut Id="ApplicationStartMenuShortcut"
Name="Agravity Bridge"
Description="Agravity drag-and-drop bridge"
Target="[INSTALLFOLDER]AgravityBridge.exe"
Icon="AppIcon.ico"
IconIndex="0"
WorkingDirectory="INSTALLFOLDER" />
<RemoveFolder Id="ApplicationProgramsFolderRemove"
On="uninstall" />
<RegistryValue Root="HKCU"
Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\AgravityBridge"
Name="installed"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</DirectoryRef>
<DirectoryRef Id="DesktopFolder">
<Component Id="DesktopShortcut" Guid="*">
<Shortcut Id="DesktopApplicationShortcut"
Name="Agravity Bridge"
Description="Agravity drag-and-drop bridge"
Target="[INSTALLFOLDER]AgravityBridge.exe"
Icon="AppIcon.ico"
IconIndex="0"
WorkingDirectory="INSTALLFOLDER" />
<RegistryValue Root="HKCU"
Key="Software\AgravityBridge"
Name="DesktopShortcut"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</DirectoryRef>
</Product>
</Wix>

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -14,9 +14,5 @@
"icon_icns": "resources/icons/app.icns", "icon_icns": "resources/icons/app.icns",
"dialog_bmp": "resources/icons/background.bmp", "dialog_bmp": "resources/icons/background.bmp",
"banner_bmp": "resources/icons/banner.bmp", "banner_bmp": "resources/icons/banner.bmp",
"license_rtf": "resources/license.rtf", "license_rtf": "resources/license.rtf"
"toolbar_icon_home": "resources/icons/home.ico",
"toolbar_icon_reload": "resources/icons/reload.ico",
"toolbar_icon_open": "resources/icons/open.ico",
"toolbar_icon_openwith": "resources/icons/openwith.ico"
} }

View file

@ -1,24 +0,0 @@
{
// Copy this file to build/brands/<your-brand>.json (without comments)
// and replace values.
"brand_id": "your_brand_id",
"display_name": "Your Brand Bridge",
"asset_prefix": "YourBrandBridge",
"exe_name": "YourBrandBridge",
"manufacturer": "Your Company",
"install_dir_name": "Your Brand Bridge",
"shortcut_description": "Your brand drag-and-drop bridge",
"bundle_identifier": "com.yourcompany.bridge",
"config_dir_name": "your_brand_bridge",
"msi_upgrade_code": "00000000-0000-0000-0000-000000000000",
"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",
"toolbar_icon_home": "resources/icons/home.ico",
"toolbar_icon_reload": "resources/icons/reload.ico",
"toolbar_icon_open": "resources/icons/open.ico",
"toolbar_icon_openwith": "resources/icons/openwith.ico"
}

View file

@ -29,10 +29,6 @@ class BrandConfig:
dialog_bmp: Path dialog_bmp: Path
banner_bmp: Path banner_bmp: Path
license_rtf: Path license_rtf: Path
toolbar_icon_home: str
toolbar_icon_reload: str
toolbar_icon_open: str
toolbar_icon_openwith: str
def windows_installer_name(self, version: str) -> str: def windows_installer_name(self, version: str) -> str:
return f"{self.asset_prefix}-{version}-win-x64.msi" return f"{self.asset_prefix}-{version}-win-x64.msi"
@ -62,14 +58,8 @@ DEFAULT_BRAND_VALUES: dict[str, Any] = {
"dialog_bmp": "resources/icons/background.bmp", "dialog_bmp": "resources/icons/background.bmp",
"banner_bmp": "resources/icons/banner.bmp", "banner_bmp": "resources/icons/banner.bmp",
"license_rtf": "resources/license.rtf", "license_rtf": "resources/license.rtf",
"toolbar_icon_home": "resources/icons/home.ico",
"toolbar_icon_reload": "resources/icons/reload.ico",
"toolbar_icon_open": "resources/icons/open.ico",
"toolbar_icon_openwith": "resources/icons/openwith.ico",
} }
DEFAULT_BRAND_ID = str(DEFAULT_BRAND_VALUES["brand_id"])
def project_root() -> Path: def project_root() -> Path:
return Path(__file__).resolve().parents[2] return Path(__file__).resolve().parents[2]
@ -80,18 +70,6 @@ def brands_dir(root: Path | None = None) -> Path:
return base / "build" / "brands" return base / "build" / "brands"
def available_brand_names(root: Path | None = None) -> list[str]:
"""Return all supported brand names, including the default build."""
base = root or project_root()
names = [DEFAULT_BRAND_ID]
manifest_dir = brands_dir(base)
if manifest_dir.exists():
for manifest in sorted(manifest_dir.glob("*.json")):
if manifest.stem not in names:
names.append(manifest.stem)
return names
def load_brand_config( def load_brand_config(
brand: str | None = None, brand: str | None = None,
*, *,
@ -102,7 +80,7 @@ def load_brand_config(
base = root or project_root() base = root or project_root()
values = dict(DEFAULT_BRAND_VALUES) values = dict(DEFAULT_BRAND_VALUES)
if manifest_path is None and brand and brand != DEFAULT_BRAND_ID: if manifest_path is None and brand:
manifest_path = brands_dir(base) / f"{brand}.json" manifest_path = brands_dir(base) / f"{brand}.json"
if manifest_path and manifest_path.exists(): if manifest_path and manifest_path.exists():
@ -133,18 +111,6 @@ def load_brand_config(
dialog_bmp=resolve_asset("dialog_bmp"), dialog_bmp=resolve_asset("dialog_bmp"),
banner_bmp=resolve_asset("banner_bmp"), banner_bmp=resolve_asset("banner_bmp"),
license_rtf=resolve_asset("license_rtf"), license_rtf=resolve_asset("license_rtf"),
toolbar_icon_home=str(
values.get("toolbar_icon_home", DEFAULT_BRAND_VALUES["toolbar_icon_home"])
),
toolbar_icon_reload=str(
values.get("toolbar_icon_reload", DEFAULT_BRAND_VALUES["toolbar_icon_reload"])
),
toolbar_icon_open=str(
values.get("toolbar_icon_open", DEFAULT_BRAND_VALUES["toolbar_icon_open"])
),
toolbar_icon_openwith=str(
values.get("toolbar_icon_openwith", DEFAULT_BRAND_VALUES["toolbar_icon_openwith"])
),
) )
@ -194,92 +160,6 @@ def generate_release_manifest(
return output_path return output_path
def merge_release_manifests(
base_manifest: dict[str, Any], overlay_manifest: dict[str, Any]
) -> dict[str, Any]:
"""Merge two release manifests, preserving previously uploaded platforms."""
merged: dict[str, Any] = {
"version": overlay_manifest.get("version") or base_manifest.get("version", ""),
"channel": overlay_manifest.get("channel") or base_manifest.get("channel", "stable"),
"brands": dict(base_manifest.get("brands", {})),
}
for brand_id, entries in overlay_manifest.get("brands", {}).items():
brand_entry = dict(merged["brands"].get(brand_id, {}))
for platform_key, platform_value in entries.items():
if platform_value:
brand_entry[platform_key] = platform_value
merged["brands"][brand_id] = brand_entry
return merged
def collect_local_release_data(
version: str,
*,
platform: str,
root: Path | None = None,
brands: list[str] | None = None,
) -> dict[str, Any]:
"""Collect local artifacts and manifest entries for the requested platform."""
base = root or project_root()
selected_brands = brands or available_brand_names(base)
release_manifest: dict[str, Any] = {
"version": version,
"channel": "stable",
"brands": {},
}
artifacts: list[str] = []
found_brands: list[str] = []
for brand_name in selected_brands:
brand = load_brand_config(brand_name, root=base)
release_manifest["channel"] = brand.update_channel
if platform == "windows":
artifact_dir = base / "build" / "dist" / "windows" / brand.brand_id
installer = artifact_dir / brand.windows_installer_name(version)
checksum = artifact_dir / f"{installer.name}.sha256"
platform_key = "windows-x64"
elif platform == "macos":
artifact_dir = base / "build" / "dist" / "macos" / brand.brand_id
installer = artifact_dir / brand.macos_installer_name(version)
checksum = artifact_dir / f"{installer.name}.sha256"
platform_key = "macos-universal"
if not installer.exists() and brand.brand_id == DEFAULT_BRAND_ID:
legacy_installer = (base / "build" / "dist" / "macos") / brand.macos_installer_name(
version
)
legacy_checksum = legacy_installer.parent / f"{legacy_installer.name}.sha256"
if legacy_installer.exists():
installer = legacy_installer
checksum = legacy_checksum
else:
raise ValueError(f"Unsupported platform: {platform}")
if not installer.exists():
continue
found_brands.append(brand.brand_id)
artifacts.append(str(installer))
if checksum.exists():
artifacts.append(str(checksum))
release_manifest["brands"].setdefault(brand.brand_id, {})[platform_key] = {
"installer": installer.name,
"checksum": checksum.name if checksum.exists() else "",
}
return {
"version": version,
"platform": platform,
"brands": found_brands,
"artifacts": artifacts,
"manifest": release_manifest,
}
def cli_env(args: argparse.Namespace) -> int: def cli_env(args: argparse.Namespace) -> int:
brand = load_brand_config(args.brand) brand = load_brand_config(args.brand)
assignments = { assignments = {
@ -289,13 +169,8 @@ def cli_env(args: argparse.Namespace) -> int:
"WEBDROP_EXE_NAME": brand.exe_name, "WEBDROP_EXE_NAME": brand.exe_name,
"WEBDROP_BUNDLE_ID": brand.bundle_identifier, "WEBDROP_BUNDLE_ID": brand.bundle_identifier,
"WEBDROP_CONFIG_DIR_NAME": brand.config_dir_name, "WEBDROP_CONFIG_DIR_NAME": brand.config_dir_name,
"WEBDROP_UPDATE_CHANNEL": brand.update_channel,
"WEBDROP_ICON_ICO": str(brand.icon_ico), "WEBDROP_ICON_ICO": str(brand.icon_ico),
"WEBDROP_ICON_ICNS": str(brand.icon_icns), "WEBDROP_ICON_ICNS": str(brand.icon_icns),
"WEBDROP_TOOLBAR_ICON_HOME": brand.toolbar_icon_home,
"WEBDROP_TOOLBAR_ICON_RELOAD": brand.toolbar_icon_reload,
"WEBDROP_TOOLBAR_ICON_OPEN": brand.toolbar_icon_open,
"WEBDROP_TOOLBAR_ICON_OPENWITH": brand.toolbar_icon_openwith,
} }
for key, value in assignments.items(): for key, value in assignments.items():
print(f'export {key}="{value}"') print(f'export {key}="{value}"')
@ -312,26 +187,6 @@ def cli_manifest(args: argparse.Namespace) -> int:
return 0 return 0
def cli_local_release_data(args: argparse.Namespace) -> int:
data = collect_local_release_data(
args.version,
platform=args.platform,
brands=args.brands,
)
print(json.dumps(data, indent=2))
return 0
def cli_merge_manifests(args: argparse.Namespace) -> int:
base_manifest = json.loads(Path(args.base).read_text(encoding="utf-8"))
overlay_manifest = json.loads(Path(args.overlay).read_text(encoding="utf-8"))
merged = merge_release_manifests(base_manifest, overlay_manifest)
output_path = Path(args.output)
output_path.write_text(json.dumps(merged, indent=2), encoding="utf-8")
print(output_path)
return 0
def cli_show(args: argparse.Namespace) -> int: def cli_show(args: argparse.Namespace) -> int:
brand = load_brand_config(args.brand) brand = load_brand_config(args.brand)
print( print(
@ -348,10 +203,6 @@ def cli_show(args: argparse.Namespace) -> int:
"config_dir_name": brand.config_dir_name, "config_dir_name": brand.config_dir_name,
"msi_upgrade_code": brand.msi_upgrade_code, "msi_upgrade_code": brand.msi_upgrade_code,
"update_channel": brand.update_channel, "update_channel": brand.update_channel,
"toolbar_icon_home": brand.toolbar_icon_home,
"toolbar_icon_reload": brand.toolbar_icon_reload,
"toolbar_icon_open": brand.toolbar_icon_open,
"toolbar_icon_openwith": brand.toolbar_icon_openwith,
}, },
indent=2, indent=2,
) )
@ -373,18 +224,6 @@ def main() -> int:
manifest_parser.add_argument("--brands", nargs="+", required=True) manifest_parser.add_argument("--brands", nargs="+", required=True)
manifest_parser.set_defaults(func=cli_manifest) manifest_parser.set_defaults(func=cli_manifest)
local_parser = subparsers.add_parser("local-release-data")
local_parser.add_argument("--version", required=True)
local_parser.add_argument("--platform", choices=["windows", "macos"], required=True)
local_parser.add_argument("--brands", nargs="+")
local_parser.set_defaults(func=cli_local_release_data)
merge_parser = subparsers.add_parser("merge-manifests")
merge_parser.add_argument("--base", required=True)
merge_parser.add_argument("--overlay", required=True)
merge_parser.add_argument("--output", required=True)
merge_parser.set_defaults(func=cli_merge_manifests)
show_parser = subparsers.add_parser("show") show_parser = subparsers.add_parser("show")
show_parser.add_argument("--brand", required=True) show_parser.add_argument("--brand", required=True)
show_parser.set_defaults(func=cli_show) show_parser.set_defaults(func=cli_show)

View file

@ -77,18 +77,13 @@ fi
echo "📋 Using configuration: $ENV_FILE" echo "📋 Using configuration: $ENV_FILE"
if [ -z "$BRAND" ]; then if [ -n "$BRAND" ]; then
BRAND="webdrop_bridge" eval "$(python3 "$BRAND_HELPER" env --brand "$BRAND")"
fi APP_NAME="$WEBDROP_ASSET_PREFIX"
DMG_VOLUME_NAME="$WEBDROP_APP_DISPLAY_NAME"
eval "$(python3 "$BRAND_HELPER" env --brand "$BRAND")" BUNDLE_IDENTIFIER="$WEBDROP_BUNDLE_ID"
APP_NAME="$WEBDROP_ASSET_PREFIX" DIST_DIR="$BUILD_DIR/dist/macos/$WEBDROP_BRAND_ID"
DMG_VOLUME_NAME="$WEBDROP_APP_DISPLAY_NAME" TEMP_BUILD="$BUILD_DIR/temp/macos/$WEBDROP_BRAND_ID"
BUNDLE_IDENTIFIER="$WEBDROP_BUNDLE_ID"
DIST_DIR="$BUILD_DIR/dist/macos/$WEBDROP_BRAND_ID"
TEMP_BUILD="$BUILD_DIR/temp/macos/$WEBDROP_BRAND_ID"
if [ -n "$WEBDROP_APP_DISPLAY_NAME" ]; then
echo "🏷️ Building brand: $WEBDROP_APP_DISPLAY_NAME ($WEBDROP_BRAND_ID)" echo "🏷️ Building brand: $WEBDROP_APP_DISPLAY_NAME ($WEBDROP_BRAND_ID)"
fi fi
@ -200,25 +195,8 @@ build_executable() {
log_info "Building macOS executable with PyInstaller..." log_info "Building macOS executable with PyInstaller..."
echo "" echo ""
# Create bundled runtime .env with brand defaults so first launch
# uses brand-specific app name and config directory.
BUNDLED_ENV_FILE="$TEMP_BUILD/.env"
cp "$ENV_FILE" "$BUNDLED_ENV_FILE"
{
echo ""
echo "# Brand-specific defaults added during packaging"
echo "APP_NAME=\"$WEBDROP_APP_DISPLAY_NAME\""
echo "BRAND_ID=\"$WEBDROP_BRAND_ID\""
echo "APP_CONFIG_DIR_NAME=\"$WEBDROP_CONFIG_DIR_NAME\""
echo "UPDATE_CHANNEL=\"$WEBDROP_UPDATE_CHANNEL\""
echo "TOOLBAR_ICON_HOME=\"$WEBDROP_TOOLBAR_ICON_HOME\""
echo "TOOLBAR_ICON_RELOAD=\"$WEBDROP_TOOLBAR_ICON_RELOAD\""
echo "TOOLBAR_ICON_OPEN=\"$WEBDROP_TOOLBAR_ICON_OPEN\""
echo "TOOLBAR_ICON_OPENWITH=\"$WEBDROP_TOOLBAR_ICON_OPENWITH\""
} >> "$BUNDLED_ENV_FILE"
# Export env file for spec file to pick up # Export env file for spec file to pick up
export WEBDROP_ENV_FILE="$BUNDLED_ENV_FILE" export WEBDROP_ENV_FILE="$ENV_FILE"
export WEBDROP_VERSION="$VERSION" export WEBDROP_VERSION="$VERSION"
export WEBDROP_BUNDLE_ID="$BUNDLE_IDENTIFIER" export WEBDROP_BUNDLE_ID="$BUNDLE_IDENTIFIER"
@ -242,7 +220,7 @@ create_dmg() {
log_info "Creating DMG package..." log_info "Creating DMG package..."
echo "" echo ""
DMG_FILE="$DIST_DIR/${APP_NAME}-${VERSION}-macos-universal.dmg" DMG_FILE="$DIST_DIR/${APP_NAME}-${VERSION}.dmg"
# Remove existing DMG # Remove existing DMG
if [ -f "$DMG_FILE" ]; then if [ -f "$DMG_FILE" ]; then

View file

@ -38,8 +38,6 @@ import argparse
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from dotenv import dotenv_values
# Import shared version utilities # Import shared version utilities
from brand_config import load_brand_config 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
@ -97,48 +95,6 @@ class WindowsBuilder:
shutil.rmtree(path) shutil.rmtree(path)
print(f" Removed {path}") print(f" Removed {path}")
@staticmethod
def _format_env_value(value: str) -> str:
"""Format env values safely for .env files."""
if any(ch in value for ch in [" ", "#", '"', "'", "\t"]):
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
return value
def _create_bundled_env_file(self) -> Path:
"""Create a bundled .env file with brand-specific runtime defaults."""
values = dotenv_values(self.env_file)
overrides = {
"APP_NAME": self.brand.display_name,
"BRAND_ID": self.brand.brand_id,
"APP_CONFIG_DIR_NAME": self.brand.config_dir_name,
"UPDATE_CHANNEL": self.brand.update_channel,
"TOOLBAR_ICON_HOME": self.brand.toolbar_icon_home,
"TOOLBAR_ICON_RELOAD": self.brand.toolbar_icon_reload,
"TOOLBAR_ICON_OPEN": self.brand.toolbar_icon_open,
"TOOLBAR_ICON_OPENWITH": self.brand.toolbar_icon_openwith,
}
output_env = self.temp_dir / ".env"
output_env.parent.mkdir(parents=True, exist_ok=True)
lines: list[str] = []
for key, raw_value in values.items():
if key in overrides:
continue
if raw_value is None:
lines.append(key)
else:
lines.append(f"{key}={self._format_env_value(str(raw_value))}")
lines.append("")
lines.append("# Brand-specific defaults added during packaging")
for key, value in overrides.items():
lines.append(f"{key}={self._format_env_value(value)}")
output_env.write_text("\n".join(lines) + "\n", encoding="utf-8")
return output_env
def build_executable(self) -> bool: def build_executable(self) -> bool:
"""Build executable using PyInstaller.""" """Build executable using PyInstaller."""
print("\n🔨 Building Windows executable with PyInstaller...") print("\n🔨 Building Windows executable with PyInstaller...")
@ -163,7 +119,7 @@ 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._create_bundled_env_file()) env["WEBDROP_ENV_FILE"] = str(self.env_file)
env["WEBDROP_BRAND_ID"] = self.brand.brand_id env["WEBDROP_BRAND_ID"] = self.brand.brand_id
env["WEBDROP_APP_DISPLAY_NAME"] = self.brand.display_name env["WEBDROP_APP_DISPLAY_NAME"] = self.brand.display_name
env["WEBDROP_ASSET_PREFIX"] = self.brand.asset_prefix env["WEBDROP_ASSET_PREFIX"] = self.brand.asset_prefix

View file

@ -3,7 +3,7 @@ param(
[string]$Version, [string]$Version,
[Parameter(Mandatory = $false)] [Parameter(Mandatory = $false)]
[string[]]$Brands, [string[]]$Brands = @("agravity"),
[Parameter(Mandatory = $false)] [Parameter(Mandatory = $false)]
[string]$ForgejoUser, [string]$ForgejoUser,
@ -12,7 +12,6 @@ param(
[string]$ForgejoPW, [string]$ForgejoPW,
[switch]$ClearCredentials, [switch]$ClearCredentials,
[switch]$DryRun,
[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"
@ -27,32 +26,11 @@ if (-not (Test-Path $pythonExe)) {
$brandHelper = Join-Path $projectRoot "build\scripts\brand_config.py" $brandHelper = Join-Path $projectRoot "build\scripts\brand_config.py"
$manifestOutput = Join-Path $projectRoot "build\dist\release-manifest.json" $manifestOutput = Join-Path $projectRoot "build\dist\release-manifest.json"
$localManifestPath = Join-Path $projectRoot "build\dist\release-manifest.local.json"
$existingManifestPath = Join-Path $projectRoot "build\dist\release-manifest.existing.json"
function Get-CurrentVersion { 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() 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()
} }
function Get-LocalReleaseData {
$arguments = @($brandHelper, "local-release-data", "--platform", "windows", "--version", $Version)
if ($Brands) {
$arguments += "--brands"
$arguments += $Brands
}
return (& $pythonExe @arguments | ConvertFrom-Json)
}
function Get-AssetMap {
param([object[]]$Assets)
$map = @{}
foreach ($asset in ($Assets | Where-Object { $_ })) {
$map[$asset.name] = $asset
}
return $map
}
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
@ -60,44 +38,6 @@ if ($ClearCredentials) {
exit 0 exit 0
} }
if (-not $Version) {
$Version = Get-CurrentVersion
}
$localData = Get-LocalReleaseData
$artifactPaths = New-Object System.Collections.Generic.List[string]
foreach ($artifact in $localData.artifacts) {
$artifactPaths.Add([string]$artifact)
if ((Test-Path $artifact) -and ((Get-Item $artifact).Extension -eq ".msi")) {
$msiSize = (Get-Item $artifact).Length / 1MB
Write-Host "Windows artifact: $([System.IO.Path]::GetFileName($artifact)) ($([math]::Round($msiSize, 2)) MB)"
}
}
if ($artifactPaths.Count -eq 0) {
Write-Host "ERROR: No local Windows artifacts found" -ForegroundColor Red
exit 1
}
$localData.manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $localManifestPath -Encoding utf8
if ($DryRun) {
Copy-Item $localManifestPath $manifestOutput -Force
$brandsText = if ($localData.brands.Count -gt 0) { $localData.brands -join ", " } else { "<none>" }
Write-Host "[DRY RUN] No network requests or uploads will be performed." -ForegroundColor Yellow
Write-Host "[DRY RUN] Release tag: v$Version"
Write-Host "[DRY RUN] Release URL: $ForgejoUrl/$Repo/releases/tag/v$Version"
Write-Host "[DRY RUN] Discovered brands: $brandsText"
Write-Host "[DRY RUN] Artifacts that would be uploaded:"
foreach ($artifact in $artifactPaths) {
Write-Host " - $artifact"
}
Write-Host "[DRY RUN] Local manifest preview: $manifestOutput"
exit 0
}
if (-not $ForgejoUser) { if (-not $ForgejoUser) {
$ForgejoUser = $env:FORGEJO_USER $ForgejoUser = $env:FORGEJO_USER
} }
@ -118,6 +58,36 @@ if (-not $ForgejoUser -or -not $ForgejoPW) {
$env:FORGEJO_PASS = $ForgejoPW $env:FORGEJO_PASS = $ForgejoPW
} }
if (-not $Version) {
$Version = Get-CurrentVersion
}
$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"
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)"
}
}
& $pythonExe $brandHelper release-manifest --version $Version --output $manifestOutput --brands $Brands | Out-Null
if (Test-Path $manifestOutput) {
$artifactPaths.Add($manifestOutput)
}
if ($artifactPaths.Count -eq 0) {
Write-Host "ERROR: No Windows artifacts found for the requested brands" -ForegroundColor Red
exit 1
}
$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"
@ -135,46 +105,28 @@ $releaseData = @{
} | ConvertTo-Json } | ConvertTo-Json
try { try {
$releaseInfo = Invoke-RestMethod -Uri $releaseLookupUrl -Method GET -Headers $headers -TimeoutSec 30 -ErrorAction Stop $lookupResponse = Invoke-WebRequest -Uri $releaseLookupUrl -Method GET -Headers $headers -TimeoutSec 30 -UseBasicParsing -ErrorAction Stop
$releaseInfo = $lookupResponse.Content | ConvertFrom-Json
$releaseId = $releaseInfo.id $releaseId = $releaseInfo.id
Write-Host "[OK] Using existing release (ID: $releaseId)" -ForegroundColor Green Write-Host "[OK] Using existing release (ID: $releaseId)" -ForegroundColor Green
} }
catch { catch {
$releaseInfo = Invoke-RestMethod -Uri $releaseUrl -Method POST -Headers $headers -Body $releaseData -TimeoutSec 30 -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 $releaseId = $releaseInfo.id
Write-Host "[OK] Release created (ID: $releaseId)" -ForegroundColor Green Write-Host "[OK] Release created (ID: $releaseId)" -ForegroundColor Green
} }
$assetMap = Get-AssetMap -Assets $releaseInfo.assets
if ($assetMap.ContainsKey("release-manifest.json")) {
Invoke-WebRequest -Uri $assetMap["release-manifest.json"].browser_download_url -Method GET -Headers $headers -TimeoutSec 30 -OutFile $existingManifestPath | Out-Null
& $pythonExe $brandHelper merge-manifests --base $existingManifestPath --overlay $localManifestPath --output $manifestOutput | Out-Null
}
else {
Copy-Item $localManifestPath $manifestOutput -Force
}
$artifactPaths.Add($manifestOutput)
$assetMap = Get-AssetMap -Assets $releaseInfo.assets
$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) { foreach ($artifact in $artifactPaths) {
$assetName = [System.IO.Path]::GetFileName($artifact)
if ($assetMap.ContainsKey($assetName)) {
$existingAsset = $assetMap[$assetName]
Invoke-RestMethod -Uri "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets/$($existingAsset.id)" -Method DELETE -Headers $headers -TimeoutSec 30 | Out-Null
Write-Host "[OK] Replaced existing asset $assetName" -ForegroundColor Yellow
}
$response = curl.exe -s -X POST -u $curlAuth -F "attachment=@$artifact" $uploadUrl $response = curl.exe -s -X POST -u $curlAuth -F "attachment=@$artifact" $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 "WARNING: Could not upload $artifact : $response" -ForegroundColor Yellow
} }
else { else {
Write-Host "[OK] Uploaded $assetName" -ForegroundColor Green Write-Host "[OK] Uploaded $([System.IO.Path]::GetFileName($artifact))" -ForegroundColor Green
} }
} }

View file

@ -4,19 +4,15 @@
set -e set -e
VERSION="" VERSION=""
BRANDS=() BRANDS=("agravity")
FORGEJO_USER="${FORGEJO_USER}" FORGEJO_USER="${FORGEJO_USER}"
FORGEJO_PASS="${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"
CLEAR_CREDS=false CLEAR_CREDS=false
DRY_RUN=false
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BRAND_HELPER="$PROJECT_ROOT/build/scripts/brand_config.py" BRAND_HELPER="$PROJECT_ROOT/build/scripts/brand_config.py"
MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.json" MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.json"
LOCAL_MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.local.json"
EXISTING_MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.existing.json"
LOCAL_DATA_OUTPUT="$PROJECT_ROOT/build/dist/release-data.local.json"
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
@ -24,11 +20,14 @@ while [[ $# -gt 0 ]]; do
-u|--url) FORGEJO_URL="$2"; shift 2 ;; -u|--url) FORGEJO_URL="$2"; shift 2 ;;
--brand) BRANDS+=("$2"); shift 2 ;; --brand) BRANDS+=("$2"); shift 2 ;;
--clear-credentials) CLEAR_CREDS=true; shift ;; --clear-credentials) CLEAR_CREDS=true; shift ;;
--dry-run) DRY_RUN=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
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
@ -40,61 +39,6 @@ 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())")" 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 fi
LOCAL_ARGS=("$BRAND_HELPER" "local-release-data" "--platform" "macos" "--version" "$VERSION")
if [ ${#BRANDS[@]} -gt 0 ]; then
LOCAL_ARGS+=("--brands" "${BRANDS[@]}")
fi
python3 "${LOCAL_ARGS[@]}" > "$LOCAL_DATA_OUTPUT"
mapfile -t ARTIFACTS < <(python3 - "$LOCAL_DATA_OUTPUT" "$LOCAL_MANIFEST_OUTPUT" <<'PY'
import json
import sys
from pathlib import Path
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
Path(sys.argv[2]).write_text(json.dumps(data["manifest"], indent=2), encoding="utf-8")
for artifact in data["artifacts"]:
print(artifact)
PY
)
for ARTIFACT in "${ARTIFACTS[@]}"; do
if [ -f "$ARTIFACT" ] && [ "${ARTIFACT##*.}" = "dmg" ]; then
DMG_SIZE=$(du -m "$ARTIFACT" | cut -f1)
echo "macOS artifact: $(basename "$ARTIFACT") ($DMG_SIZE MB)"
fi
done
if [ ${#ARTIFACTS[@]} -eq 0 ]; then
echo "ERROR: No local macOS artifacts found"
exit 1
fi
if [ "$DRY_RUN" = true ]; then
cp "$LOCAL_MANIFEST_OUTPUT" "$MANIFEST_OUTPUT"
DISCOVERED_BRANDS=$(python3 - "$LOCAL_DATA_OUTPUT" <<'PY'
import json
import sys
from pathlib import Path
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
print(", ".join(data.get("brands", [])) or "<none>")
PY
)
echo "[DRY RUN] No network requests or uploads will be performed."
echo "[DRY RUN] Release tag: v$VERSION"
echo "[DRY RUN] Release URL: $FORGEJO_URL/$REPO/releases/tag/v$VERSION"
echo "[DRY RUN] Discovered brands: $DISCOVERED_BRANDS"
echo "[DRY RUN] Artifacts that would be uploaded:"
for ARTIFACT in "${ARTIFACTS[@]}"; do
echo " - $ARTIFACT"
done
echo "[DRY RUN] Local manifest preview: $MANIFEST_OUTPUT"
exit 0
fi
if [ -z "$FORGEJO_USER" ] || [ -z "$FORGEJO_PASS" ]; then if [ -z "$FORGEJO_USER" ] || [ -z "$FORGEJO_PASS" ]; then
echo "Forgejo credentials not found. Enter your credentials:" echo "Forgejo credentials not found. Enter your credentials:"
if [ -z "$FORGEJO_USER" ]; then if [ -z "$FORGEJO_USER" ]; then
@ -108,25 +52,36 @@ if [ -z "$FORGEJO_USER" ] || [ -z "$FORGEJO_PASS" ]; then
export FORGEJO_PASS export FORGEJO_PASS
fi 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
fi
BASIC_AUTH=$(echo -n "${FORGEJO_USER}:${FORGEJO_PASS}" | base64) BASIC_AUTH=$(echo -n "${FORGEJO_USER}:${FORGEJO_PASS}" | base64)
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" RELEASE_LOOKUP_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/tags/v$VERSION"
RELEASE_RESPONSE_FILE=$(mktemp) RESPONSE=$(curl -s -H "Authorization: Basic $BASIC_AUTH" "$RELEASE_LOOKUP_URL")
HTTP_CODE=$(curl -s -o "$RELEASE_RESPONSE_FILE" -w "%{http_code}" -H "Authorization: Basic $BASIC_AUTH" "$RELEASE_LOOKUP_URL") RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
if [ "$HTTP_CODE" = "200" ]; then
RELEASE_ID=$(python3 - "$RELEASE_RESPONSE_FILE" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
print(payload.get("id", ""))
PY
)
else
RELEASE_ID=""
fi
if [ -z "$RELEASE_ID" ]; then if [ -z "$RELEASE_ID" ]; then
RELEASE_DATA=$(cat <<EOF RELEASE_DATA=$(cat <<EOF
@ -139,76 +94,22 @@ if [ -z "$RELEASE_ID" ]; then
} }
EOF EOF
) )
HTTP_CODE=$(curl -s -o "$RELEASE_RESPONSE_FILE" -w "%{http_code}" -X POST \ RESPONSE=$(curl -s -X POST \
-H "Authorization: Basic $BASIC_AUTH" \ -H "Authorization: Basic $BASIC_AUTH" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$RELEASE_DATA" \ -d "$RELEASE_DATA" \
"$RELEASE_URL") "$RELEASE_URL")
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ]; then RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
RELEASE_ID=$(python3 - "$RELEASE_RESPONSE_FILE" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
print(payload.get("id", ""))
PY
)
fi
fi fi
if [ -z "$RELEASE_ID" ]; then if [ -z "$RELEASE_ID" ]; then
echo "ERROR creating or finding release" echo "ERROR creating or finding release"
cat "$RELEASE_RESPONSE_FILE" echo "$RESPONSE"
exit 1 exit 1
fi fi
MANIFEST_URL=$(python3 - "$RELEASE_RESPONSE_FILE" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
for asset in payload.get("assets", []):
if asset.get("name") == "release-manifest.json":
print(asset.get("browser_download_url", ""))
break
PY
)
if [ -n "$MANIFEST_URL" ]; then
curl -s -H "Authorization: Basic $BASIC_AUTH" "$MANIFEST_URL" -o "$EXISTING_MANIFEST_OUTPUT"
python3 "$BRAND_HELPER" merge-manifests --base "$EXISTING_MANIFEST_OUTPUT" --overlay "$LOCAL_MANIFEST_OUTPUT" --output "$MANIFEST_OUTPUT" >/dev/null
else
cp "$LOCAL_MANIFEST_OUTPUT" "$MANIFEST_OUTPUT"
fi
ARTIFACTS+=("$MANIFEST_OUTPUT")
UPLOAD_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets" UPLOAD_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets"
for ARTIFACT in "${ARTIFACTS[@]}"; do for ARTIFACT in "${ARTIFACTS[@]}"; do
ASSET_NAME="$(basename "$ARTIFACT")"
EXISTING_ASSET_ID=$(python3 - "$RELEASE_RESPONSE_FILE" "$ASSET_NAME" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
asset_name = sys.argv[2]
for asset in payload.get("assets", []):
if asset.get("name") == asset_name:
print(asset.get("id", ""))
break
PY
)
if [ -n "$EXISTING_ASSET_ID" ]; then
curl -s -X DELETE \
-H "Authorization: Basic $BASIC_AUTH" \
"$FORGEJO_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets/$EXISTING_ASSET_ID" >/dev/null
echo "[OK] Replaced existing asset $ASSET_NAME"
fi
HTTP_CODE=$(curl -s -w "%{http_code}" -X POST \ HTTP_CODE=$(curl -s -w "%{http_code}" -X POST \
-H "Authorization: Basic $BASIC_AUTH" \ -H "Authorization: Basic $BASIC_AUTH" \
-F "attachment=@$ARTIFACT" \ -F "attachment=@$ARTIFACT" \
@ -216,9 +117,9 @@ PY
-o /tmp/curl_response.txt) -o /tmp/curl_response.txt)
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then
echo "[OK] Uploaded $ASSET_NAME" echo "[OK] Uploaded $(basename "$ARTIFACT")"
else else
echo "ERROR uploading $ASSET_NAME (HTTP $HTTP_CODE)" echo "ERROR uploading $(basename "$ARTIFACT") (HTTP $HTTP_CODE)"
cat /tmp/curl_response.txt cat /tmp/curl_response.txt
exit 1 exit 1
fi fi

View file

@ -1,488 +0,0 @@
# Branding, Builds, and Releases
This document describes how branded builds work in this repository, how to add or edit a brand, how to build the default and branded variants, and how to publish releases.
## Overview
The project supports one default product and any number of branded variants from the same codebase.
- The default product is defined by built-in defaults in `build/scripts/brand_config.py`.
- The default product identifier is `webdrop_bridge`.
- Additional brands are defined by JSON manifests in `build/brands/`.
- Runtime behavior can also be branded through application config values such as `brand_id`, `config_dir_name`, `app_name`, and update settings.
- Windows and macOS installers are built as separate artifacts per brand.
- Releases are shared by version. A single Forgejo release can contain installers for the default product and multiple brands.
## Branding Model
There are two layers to branding:
1. Packaging identity
Controls installer name, executable/app bundle name, product display name, bundle identifier, MSI upgrade code, installer artwork, and related metadata.
2. Runtime configuration
Controls app name shown in the UI, config directory name, update feed settings, URL mappings, allowed roots, and similar application behavior.
Packaging identity lives in `build/brands/<brand>.json`.
Runtime configuration lives in app config files loaded by the application. See `config.example.json` for the current branded example.
## Important Files
- `build/scripts/brand_config.py`: central helper for brand metadata, artifact naming, and release manifest generation
- `build/brands/agravity.json`: example branded manifest
- `build/scripts/build_windows.py`: Windows build entrypoint
- `build/scripts/build_macos.sh`: macOS build entrypoint
- `build/scripts/create_release.ps1`: Windows release uploader
- `build/scripts/create_release.sh`: macOS release uploader
- `config.example.json`: example runtime branding config
## Create a New Brand
To create a new brand, add a new manifest file under `build/brands/`.
Example:
1. Copy `build/brands/template.jsonc` to `build/brands/<new-brand>.json`.
2. Update the values for the new brand.
3. Add any brand-specific assets if you do not want to reuse the default icons/license assets.
Minimal example:
```json
{
"brand_id": "customerx",
"display_name": "Customer X Bridge",
"asset_prefix": "CustomerXBridge",
"exe_name": "CustomerXBridge",
"manufacturer": "Customer X",
"install_dir_name": "Customer X Bridge",
"shortcut_description": "Customer X drag-and-drop bridge",
"bundle_identifier": "com.customerx.bridge",
"config_dir_name": "customerx_bridge",
"msi_upgrade_code": "PUT-A-NEW-GUID-HERE",
"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"
}
```
### Required Fields
- `brand_id`: internal identifier used for build output folders and release manifest entries
- `display_name`: user-facing product name
- `asset_prefix`: base name for installer artifacts and app bundle name
- `exe_name`: executable name for Windows and app bundle name base for macOS
- `manufacturer`: MSI manufacturer string
- `install_dir_name`: installation directory name shown to the OS
- `shortcut_description`: Windows shortcut description
- `bundle_identifier`: macOS bundle identifier
- `config_dir_name`: local app config/log/cache directory name
- `msi_upgrade_code`: stable GUID for Windows upgrades
- `update_channel`: currently typically `stable`
Generate a new `msi_upgrade_code` for a new brand once and keep it stable afterwards.
Examples:
```powershell
New-Guid
```
```bash
uuidgen
```
### Asset Fields
These can point at brand-specific files or default shared files:
- `icon_ico`
- `icon_icns`
- `dialog_bmp`
- `banner_bmp`
- `license_rtf`
Optional toolbar icon overrides:
- `toolbar_icon_home`
- `toolbar_icon_reload`
- `toolbar_icon_open`
- `toolbar_icon_openwith`
If a referenced asset path does not exist, the helper falls back to the default asset defined in `build/scripts/brand_config.py`.
For toolbar icons, the runtime looks for the configured paths in packaged and development layouts. If an icon is missing:
- Home falls back to a standard Qt home icon
- Reload/Open/OpenWith keep their existing icon behavior
### Identity Rules
Treat these values as long-lived product identity once a brand has shipped:
- `brand_id`
- `asset_prefix`
- `exe_name`
- `bundle_identifier`
- `config_dir_name`
- `msi_upgrade_code`
Changing them later can break one or more of the following:
- Windows upgrade behavior
- macOS app identity
- auto-update asset selection
- local config/log/cache continuity
- installer and artifact naming consistency
If the product is already in use, only change these values deliberately and with migration planning.
## Edit an Existing Brand
To edit a shipped or in-progress brand:
1. Update the brand manifest in `build/brands/<brand>.json`.
2. If needed, update brand-specific assets referenced by that manifest.
3. If runtime behavior should also change, update the relevant application config values.
4. Rebuild the affected platform artifacts.
5. Validate the result with a dry-run release before publishing.
Safe edits after release usually include:
- `display_name`
- `shortcut_description`
- artwork paths
- license text
- update channel, if release policy changes
High-risk edits after release are the identity fields listed above.
## Runtime Branding Configuration
Packaging branding alone is not enough if the app should also present a different name, use different local storage, or point to different update settings.
Relevant runtime config keys include:
- `brand_id`
- `config_dir_name`
- `app_name`
- `update_base_url`
- `update_repo`
- `update_channel`
- `update_manifest_name`
Toolbar icon env overrides (useful for packaged branding):
- `TOOLBAR_ICON_HOME`
- `TOOLBAR_ICON_RELOAD`
- `TOOLBAR_ICON_OPEN`
- `TOOLBAR_ICON_OPENWITH`
The current example in `config.example.json` shows the Agravity runtime setup.
When adding a new brand, make sure the runtime config matches the packaging manifest at least for:
- `brand_id`
- `config_dir_name`
- `app_name`
## Build the Default Product
### Windows
Build the default executable only:
```powershell
python .\build\scripts\build_windows.py
```
Build the default Windows MSI:
```powershell
python .\build\scripts\build_windows.py --msi
```
Build with a specific `.env` file:
```powershell
python .\build\scripts\build_windows.py --msi --env-file .\.env
```
### macOS
Build the default macOS app and DMG:
```bash
bash build/scripts/build_macos.sh
```
Build with a specific `.env` file:
```bash
bash build/scripts/build_macos.sh --env-file .env
```
## Build a Brand
### Windows
Build a branded executable only:
```powershell
python .\build\scripts\build_windows.py --brand agravity
```
Build a branded MSI:
```powershell
python .\build\scripts\build_windows.py --brand agravity --msi
```
### macOS
Build a branded macOS app and DMG:
```bash
bash build/scripts/build_macos.sh --brand agravity
```
## Build Output Locations
Windows artifacts are written to:
- `build/dist/windows/webdrop_bridge/` for the default product
- `build/dist/windows/<brand_id>/` for branded products
macOS artifacts are written to:
- `build/dist/macos/webdrop_bridge/` for the default product
- `build/dist/macos/<brand_id>/` for branded products
Typical artifact names:
- Windows MSI: `<asset_prefix>-<version>-win-x64.msi`
- Windows checksum: `<asset_prefix>-<version>-win-x64.msi.sha256`
- macOS DMG: `<asset_prefix>-<version>-macos-universal.dmg`
- macOS checksum: `<asset_prefix>-<version>-macos-universal.dmg.sha256`
## Create a Release
Releases are shared by version. The release scripts scan local build outputs on the current machine and upload every artifact they find for that platform.
This means:
- a Windows machine can upload all locally built MSIs for the current version
- a macOS machine can later upload all locally built DMGs for the same version
- both runs contribute to the same Forgejo release tag
- `release-manifest.json` is merged so later runs do not wipe earlier platform entries
### Windows Release
Dry run first:
```powershell
.\build\scripts\create_release.ps1 -DryRun
```
Publish all locally built Windows variants for the current version:
```powershell
.\build\scripts\create_release.ps1
```
Publish only selected brands:
```powershell
.\build\scripts\create_release.ps1 -Brands agravity
```
Publish only the default product:
```powershell
.\build\scripts\create_release.ps1 -Brands webdrop_bridge
```
Publish a specific version:
```powershell
.\build\scripts\create_release.ps1 -Version 0.8.4
```
### macOS Release
Dry run first:
```bash
bash build/scripts/create_release.sh --dry-run
```
Publish all locally built macOS variants for the current version:
```bash
bash build/scripts/create_release.sh
```
Publish only selected brands:
```bash
bash build/scripts/create_release.sh --brand agravity
```
Publish only the default product:
```bash
bash build/scripts/create_release.sh --brand webdrop_bridge
```
Publish a specific version:
```bash
bash build/scripts/create_release.sh --version 0.8.4
```
### Credentials
Both release scripts use Forgejo credentials from environment variables when available:
- `FORGEJO_USER`
- `FORGEJO_PASS`
If they are not set and you are not in dry-run mode, the script prompts for them.
Both scripts also support clearing credentials from the current shell session:
- Windows: `-ClearCredentials`
- macOS: `--clear-credentials`
## Dry Run Behavior
Dry-run mode is the preferred validation step before publishing.
Dry-run mode:
- discovers the local artifacts exactly like a real release run
- prints the release tag and target release URL
- prints the brands that were discovered locally
- prints the artifact paths that would be uploaded
- writes a local manifest preview to `build/dist/release-manifest.json`
- does not prompt for credentials
- does not perform network requests
- does not delete or upload assets
## Release Manifest
The release scripts generate and upload `release-manifest.json`.
This file is used by the updater to select the correct installer and checksum for a given brand and platform.
Current platform keys are:
- `windows-x64`
- `macos-universal`
The manifest is built from local artifacts and merged with any existing manifest already attached to the release.
## First Manual Download (Before Auto-Update)
After creating a release, a user can manually download the first installer directly from Forgejo. Once installed, auto-update handles later versions.
Base repository URL:
- `https://git.him-tools.de/HIM-public/webdrop-bridge`
Release page pattern:
- `https://git.him-tools.de/HIM-public/webdrop-bridge/releases/tag/v<version>`
Direct asset download pattern:
- `https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v<version>/<asset-file-name>`
Example asset names:
- `WebDropBridge-0.8.4-win-x64.msi`
- `WebDropBridge-0.8.4-macos-universal.dmg`
- `AgravityBridge-0.8.4-win-x64.msi`
- `AgravityBridge-0.8.4-macos-universal.dmg`
### wget Examples
```bash
# Default Windows installer
wget "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-win-x64.msi"
# Agravity macOS installer
wget "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/AgravityBridge-0.8.4-macos-universal.dmg"
```
### curl Examples
```bash
# Default macOS installer
curl -L -o WebDropBridge-0.8.4-macos-universal.dmg \
"https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-macos-universal.dmg"
# Agravity Windows installer
curl -L -o AgravityBridge-0.8.4-win-x64.msi \
"https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/AgravityBridge-0.8.4-win-x64.msi"
```
### PowerShell Example
```powershell
Invoke-WebRequest `
-Uri "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-win-x64.msi" `
-OutFile "WebDropBridge-0.8.4-win-x64.msi"
```
You can inspect `release-manifest.json` on the release to see the exact file names for each brand and platform.
## Recommended Workflow for a New Brand
1. Create `build/brands/<brand>.json`.
2. Add or update brand-specific assets if needed.
3. Prepare matching runtime config values.
4. Build the brand on Windows and/or macOS.
5. Run the release script in dry-run mode.
6. Verify artifact names, discovered brands, and manifest contents.
7. Run the actual release script.
8. Validate update behavior against the shared release.
## Troubleshooting Notes
### Brand not discovered by release script
Check that:
- the build completed successfully
- the artifact is under the expected platform folder
- the artifact name matches the `asset_prefix` and current version
- the version used by the release script matches the built artifact version
### Windows upgrade behavior is wrong
Check that the brand has its own stable `msi_upgrade_code`. Reusing or changing it incorrectly will break expected MSI upgrade semantics.
### App uses the wrong local config folder
Check that runtime config uses the intended `config_dir_name`, and that it matches the packaging brand you expect.
### Auto-update downloads the wrong installer
Check that:
- the release contains the correct installer files
- `release-manifest.json` includes the correct brand and platform entry
- runtime update settings point to the expected repo/channel/manifest
## Current Example Brand
The first branded variant currently in the repository is:
- `build/brands/agravity.json`
Use it as the template for future branded variants.

View file

@ -1,6 +1,6 @@
"""WebDrop Bridge - Qt-based desktop application for intelligent drag-and-drop file handling.""" """WebDrop Bridge - Qt-based desktop application for intelligent drag-and-drop file handling."""
__version__ = "0.8.4" __version__ = "0.8.3"
__author__ = "WebDrop Team" __author__ = "WebDrop Team"
__license__ = "MIT" __license__ = "MIT"

View file

@ -408,15 +408,7 @@ class Config:
candidate_paths.append(Path(env_file).resolve()) candidate_paths.append(Path(env_file).resolve())
else: else:
if getattr(sys, "frozen", False): if getattr(sys, "frozen", False):
exe_dir = Path(sys.executable).resolve().parent candidate_paths.append(Path(sys.executable).resolve().parent / ".env")
# One-folder fallback: some packagers place data files in _internal.
candidate_paths.append(exe_dir / ".env")
candidate_paths.append(exe_dir / "_internal" / ".env")
# PyInstaller runtime extraction directory (one-file and one-folder).
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
candidate_paths.append(Path(meipass).resolve() / ".env")
candidate_paths.append(Path.cwd() / ".env") candidate_paths.append(Path.cwd() / ".env")
candidate_paths.append(Path(__file__).resolve().parents[2] / ".env") candidate_paths.append(Path(__file__).resolve().parents[2] / ".env")

View file

@ -3,7 +3,6 @@
import asyncio import asyncio
import json import json
import logging import logging
import os
import re import re
import subprocess import subprocess
import sys import sys
@ -1387,13 +1386,16 @@ class MainWindow(QMainWindow):
# Separator # Separator
toolbar.addSeparator() toolbar.addSeparator()
if hasattr(sys, "_MEIPASS"):
icons_dir = Path(sys._MEIPASS) / "resources" / "icons" # type: ignore[attr-defined]
else:
icons_dir = Path(__file__).parent.parent.parent.parent / "resources" / "icons"
# Home button # Home button
home_icon_path = self._resolve_toolbar_icon_path( home_icon_path = icons_dir / "home.ico"
os.getenv("TOOLBAR_ICON_HOME", "resources/icons/home.ico")
)
home_icon = ( home_icon = (
QIcon(str(home_icon_path)) QIcon(str(home_icon_path))
if home_icon_path is not None if home_icon_path.exists()
else self.style().standardIcon(self.style().StandardPixmap.SP_DirHomeIcon) else self.style().standardIcon(self.style().StandardPixmap.SP_DirHomeIcon)
) )
home_action = toolbar.addAction(home_icon, "") home_action = toolbar.addAction(home_icon, "")
@ -1402,19 +1404,15 @@ class MainWindow(QMainWindow):
# Refresh button # Refresh button
refresh_action = self.web_view.pageAction(self.web_view.page().WebAction.Reload) refresh_action = self.web_view.pageAction(self.web_view.page().WebAction.Reload)
reload_icon_path = self._resolve_toolbar_icon_path( reload_icon_path = icons_dir / "reload.ico"
os.getenv("TOOLBAR_ICON_RELOAD", "resources/icons/reload.ico") if reload_icon_path.exists():
)
if reload_icon_path is not None:
refresh_action.setIcon(QIcon(str(reload_icon_path))) refresh_action.setIcon(QIcon(str(reload_icon_path)))
toolbar.addAction(refresh_action) toolbar.addAction(refresh_action)
# Open-with-default-app drop zone (right of Reload) # Open-with-default-app drop zone (right of Reload)
self._open_drop_zone = OpenDropZone() self._open_drop_zone = OpenDropZone()
open_icon_path = self._resolve_toolbar_icon_path( open_icon_path = icons_dir / "open.ico"
os.getenv("TOOLBAR_ICON_OPEN", "resources/icons/open.ico") if open_icon_path.exists():
)
if open_icon_path is not None:
self._open_drop_zone.set_icon(QIcon(str(open_icon_path))) self._open_drop_zone.set_icon(QIcon(str(open_icon_path)))
self._open_drop_zone.file_opened.connect(self._on_file_opened_via_drop) self._open_drop_zone.file_opened.connect(self._on_file_opened_via_drop)
self._open_drop_zone.file_open_failed.connect(self._on_file_open_failed_via_drop) self._open_drop_zone.file_open_failed.connect(self._on_file_open_failed_via_drop)
@ -1424,10 +1422,8 @@ class MainWindow(QMainWindow):
# Open-with chooser drop zone (right of Open-with-default-app) # Open-with chooser drop zone (right of Open-with-default-app)
self._open_with_drop_zone = OpenWithDropZone() self._open_with_drop_zone = OpenWithDropZone()
open_with_icon_path = self._resolve_toolbar_icon_path( open_with_icon_path = icons_dir / "openwith.ico"
os.getenv("TOOLBAR_ICON_OPENWITH", "resources/icons/openwith.ico") if open_with_icon_path.exists():
)
if open_with_icon_path is not None:
self._open_with_drop_zone.set_icon(QIcon(str(open_with_icon_path))) self._open_with_drop_zone.set_icon(QIcon(str(open_with_icon_path)))
self._open_with_drop_zone.file_open_with_requested.connect( self._open_with_drop_zone.file_open_with_requested.connect(
self._on_file_open_with_requested self._on_file_open_with_requested
@ -1471,32 +1467,6 @@ class MainWindow(QMainWindow):
dev_tools_action.setToolTip(tr("toolbar.tooltip.dev_tools")) dev_tools_action.setToolTip(tr("toolbar.tooltip.dev_tools"))
dev_tools_action.triggered.connect(self._open_developer_tools) dev_tools_action.triggered.connect(self._open_developer_tools)
def _resolve_toolbar_icon_path(self, configured_path: str) -> Path | None:
"""Resolve configured toolbar icon path in both dev and packaged layouts."""
icon_path = Path(configured_path)
candidates: list[Path] = []
if icon_path.is_absolute():
candidates.append(icon_path)
else:
if hasattr(sys, "_MEIPASS"):
meipass = Path(sys._MEIPASS) # type: ignore[attr-defined]
candidates.append(meipass / icon_path)
exe_dir = Path(sys.executable).resolve().parent
candidates.append(exe_dir / icon_path)
candidates.append(exe_dir / "_internal" / icon_path)
project_root = Path(__file__).parent.parent.parent.parent
candidates.append(project_root / icon_path)
for candidate in candidates:
if candidate.exists():
return candidate
logger.warning(f"Toolbar icon not found for configured path: {configured_path}")
return None
def _open_log_file(self) -> None: def _open_log_file(self) -> None:
"""Open the application log file in the system default text editor. """Open the application log file in the system default text editor.

View file

@ -8,13 +8,7 @@ BUILD_SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "build" / "scripts"
if str(BUILD_SCRIPTS_DIR) not in sys.path: if str(BUILD_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(BUILD_SCRIPTS_DIR)) sys.path.insert(0, str(BUILD_SCRIPTS_DIR))
from brand_config import ( from brand_config import generate_release_manifest, load_brand_config
DEFAULT_BRAND_ID,
collect_local_release_data,
generate_release_manifest,
load_brand_config,
merge_release_manifests,
)
def test_load_agravity_brand_config(): def test_load_agravity_brand_config():
@ -25,10 +19,6 @@ def test_load_agravity_brand_config():
assert brand.display_name == "Agravity Bridge" assert brand.display_name == "Agravity Bridge"
assert brand.asset_prefix == "AgravityBridge" assert brand.asset_prefix == "AgravityBridge"
assert brand.exe_name == "AgravityBridge" assert brand.exe_name == "AgravityBridge"
assert brand.toolbar_icon_home == "resources/icons/home.ico"
assert brand.toolbar_icon_reload == "resources/icons/reload.ico"
assert brand.toolbar_icon_open == "resources/icons/open.ico"
assert brand.toolbar_icon_openwith == "resources/icons/openwith.ico"
assert brand.windows_installer_name("0.8.4") == "AgravityBridge-0.8.4-win-x64.msi" assert brand.windows_installer_name("0.8.4") == "AgravityBridge-0.8.4-win-x64.msi"
@ -85,63 +75,3 @@ def test_generate_release_manifest_for_agravity(tmp_path):
manifest["brands"]["agravity"]["macos-universal"]["installer"] manifest["brands"]["agravity"]["macos-universal"]["installer"]
== "AgravityBridge-0.8.4-macos-universal.dmg" == "AgravityBridge-0.8.4-macos-universal.dmg"
) )
def test_collect_local_release_data_includes_default_brand(tmp_path):
"""Test discovering local artifacts for the default Windows build."""
project_root = tmp_path
installer_dir = project_root / "build" / "dist" / "windows" / DEFAULT_BRAND_ID
installer_dir.mkdir(parents=True)
installer = installer_dir / "WebDropBridge-0.8.4-win-x64.msi"
installer.write_bytes(b"msi")
checksum = installer_dir / f"{installer.name}.sha256"
checksum.write_text("abc", encoding="utf-8")
data = collect_local_release_data("0.8.4", platform="windows", root=project_root)
assert data["brands"] == [DEFAULT_BRAND_ID]
assert str(installer) in data["artifacts"]
assert str(checksum) in data["artifacts"]
assert (
data["manifest"]["brands"][DEFAULT_BRAND_ID]["windows-x64"]["installer"] == installer.name
)
def test_merge_release_manifests_preserves_existing_platforms():
"""Test merging platform-specific manifest entries from separate upload runs."""
base_manifest = {
"version": "0.8.4",
"channel": "stable",
"brands": {
"agravity": {
"windows-x64": {
"installer": "AgravityBridge-0.8.4-win-x64.msi",
"checksum": "AgravityBridge-0.8.4-win-x64.msi.sha256",
}
}
},
}
overlay_manifest = {
"version": "0.8.4",
"channel": "stable",
"brands": {
"agravity": {
"macos-universal": {
"installer": "AgravityBridge-0.8.4-macos-universal.dmg",
"checksum": "AgravityBridge-0.8.4-macos-universal.dmg.sha256",
}
}
},
}
merged = merge_release_manifests(base_manifest, overlay_manifest)
assert (
merged["brands"]["agravity"]["windows-x64"]["installer"]
== "AgravityBridge-0.8.4-win-x64.msi"
)
assert (
merged["brands"]["agravity"]["macos-universal"]["installer"]
== "AgravityBridge-0.8.4-macos-universal.dmg"
)

View file

@ -1,7 +1,6 @@
"""Unit tests for configuration system.""" """Unit tests for configuration system."""
import os import os
import sys
import pytest import pytest
@ -233,22 +232,3 @@ class TestConfigValidation:
assert config_path.parts[-2:] == ("agravity_bridge", "config.json") assert config_path.parts[-2:] == ("agravity_bridge", "config.json")
assert log_path.parts[-2:] == ("logs", "agravity_bridge.log") assert log_path.parts[-2:] == ("logs", "agravity_bridge.log")
class TestBootstrapEnvLoading:
"""Test bootstrap .env loading behavior for packaged builds."""
def test_load_bootstrap_env_reads_meipass_dotenv(self, tmp_path, monkeypatch):
"""Packaged app should load .env from PyInstaller runtime directory."""
meipass_dir = tmp_path / "runtime"
meipass_dir.mkdir(parents=True)
env_path = meipass_dir / ".env"
env_path.write_text("APP_NAME=Agravity Bridge\n", encoding="utf-8")
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "_MEIPASS", str(meipass_dir), raising=False)
loaded_path = Config.load_bootstrap_env()
assert loaded_path == env_path
assert os.getenv("APP_NAME") == "Agravity Bridge"