diff --git a/build/scripts/README.md b/build/scripts/README.md index 792600b..c4fba4c 100644 --- a/build/scripts/README.md +++ b/build/scripts/README.md @@ -10,11 +10,36 @@ Automation scripts for building, releasing, and downloading WebDrop Bridge. | `download_release.sh` | Download installer from Forgejo via wget | macOS/Linux | | `build_windows.py` | Build Windows MSI installer | Windows | | `build_macos.sh` | Build macOS DMG installer | macOS | +| `generate_icons.py` | Generate `.ico` + `.icns` from one PNG | All (macOS required for `.icns`) | | `create_release.ps1` | Create GitHub/Forgejo release | Windows | | `create_release.sh` | Create GitHub/Forgejo release | macOS/Linux | | `sync_remotes.ps1` | Sync git remotes | Windows | | `sync_version.py` | Manage version synchronization | All | +## Icon Generation + +Use one master icon PNG and generate both platform formats: + +```bash +python build/scripts/generate_icons.py +``` + +Defaults: +- Source PNG: `resources/icons/app.png` +- Windows icon: `resources/icons/app.ico` +- macOS icon: `resources/icons/app.icns` + +Generate only one format: + +```bash +python build/scripts/generate_icons.py --only ico +python build/scripts/generate_icons.py --only icns +``` + +Requirements: +- `Pillow` for `.ico` generation (`pip install -r requirements-dev.txt`) +- macOS `sips` + `iconutil` for `.icns` + ## Download Scripts ### Purpose diff --git a/build/scripts/build_macos.sh b/build/scripts/build_macos.sh index e2044a5..ca0b41a 100644 --- a/build/scripts/build_macos.sh +++ b/build/scripts/build_macos.sh @@ -274,39 +274,50 @@ create_dmg() { else log_warning "create-dmg not found, using hdiutil (less stylish)" log_info "For professional DMG: brew install create-dmg" - - # Create temporary DMG directory structure - DMG_TEMP="$TEMP_BUILD/dmg_contents" - mkdir -p "$DMG_TEMP" - - # Copy app bundle - cp -r "$DIST_DIR/$APP_NAME.app" "$DMG_TEMP/" - - # Create symlink to Applications folder - ln -s /Applications "$DMG_TEMP/Applications" - # Build a small, HFS+ staging image first and then convert to - # compressed UDZO. This avoids oversized APFS container images. + # Build a writable HFS+ staging image, mount it, and copy the + # app bundle with ditto so symlinks are preserved exactly. STAGING_DMG="$TEMP_BUILD/${APP_NAME}-staging.dmg" + STAGING_MOUNT="$TEMP_BUILD/dmg_mount" + STAGING_EMPTY="$TEMP_BUILD/dmg_empty" rm -f "$STAGING_DMG" + rm -rf "$STAGING_MOUNT" + rm -rf "$STAGING_EMPTY" + mkdir -p "$STAGING_MOUNT" + mkdir -p "$STAGING_EMPTY" + + APP_SIZE_KB=$(du -sk "$DIST_DIR/$APP_NAME.app" | awk '{print $1}') + DMG_SIZE_KB=$((APP_SIZE_KB + 512000)) hdiutil create \ - -volname "$DMG_VOLUME_NAME" \ - -srcfolder "$DMG_TEMP" \ + -size "${DMG_SIZE_KB}k" \ -fs HFS+ \ - -fsargs "-c c=64,a=16,e=16" \ + -volname "$DMG_VOLUME_NAME" \ -format UDRW \ + -srcfolder "$STAGING_EMPTY" \ -ov \ "$STAGING_DMG" + hdiutil attach "$STAGING_DMG" \ + -mountpoint "$STAGING_MOUNT" \ + -nobrowse \ + -noverify \ + -noautoopen + + ditto "$DIST_DIR/$APP_NAME.app" "$STAGING_MOUNT/$APP_NAME.app" + ln -s /Applications "$STAGING_MOUNT/Applications" + + hdiutil detach "$STAGING_MOUNT" + hdiutil convert "$STAGING_DMG" \ -format UDZO \ -imagekey zlib-level=9 \ -o "$DMG_FILE" - + # Clean up rm -f "$STAGING_DMG" - rm -rf "$DMG_TEMP" + rm -rf "$STAGING_MOUNT" + rm -rf "$STAGING_EMPTY" fi if [ ! -f "$DMG_FILE" ]; then diff --git a/build/scripts/generate_icons.py b/build/scripts/generate_icons.py new file mode 100644 index 0000000..6711ea4 --- /dev/null +++ b/build/scripts/generate_icons.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Generate application icons for Windows (.ico) and macOS (.icns). + +This script creates both platform icon formats from a single master PNG so +branding stays visually consistent across installers. + +Requirements: +- Pillow (for .ico generation) +- macOS tools `sips` and `iconutil` (for .icns generation) +""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +ICONSET_ENTRIES: list[tuple[str, int]] = [ + ("icon_16x16.png", 16), + ("icon_16x16@2x.png", 32), + ("icon_32x32.png", 32), + ("icon_32x32@2x.png", 64), + ("icon_128x128.png", 128), + ("icon_128x128@2x.png", 256), + ("icon_256x256.png", 256), + ("icon_256x256@2x.png", 512), + ("icon_512x512.png", 512), + ("icon_512x512@2x.png", 1024), +] + +ICO_SIZES = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)] + + +def _run(cmd: list[str]) -> None: + subprocess.run(cmd, check=True) + + +def _require_tool(name: str) -> None: + if shutil.which(name): + return + raise RuntimeError( + f"Required tool '{name}' not found in PATH. " + f"Install it and retry." + ) + + +def generate_icns(source_png: Path, target_icns: Path) -> None: + """Generate macOS .icns using iconutil and sips.""" + _require_tool("sips") + _require_tool("iconutil") + + target_icns.parent.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory(prefix="webdrop_iconset_") as tmp: + iconset_dir = Path(tmp) / "app.iconset" + iconset_dir.mkdir(parents=True, exist_ok=True) + + for filename, size in ICONSET_ENTRIES: + out_file = iconset_dir / filename + _run( + [ + "sips", + "-z", + str(size), + str(size), + str(source_png), + "--out", + str(out_file), + ] + ) + + _run(["iconutil", "-c", "icns", str(iconset_dir), "-o", str(target_icns)]) + + +def generate_ico(source_png: Path, target_ico: Path) -> None: + """Generate Windows .ico using Pillow.""" + try: + from PIL import Image + except ImportError as exc: + raise RuntimeError( + "Pillow is required for .ico generation. " + "Install it with: pip install Pillow" + ) from exc + + target_ico.parent.mkdir(parents=True, exist_ok=True) + + with Image.open(source_png) as img: + rgba = img.convert("RGBA") + rgba.save(target_ico, format="ICO", sizes=ICO_SIZES) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Generate .ico and .icns icons from a single PNG source" + ) + parser.add_argument( + "--source", + default="resources/icons/app.png", + help="Master source PNG (default: resources/icons/app.png)", + ) + parser.add_argument( + "--icon-ico", + default="resources/icons/app.ico", + help="Target .ico path (default: resources/icons/app.ico)", + ) + parser.add_argument( + "--icon-icns", + default="resources/icons/app.icns", + help="Target .icns path (default: resources/icons/app.icns)", + ) + parser.add_argument( + "--only", + choices=["all", "ico", "icns"], + default="all", + help="Generate all icons or only one format", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + source = Path(args.source).resolve() + icon_ico = Path(args.icon_ico).resolve() + icon_icns = Path(args.icon_icns).resolve() + + if not source.exists(): + print(f"ERROR: Source icon not found: {source}") + return 1 + + try: + if args.only in {"all", "ico"}: + generate_ico(source, icon_ico) + print(f"OK: Generated ICO: {icon_ico}") + + if args.only in {"all", "icns"}: + generate_icns(source, icon_icns) + print(f"OK: Generated ICNS: {icon_icns}") + + return 0 + except (subprocess.CalledProcessError, RuntimeError) as exc: + print(f"ERROR: {exc}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/requirements-dev.txt b/requirements-dev.txt index c48f0c3..ef0ab7e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -15,6 +15,7 @@ isort>=5.12.0 # Building pyinstaller>=6.0.0 pefile>=2023.2.7 +Pillow>=10.0.0 # Documentation sphinx>=7.0.0 diff --git a/resources/icons/app.icns b/resources/icons/app.icns new file mode 100644 index 0000000..c4cd67d Binary files /dev/null and b/resources/icons/app.icns differ diff --git a/resources/icons/app.ico b/resources/icons/app.ico index 9998838..7c568ff 100644 Binary files a/resources/icons/app.ico and b/resources/icons/app.ico differ