CheckMat / build.py
aiqknow's picture
Upload 97 files
35205e8 verified
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()