#!/usr/bin/env python3 """Regenerate the current TagLauncher grid/rainbow app icon assets.""" from __future__ import annotations import colorsys import subprocess import tempfile from pathlib import Path from PIL import Image, ImageChops, ImageDraw SIZE = 1024 PROJECT_DIR = Path(__file__).resolve().parent APPICON_DIR = PROJECT_DIR / "Apptag" / "Assets.xcassets" / "AppIcon.appiconset" ROOT_ICNS = PROJECT_DIR / "icon-icns.icns" RELEASE_ICON_TARGETS = [ PROJECT_DIR / "Release" / "AppStore-7.6.0-20260527.0124" / "Assets" / "TagLauncher-AppIcon-1024.png", PROJECT_DIR / "Release" / "_archive" / "AppStore-7.6.0-20260525.0016" / "Assets" / "TagLauncher-AppIcon-1024.png", ] SOURCE_CANDIDATES = [ APPICON_DIR / "icon_512x512@2x.png", RELEASE_ICON_TARGETS[0], ROOT_ICNS, ] VARIANTS = [ ("icon_16x16.png", 16), ("icon_16x16@2x.png", 32), ("icon_32x32.png", 32), ("icon_32x32@2x.png", 64), ("icon_128x128.png", 128), ("icon_128x128@2x.png", 256), ("icon_256x256.png", 256), ("icon_256x256@2x.png", 512), ("icon_512x512.png", 512), ("icon_512x512@2x.png", 1024), ] # The approved grid/rainbow icon artwork sits inside this rounded rect. # Keeping this mask explicit prevents the old opaque-white square corners from # reappearing when assets are regenerated from an older flattened source. MASK_BOUNDS = (77, 57, 943, 934) MASK_RADIUS = 232 def load_source() -> tuple[Path, Image.Image]: for path in SOURCE_CANDIDATES: if not path.exists(): continue with Image.open(path) as image: image.load() source = image.convert("RGBA").resize((SIZE, SIZE), Image.LANCZOS) return path, source raise FileNotFoundError("No app icon source found.") def assert_current_grid_rainbow_icon(image: Image.Image) -> None: """Reject the removed two-tag icon if it is accidentally used as source.""" bands = {"yellow": 0, "green": 0, "cyan": 0, "purple": 0} pixels = image.load() for y in range(120, 900, 4): for x in range(520, 900, 4): red, green, blue, alpha = pixels[x, y] if alpha < 128: continue hue, saturation, value = colorsys.rgb_to_hsv( red / 255.0, green / 255.0, blue / 255.0 ) if saturation < 0.30 or value < 0.35: continue if 0.10 <= hue <= 0.19: bands["yellow"] += 1 elif 0.20 <= hue <= 0.42: bands["green"] += 1 elif 0.43 <= hue <= 0.58: bands["cyan"] += 1 elif 0.70 <= hue <= 0.86: bands["purple"] += 1 present_bands = [name for name, count in bands.items() if count >= 100] if len(present_bands) < 3: raise ValueError( "Source image does not match the current grid/rainbow icon family; " f"detected color bands: {bands}" ) def icon_mask() -> Image.Image: x0, y0, x1, y1 = MASK_BOUNDS mask = Image.new("L", (SIZE, SIZE), 0) draw = ImageDraw.Draw(mask) draw.rounded_rectangle( [x0, y0, x1 - 1, y1 - 1], radius=MASK_RADIUS, fill=255, ) return mask def normalize_icon(image: Image.Image) -> Image.Image: image = image.resize((SIZE, SIZE), Image.LANCZOS).convert("RGBA") alpha = ImageChops.multiply(image.getchannel("A"), icon_mask()) normalized = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0)) normalized.paste(image, (0, 0), alpha) normalized.putalpha(alpha) return normalized def save_png(path: Path, image: Image.Image, size: int) -> None: path.parent.mkdir(parents=True, exist_ok=True) resized = image.resize((size, size), Image.LANCZOS) resized.save(path, "PNG", optimize=True) print(f" {path.relative_to(PROJECT_DIR)} ({size}x{size})") def generate_icns(image: Image.Image) -> None: with tempfile.TemporaryDirectory() as temp_dir: iconset = Path(temp_dir) / "AppIcon.iconset" iconset.mkdir() for name, size in VARIANTS: image.resize((size, size), Image.LANCZOS).save(iconset / name, "PNG") subprocess.run( ["iconutil", "-c", "icns", str(iconset), "-o", str(ROOT_ICNS)], check=True, ) print(f" {ROOT_ICNS.relative_to(PROJECT_DIR)}") def print_corner_alpha(path: Path) -> None: with Image.open(path) as image: rgba = image.convert("RGBA") width, height = rgba.size corners = [ rgba.getpixel((0, 0))[3], rgba.getpixel((width - 1, 0))[3], rgba.getpixel((0, height - 1))[3], rgba.getpixel((width - 1, height - 1))[3], ] print(f" alpha corners {path.relative_to(PROJECT_DIR)}: {corners}") def main() -> None: source_path, source = load_source() assert_current_grid_rainbow_icon(source) icon = normalize_icon(source) print(f"Source: {source_path.relative_to(PROJECT_DIR)}") print("Writing AppIcon.appiconset:") save_png(APPICON_DIR / "icon_1024_preview.png", icon, 1024) for name, size in VARIANTS: save_png(APPICON_DIR / name, icon, size) print("Writing release 1024 assets:") for target in RELEASE_ICON_TARGETS: save_png(target, icon, 1024) print("Writing root .icns:") generate_icns(icon) print("Validation:") print_corner_alpha(ROOT_ICNS) print_corner_alpha(APPICON_DIR / "icon_512x512@2x.png") for target in RELEASE_ICON_TARGETS: print_corner_alpha(target) if __name__ == "__main__": main()