f5749947e1
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>
151 lines
4.6 KiB
Python
151 lines
4.6 KiB
Python
#!/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.")
|