Files
buzz/scripts/generate-macos-icon.py
klopez4212 0ee2ad7fbf Fix rounded macOS icon on older Macs (#8018)
Older macOS versions display Buzz’s opaque icon as an oversized square.
Bake rounded corners and transparent padding into the macOS `.icns`,
preserving the original bee artwork and leaving other platforms
unchanged.

Generation uses Hermit-pinned uv, Python 3.14.3, and Pillow 12.2.0.
`scripts/macos-icon.sh --check` regenerates temporarily and compares all
decoded representations; macOS CI runs it before packaging. Pixel
reproducibility is verified independently of Apple’s ICNS container
encoding.

Validated native AppKit preview, all 10 representations, fresh-cache
generation, one-pixel/missing-representation drift rejection, workflow
lint, and pre-push checks. Full local `just ci` is running. Packaged
Dock checks on older/current macOS remain outstanding.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-01 17:00:57 +01:00

95 lines
3.5 KiB
Python

#!/usr/bin/env python3
"""Build the legacy macOS icon without relying on system corner masking.
Run: scripts/macos-icon.sh [--check]
The wrapper pins uv, Python and Pillow. Verification compares the complete set
of decoded RGBA representations, not PNG encoding or ICNS container bytes:
Apple's system iconutil may encode the same pixels differently across macOS.
The shared artwork and other platforms' icons are intentionally untouched.
"""
# /// script
# requires-python = "==3.14.3"
# dependencies = ["Pillow==12.2.0"]
# ///
import argparse
from pathlib import Path
import subprocess
import tempfile
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parent.parent
ICONS = ROOT / "desktop/src-tauri/icons"
def generate(output):
# An 824px tile on a 1024px canvas leaves the macOS optical inset.
# Work at 4x resolution to antialias the rounded edge at every icon size.
scale = 4
canvas = Image.new("RGBA", (1024 * scale, 1024 * scale))
with Image.open(ICONS / "buzz-source.png") as source:
tile = source.convert("RGBA").resize(
(824 * scale, 824 * scale), Image.Resampling.LANCZOS
)
mask = Image.new("L", tile.size)
ImageDraw.Draw(mask).rounded_rectangle(
(0, 0, tile.width - 1, tile.height - 1), radius=185 * scale, fill=255
)
canvas.paste(tile, (100 * scale, 100 * scale), mask)
with tempfile.TemporaryDirectory(prefix="buzz-macos-icon-") as temporary:
iconset = Path(temporary) / "Buzz.iconset"
iconset.mkdir()
for size in (16, 32, 128, 256, 512):
for density in (1, 2):
suffix = "@2x" if density == 2 else ""
canvas.resize(
(size * density, size * density), Image.Resampling.LANCZOS
).save(iconset / f"icon_{size}x{size}{suffix}.png")
subprocess.run(
["/usr/bin/iconutil", "-c", "icns", str(iconset), "-o", str(output)],
check=True,
)
def verify(candidate, reference):
"""Reject missing/extra representations and any decoded pixel drift."""
with tempfile.TemporaryDirectory(prefix="buzz-icon-check-") as temporary:
sets = []
for name, icon in (("candidate", candidate), ("reference", reference)):
iconset = Path(temporary) / f"{name}.iconset"
subprocess.run(
["/usr/bin/iconutil", "-c", "iconset", str(icon), "-o", str(iconset)],
check=True,
)
sets.append({p.name: p for p in iconset.glob("*.png")})
if sets[0].keys() != sets[1].keys():
raise ValueError("macOS icon representation set drifted")
for name in sorted(sets[0]):
with Image.open(sets[0][name]) as a, Image.open(sets[1][name]) as b:
if a.size != b.size or a.convert("RGBA").tobytes() != b.convert("RGBA").tobytes():
raise ValueError(f"macOS icon pixel drift: {name}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="verify without modifying the icon")
parser.add_argument("--output", type=Path, default=ICONS / "icon.icns")
args = parser.parse_args()
if args.check:
with tempfile.TemporaryDirectory(prefix="buzz-icon-regenerate-") as temporary:
generated = Path(temporary) / "icon.icns"
generate(generated)
verify(args.output, generated)
print("macOS icon: all decoded representations match")
else:
generate(args.output)
if __name__ == "__main__":
main()