#!/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())