from __future__ import annotations import argparse import os import platform import shutil import subprocess import sys from pathlib import Path import plistlib from PIL import Image ROOT = Path(__file__).parent.resolve() BUILD_DIR = ROOT / "build" ICONS_DIR = BUILD_DIR / "icons" def info(msg: str) -> None: print(f"[build] {msg}") def ensure_dirs() -> None: ICONS_DIR.mkdir(parents=True, exist_ok=True) def load_icon_png(path: Path) -> Image.Image: if Image is None: raise RuntimeError("Pillow is required to process icons.") img = Image.open(path).convert("RGBA") size = max(img.width, img.height) canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) x = (size - img.width) // 2 y = (size - img.height) // 2 canvas.paste(img, (x, y)) return canvas def rounded(img: Image.Image, radius_ratio: float = 0.22) -> Image.Image: if Image is None: return img w, h = img.size r = int(min(w, h) * max(0.0, min(radius_ratio, 0.5))) if r <= 0: return img mask = Image.new("L", (w, h), 0) from PIL import ImageDraw d = ImageDraw.Draw(mask) d.rounded_rectangle((0, 0, w, h), radius=r, fill=255) out = img.copy() out.putalpha(mask) return out def make_windows_ico(src_png: Path, out_ico: Path, radius_ratio: float) -> Path: info("Generating Windows .ico") square = load_icon_png(src_png) sizes = [16, 24, 32, 48, 64, 128, 256] images = [rounded(square.resize((s, s), Image.LANCZOS), radius_ratio) for s in sizes] images[0].save(out_ico, format="ICO", sizes=[(s, s) for s in sizes]) return out_ico def make_macos_icns(src_png: Path, out_icns: Path, radius_ratio: float) -> Path: info("Generating macOS .icns") iconset = BUILD_DIR / "icon.iconset" if iconset.exists(): shutil.rmtree(iconset) iconset.mkdir(parents=True, exist_ok=True) square = load_icon_png(src_png) sizes = [16, 32, 64, 128, 256, 512, 1024] mapping = { 16: ["icon_16x16.png", "icon_32x32.png"], 32: ["icon_16x16@2x.png"], 64: ["icon_32x32@2x.png"], 128: ["icon_128x128.png", "icon_256x256.png"], 256: ["icon_128x128@2x.png"], 512: ["icon_512x512.png"], 1024:["icon_512x512@2x.png"], } for s in sizes: img = rounded(square.resize((s, s), Image.LANCZOS), radius_ratio) for name in mapping.get(s, []): img.save(iconset / name, format="PNG") try: subprocess.run(["iconutil", "-c", "icns", str(iconset), "-o", str(out_icns)], check=True) except Exception as e: raise RuntimeError("Failed to create .icns. Ensure Xcode command line tools are installed (iconutil).\n" f"Details: {e}") finally: shutil.rmtree(iconset, ignore_errors=True) return out_icns def pyinstaller_add_data_arg(src: Path, dest: str) -> str: sep = ";" if os.name == "nt" else ":" return f"{src}{sep}{dest}" def run_pyinstaller(entry: Path, name: str, icon: Path | None, extra_data: list[tuple[Path, str]], bundle_id: str | None = None) -> None: cmd = [ sys.executable, "-m", "PyInstaller", "--windowed", "--noconfirm", "--name", name, ] if bundle_id and platform.system().lower() == "darwin": cmd += ["--osx-bundle-identifier", bundle_id] if icon is not None: cmd += ["--icon", str(icon)] for (src, dest) in extra_data: cmd += ["--add-data", pyinstaller_add_data_arg(src, dest)] cmd.append(str(entry)) info("Running: " + " ".join(cmd)) subprocess.run(cmd, check=True) def patch_macos_plist(app_path: Path, bundle_id: str, icon_base_name: str = "appicon") -> None: info("Patching macOS Info.plist") plist_path = app_path / "Contents" / "Info.plist" if not plist_path.exists(): info(f"No Info.plist at {plist_path}, skipping patch") return with plist_path.open("rb") as f: data = plistlib.load(f) data["CFBundleIdentifier"] = bundle_id data["CFBundleName"] = data.get("CFBundleName") or app_path.stem data["CFBundleDisplayName"] = data.get("CFBundleDisplayName") or app_path.stem data["CFBundleIconFile"] = icon_base_name data["CFBundleIconName"] = icon_base_name with plist_path.open("wb") as f: plistlib.dump(data, f) def make_dmg(app_path: Path, dmg_path: Path, volume_name: str) -> None: info("Creating DMG") staging = BUILD_DIR / "dmg_staging" if staging.exists(): shutil.rmtree(staging) (staging).mkdir(parents=True, exist_ok=True) shutil.rmtree(staging / app_path.name, ignore_errors=True) shutil.copytree(app_path, staging / app_path.name, symlinks=True) try: os.symlink("/Applications", staging / "Applications") except FileExistsError: pass dmg_path.parent.mkdir(parents=True, exist_ok=True) subprocess.run([ "hdiutil", "create", "-volname", volume_name, "-srcfolder", str(staging), "-format", "UDZO", "-imagekey", "zlib-level=9", str(dmg_path) ], check=True) shutil.rmtree(staging, ignore_errors=True) def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--name", default="ChatMock") parser.add_argument("--entry", default="gui.py") parser.add_argument("--icon", default="icon.png") parser.add_argument("--radius", type=float, default=0.22) parser.add_argument("--square", action="store_true") parser.add_argument("--dmg", action="store_true") parser.add_argument("--dmg-only", action="store_true") args = parser.parse_args() ensure_dirs() entry = ROOT / args.entry icon_src = ROOT / args.icon if args.dmg_only: app_path = ROOT / "dist" / f"{args.name}.app" if not app_path.exists(): raise SystemExit(f"App not found: {app_path}") dmg = ROOT / "dist" / f"{args.name}.dmg" make_dmg(app_path, dmg, args.name) return if not entry.exists(): raise SystemExit(f"Entry not found: {entry}") if not icon_src.exists(): raise SystemExit(f"Icon PNG not found: {icon_src}") os_name = platform.system().lower() extra_data: list[tuple[Path, str]] = [ (ROOT / "prompt.md", "."), (ROOT / "prompt_gpt5_codex.md", "."), ] bundle_icon: Path | None = None rr = 0.0 if args.square else float(args.radius) if os_name == "windows": ico = ICONS_DIR / "appicon.ico" make_windows_ico(icon_src, ico, rr) bundle_icon = ico extra_data.append((ico, ".")) elif os_name == "darwin": icns = ICONS_DIR / "appicon.icns" make_macos_icns(icon_src, icns, rr) bundle_icon = icns extra_data.append((icns, ".")) else: png_copy = ICONS_DIR / "appicon.png" if Image is not None: square = load_icon_png(icon_src).resize((512, 512), Image.LANCZOS) square = rounded(square, rr) if rr > 0 else square square.save(png_copy) else: shutil.copy2(icon_src, png_copy) extra_data.append((png_copy, ".")) run_pyinstaller(entry, args.name, bundle_icon, extra_data) if os_name == "darwin": app_path = ROOT / "dist" / f"{args.name}.app" if app_path.exists(): bid = "com.chatmock.app" patch_macos_plist(app_path, bundle_id=bid, icon_base_name="appicon") if args.dmg: dmg = ROOT / "dist" / f"{args.name}.dmg" make_dmg(app_path, dmg, args.name) if __name__ == "__main__": main()