#!/usr/bin/env python3 """ Generate icons/app.ico (Windows) and icons/app.icns (macOS) from the rounded PNG source files in icons/twin-gears/rounded/. Run from the repo root: python scripts/build_icons.py Requirements: pip install pillow # for .ico iconutil # built into macOS; for .icns """ from __future__ import annotations import shutil import subprocess import tempfile from pathlib import Path ROOT = Path(__file__).parent.parent SRC = ROOT / "icons" / "twin-gears" / "rounded" OUT = ROOT / "icons" def build_ico(): try: from PIL import Image except ImportError: print("Pillow not installed — skipping .ico generation (pip install pillow)") return import io import struct sizes = [16, 24, 32, 48, 64, 128, 256] images: list[tuple[int, object]] = [] for s in sizes: p = SRC / f"icon-{s}.png" if not p.exists(): print(f" Missing {p.name}, skipping") continue images.append((s, Image.open(p).convert("RGBA"))) if not images: print(" No source PNGs found — cannot build .ico") return # Write the ICO manually so that entries ≤ 128 px use uncompressed BMP DIB # and the 256 px entry uses PNG. Pillow's ICO saver stores all sizes as # PNG-compressed ("Vista icon" format), which PyInstaller's Windows # resource-updater cannot embed — it silently falls back to its default icon. def bmp_dib(img: object) -> bytes: """Return a BMP DIB (BITMAPINFOHEADER + BGRA rows + AND mask).""" w, h = img.size # biHeight is doubled: top half = XOR mask (color), bottom = AND mask hdr = struct.pack( " bytes: buf = io.BytesIO() img.save(buf, format="PNG") return buf.getvalue() blobs: list[tuple[int, bytes]] = [] for s, img in images: blobs.append((s, bmp_dib(img) if s < 256 else png_bytes(img))) n = len(blobs) dir_offset = 6 + n * 16 # ICONDIR (6) + n × ICONDIRENTRY (16) out = bytearray() out += struct.pack("