Files
better-claude-config/scripts/build_icons.py
T
AJ Avezzano f5749947e1 fix: write BMP DIB entries in ICO so Windows exe shows correct icon
Pillow's ICO saver stores all sizes as PNG-compressed ("Vista icon"
format).  PyInstaller's Windows resource-updater cannot embed
PNG-compressed entries for small sizes and silently falls back to its
default gear icon.

Fix build_icons.py to write the ICO manually: BMP DIB for sizes ≤ 128 px,
PNG only for the 256 px entry (where Windows Explorer expects PNG).
Regenerate icons/app.ico with the new code.

Also set upx=False in bcc.spec for the Windows/Linux EXE; UPX is another
known cause of icon resources being stripped from PE files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 22:42:02 -04:00

151 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 os
import platform
import shutil
import subprocess
import sys
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, 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(
"<IiiHHIIiiII",
40, # biSize
w,
h * 2, # biHeight (doubled per ICO convention)
1, # biPlanes
32, # biBitCount
0, # biCompression (BI_RGB)
0, # biSizeImage (0 ok for BI_RGB)
0, 0, 0, 0,
)
pix = img.load()
rows = bytearray()
for y in range(h - 1, -1, -1): # bottom-up
for x in range(w):
r, g, b, a = pix[x, y]
rows += bytes([b, g, r, a])
# AND mask: 1 bit/pixel, rows padded to 32-bit boundary, all 0 (use alpha)
mask_row = ((w + 31) // 32) * 4
and_mask = bytes(mask_row * h)
return hdr + bytes(rows) + and_mask
def png_bytes(img: object) -> 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("<HHH", 0, 1, n) # ICONDIR
cur = dir_offset
for s, blob in blobs:
w = h = s % 256 # 256 is stored as 0 in the byte field
out += struct.pack("<BBBBHHII", w, h, 0, 0, 1, 32, len(blob), cur)
cur += len(blob)
for _, blob in blobs:
out += blob
dest = OUT / "app.ico"
dest.write_bytes(out)
print(f" Generated {dest.relative_to(ROOT)} ({len(out) // 1024 + 1} KB)")
def build_icns():
if shutil.which("iconutil") is None:
print(" iconutil not found (macOS-only) — skipping .icns generation")
return
iconset = Path(tempfile.mkdtemp()) / "app.iconset"
iconset.mkdir()
mapping = {
"icon_16x16.png": "icon-16.png",
"icon_16x16@2x.png": "icon-32.png",
"icon_32x32.png": "icon-32.png",
"icon_32x32@2x.png": "icon-64.png",
"icon_128x128.png": "icon-128.png",
"icon_128x128@2x.png": "icon-256.png",
"icon_256x256.png": "icon-256.png",
"icon_256x256@2x.png": "icon-512.png",
"icon_512x512.png": "icon-512.png",
"icon_512x512@2x.png": "icon-1024.png",
}
for dst_name, src_name in mapping.items():
src = SRC / src_name
if src.exists():
shutil.copy2(src, iconset / dst_name)
else:
print(f" Missing {src_name}, skipping {dst_name}")
dest = OUT / "app.icns"
subprocess.run(
["iconutil", "-c", "icns", str(iconset), "-o", str(dest)],
check=True,
)
shutil.rmtree(iconset.parent)
print(f" Generated {dest.relative_to(ROOT)} ({dest.stat().st_size // 1024} KB)")
if __name__ == "__main__":
print("Building icons/app.ico ...")
build_ico()
print("Building icons/app.icns ...")
build_icns()
print("Done.")