#!/usr/bin/env python3 """Generate every app icon in the repo from the nous-girl art + platform backgrounds. Usage (from repo root): node scripts/generate-icons.mjs # write node scripts/generate-icons.mjs --check # verify structure Sources of truth — two axes, composed per target: Girl art (vector): assets/nous-girl-black.svg (black positive space) assets/nous-girl-white.svg (white positive space) straight from the Nous brand kit (Inkscape exports, 5487^2 viewBox, one path each). Backgrounds (per platform surface, light/dark): assets/backgrounds/squircle-light.svg white rounded assets/backgrounds/squircle-dark.svg #0d1117 rounded assets/backgrounds/squircle-mac-light.svg mac HIG grid assets/backgrounds/squircle-mac-dark.svg mac HIG grid The master SVGs (assets/icon-master.svg light, assets/icon-master-dark.svg dark) are GENERATED artifacts — squircle background + scaled girl artwork. The light master drives every squircle target; the dark master drives the dark-appearance targets. macOS is the exception: its icns targets render from an in-memory mac master that puts the same squircle on Apple's 824x824 (r=185.4) grid — centered in 1024 with 100px margins — so the icon matches the size of Apple-template neighbors. Desktop build identity comes from HERMES_PAYLOAD_TAG / HERMES_BUILD_COMMIT: Canary uses yellow/dark-yellow backgrounds. Commit builds use red/dark-red and a seven-character SHA badge. The girl and tile geometry do not change. Only apps/desktop outputs use this identity. Website, bootstrap, dashboard, and the shared master SVGs retain the default brand. The girl's position and uniform scale are registered to the reference artwork. She renders in front of the border, clipped only to the outer rounded silhouette. Only nodes near her bottom edge extend to the border; the fitted face and hair stay fixed. Standalone wordmarks remain centered and have no border. GENERATED OUTPUTS ARE COMMITTED. Regular builds and installs consume them and never render; flavored release bundles (canary/commit) render to a product dir. icons-freshness-check.yml regenerates, runs --check, and fails on any diff. Rendering: resvg (resvg-py) for SVG -> PNG fidelity at every size. Containers: Pillow for multi-size .ico and .icns. Dependencies: Pillow and resvg-py are core runtime dependencies; run this file with a Hermes runtime interpreter (scripts/generate-icons.mjs uses HERMES_PYTHON). Outputs (30 files): assets/icon-master.svg generated light master assets/icon-master-dark.svg generated dark master apps/desktop/assets/icon.png 1024x1024 squircle (light) apps/desktop/assets/icon.ico 16,24,32,48,64,128,256 apps/desktop/assets/icon.icns 16..1024 (real ICNS) apps/desktop/assets/icon-dark.png 1024x1024 squircle (dark) apps/desktop/assets/icon-dark.ico 16,24,32,48,64,128,256 apps/desktop/assets/icon-dark.icns 16..1024 (real ICNS) apps/desktop/assets/appx/Wide310x150Logo.png 310x150, squircle 100 centered apps/desktop/assets/appx/StoreLogo.png 50x50 squircle apps/desktop/assets/appx/Square44x44Logo.png 44x44 squircle apps/desktop/assets/appx/Square150x150Logo.png 150x150 squircle apps/desktop/assets/appx/*-dark.png dark-appearance logos apps/desktop/public/apple-touch-icon.png 1024x1024 squircle apps/desktop/public/nous-girl.png 256x256 squircle, black girl (light mark) apps/desktop/public/nous-girl-dark.png 256x256 squircle, white girl (dark mark) apps/bootstrap-installer/src-tauri/icons/32x32.png 32x32 apps/bootstrap-installer/src-tauri/icons/128x128.png 128x128 apps/bootstrap-installer/src-tauri/icons/128x128@2x.png 256x256 apps/bootstrap-installer/src-tauri/icons/icon.ico 16,32,64,128,256 apps/bootstrap-installer/src-tauri/icons/icon.icns 16..1024 apps/bootstrap-installer/public/nous-girl.png 256x256 squircle mark (light) website/static/img/logo.png 1772x1799 girl alone, transparent (light) website/static/img/logo-dark.png 1772x1799 girl alone, transparent (dark) website/static/img/nous-logo.png 150x150 on white (opaque) website/static/img/nous-logo-dark.png 150x150 on #0d1117 (opaque) website/static/img/favicon-16x16.png 16x16 website/static/img/favicon-32x32.png 32x32 website/static/img/apple-touch-icon.png 180x180 website/static/img/favicon.ico 16,32,48 website/static/img/favicon.svg copy of the light master web/public/favicon.ico 16,32,48 """ from __future__ import annotations import argparse import io import os import re import sys import xml.etree.ElementTree as ET from pathlib import Path from PIL import Image try: import resvg_py except ImportError: sys.exit( "resvg-py is missing: run the generator with a Hermes runtime interpreter\n" " (HERMES_PYTHON= node scripts/generate-icons.mjs)" ) # Copy of hermes_cli.update_channel._CANARY_TAG_RE: builders run this renderer # on the runtime dependencies without the application package installed # (Docker, bundles). tests/scripts/test_icon_flavors.py pins it to the canonical one. _CANARY_TAG_RE = re.compile( r"^v(?:0|[1-9]\d{0,2})\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" r"\+canary\.20\d{6}T\d{6}Z$" ) # The nous dark background (#0d1117) — fixed dark tile/background everywhere. DARK_HEX = "#0d1117" DARK_RGB = (13, 17, 23) BORDER_FRACTION = 0.0407747197 # Portrait boxes fitted to the reference at equal visible tile width, with # uniform scaling about the tile center followed by an up-left translation. # Keep their y coordinate: bottom anchoring would undo the registration. GIRL_BOXES = { "squircle-light.svg": (72.149433, 104.703674, 872.767801, 872.767801), "squircle-dark.svg": (72.149433, 104.703674, 872.767801, 872.767801), # Mac: the girl scaled 1.12x about the plate center; the plate stays on the # 824 grid, but a white tile with a ring reads small beside full-color peers. "squircle-mac-light.svg": (122.43, 144.84, 786.83, 786.83), "squircle-mac-dark.svg": (122.43, 144.84, 786.83, 786.83), } # The brand-kit SVG canvas (both girl svgs share this viewBox). GIRL_VIEWBOX = 5487.0615 # Target sizes for --check's structural verification: relpath -> (format, size) CHECK_SIZES: dict[str, tuple[str, tuple[int, int]]] = { "apps/desktop/assets/icon.png": ("PNG", (1024, 1024)), "apps/desktop/assets/icon-dark.png": ("PNG", (1024, 1024)), "apps/desktop/assets/appx/Wide310x150Logo.png": ("PNG", (310, 150)), "apps/desktop/assets/appx/Wide310x150Logo-dark.png": ("PNG", (310, 150)), "apps/desktop/assets/appx/StoreLogo.png": ("PNG", (50, 50)), "apps/desktop/assets/appx/StoreLogo-dark.png": ("PNG", (50, 50)), "apps/desktop/assets/appx/Square44x44Logo.png": ("PNG", (44, 44)), "apps/desktop/assets/appx/Square44x44Logo-dark.png": ("PNG", (44, 44)), "apps/desktop/assets/appx/Square150x150Logo.png": ("PNG", (150, 150)), "apps/desktop/assets/appx/Square150x150Logo-dark.png": ("PNG", (150, 150)), "apps/desktop/public/apple-touch-icon.png": ("PNG", (1024, 1024)), "apps/desktop/public/nous-girl.png": ("PNG", (256, 256)), "apps/desktop/public/nous-girl-dark.png": ("PNG", (256, 256)), "apps/bootstrap-installer/src-tauri/icons/32x32.png": ("PNG", (32, 32)), "apps/bootstrap-installer/src-tauri/icons/128x128.png": ("PNG", (128, 128)), "apps/bootstrap-installer/src-tauri/icons/128x128@2x.png": ("PNG", (256, 256)), "apps/bootstrap-installer/public/nous-girl.png": ("PNG", (256, 256)), "website/static/img/logo.png": ("PNG", (1772, 1799)), "website/static/img/logo-dark.png": ("PNG", (1772, 1799)), "website/static/img/nous-logo.png": ("PNG", (150, 150)), "website/static/img/nous-logo-dark.png": ("PNG", (150, 150)), "website/static/img/favicon-16x16.png": ("PNG", (16, 16)), "website/static/img/favicon-32x32.png": ("PNG", (32, 32)), "website/static/img/apple-touch-icon.png": ("PNG", (180, 180)), } # (relpath, kind, arg) TARGETS: list[tuple[str, str, object]] = [ ("assets/icon-master.svg", "svg", None), ("assets/icon-master-dark.svg", "svg_dark", None), ("apps/desktop/assets/icon.png", "png", 1024), ("apps/desktop/assets/icon.ico", "ico", [16, 24, 32, 48, 64, 128, 256]), ("apps/desktop/assets/icon.icns", "icns", None), ("apps/desktop/assets/icon-dark.png", "png_dark", 1024), ("apps/desktop/assets/icon-dark.ico", "ico_dark", [16, 24, 32, 48, 64, 128, 256]), ("apps/desktop/assets/icon-dark.icns", "icns_dark", None), ("apps/desktop/assets/appx/Wide310x150Logo.png", "wide", (310, 150)), ("apps/desktop/assets/appx/StoreLogo.png", "png", 50), ("apps/desktop/assets/appx/Square44x44Logo.png", "png", 44), ("apps/desktop/assets/appx/Square150x150Logo.png", "png", 150), ("apps/desktop/assets/appx/Wide310x150Logo-dark.png", "wide_dark", (310, 150)), ("apps/desktop/assets/appx/StoreLogo-dark.png", "png_dark", 50), ("apps/desktop/assets/appx/Square44x44Logo-dark.png", "png_dark", 44), ("apps/desktop/assets/appx/Square150x150Logo-dark.png", "png_dark", 150), ("apps/desktop/public/apple-touch-icon.png", "png", 1024), # The dev-run Dock icon (app.dock.setIcon): same mac grid as the icns. ("apps/desktop/assets/icon-mac.png", "png_mac", 1024), ("apps/desktop/public/nous-girl.png", "girl_light", 256), ("apps/desktop/public/nous-girl-dark.png", "girl_dark", 256), ("apps/bootstrap-installer/src-tauri/icons/32x32.png", "png", 32), ("apps/bootstrap-installer/src-tauri/icons/128x128.png", "png", 128), ("apps/bootstrap-installer/src-tauri/icons/128x128@2x.png", "png", 256), ("apps/bootstrap-installer/src-tauri/icons/icon.ico", "ico", [16, 32, 64, 128, 256]), ("apps/bootstrap-installer/src-tauri/icons/icon.icns", "icns", None), ("apps/bootstrap-installer/public/nous-girl.png", "girl_light", 256), ("website/static/img/logo.png", "logo", None), ("website/static/img/logo-dark.png", "logo_dark", None), ("website/static/img/nous-logo.png", "png_white", 150), ("website/static/img/nous-logo-dark.png", "png_dark_white", 150), ("website/static/img/favicon-16x16.png", "png", 16), ("website/static/img/favicon-32x32.png", "png", 32), ("website/static/img/apple-touch-icon.png", "png", 180), ("website/static/img/favicon.ico", "ico", [16, 32, 48]), ("website/static/img/favicon.svg", "svg_copy", None), ("web/public/favicon.ico", "ico", [16, 32, 48]), ] # ─── girl art extraction ──────────────────────────────────────────────────── class IconArt: """One generation's rendering inputs and caches; never writes to source.""" def __init__(self, source: Path, *, colors: tuple[str, str] | None = None, commit: str = ""): assets = source / "assets" self.colors = colors self.commit = commit self.girls = {color: assets / f"nous-girl-{color}.svg" for color in ("black", "white")} self.backgrounds = assets / "backgrounds" self.paths: dict[str, str] = {} self.bboxes: dict[str, tuple[float, float, float, float]] = {} self.master = compose_svg(self, "black", "squircle-light.svg") self.master_dark = compose_svg(self, "white", "squircle-dark.svg") # macOS icons sit on Apple's 824-on-1024 grid, not the full-bleed # squircle: same art, mac-grid backgrounds, icns targets only. self.master_mac = compose_svg(self, "black", "squircle-mac-light.svg") self.master_mac_dark = compose_svg(self, "white", "squircle-mac-dark.svg") def girl_path(art: IconArt, girl: str) -> str: """The girl `` element with editor metadata stripped (resvg rejects undeclared inkscape/sodipodi prefixes).""" if girl not in art.paths: src = art.girls[girl].read_text(encoding="utf-8-sig") m = re.search(r"", src, re.S) assert m, f"no found in {art.girls[girl].name}" path = re.sub(r'\s+(inkscape|sodipodi):[a-zA-Z-]+="[^"]*"', "", m.group(0)) art.paths[girl] = path return art.paths[girl] def girl_bbox(art: IconArt, girl: str) -> tuple[float, float, float, float]: """Art bounding box in the girl SVG's coordinate space, measured by rendering once and taking the alpha bbox (robust to art changes).""" if girl not in art.bboxes: data = resvg_py.svg_to_bytes(svg_path=str(art.girls[girl]), width=512, height=512) im = Image.open(io.BytesIO(data)) bx, by, bx2, by2 = im.getchannel("A").point(lambda v: 255 if v > 0 else 0).getbbox() s = GIRL_VIEWBOX / 512.0 art.bboxes[girl] = (bx * s, by * s, (bx2 - bx) * s, (by2 - by) * s) return art.bboxes[girl] def girl_layer( art: IconArt, girl: str, box: tuple[float, float, float, float], *, align: str = "xMidYMid", ) -> str: """Nested-svg layer: girl art (bbox as viewBox) placed into `box` — the box's aspect is preserved via 'meet', so the girl never distorts.""" bx, by, bw, bh = girl_bbox(art, girl) x, y, w, h = box return ( f'\n' f" {girl_path(art, girl)}\n" " " ) def background_inner(art: IconArt, name: str) -> tuple[str, int, int]: """Inner content + (width, height) of a background SVG asset.""" text = (art.backgrounds / name).read_text(encoding="utf-8-sig") if art.colors: text = text.replace('fill="#ffffff"', f'fill="{art.colors[0]}"') text = text.replace(f'fill="{DARK_HEX}"', f'fill="{art.colors[1]}"') root = ET.fromstring(text) m = re.fullmatch(r"0 0 (\d+(?:\.\d+)?) (\d+(?:\.\d+)?)", root.get("viewBox", "")) assert m, f"cannot parse viewBox of {name}" w, h = float(m.group(1)), float(m.group(2)) # Editor exports include XML declarations and root-scoped namespaces. # Parse away the prolog and retain child namespaces when embedding. inner = "".join(ET.tostring(child, encoding="unicode") for child in root) return inner, int(w), int(h) # Five-by-seven lowercase hexadecimal glyphs, one five-bit row at a time. # Vector cells keep release builds deterministic without any installed fonts. HEX_GLYPHS = { "0": (14, 17, 19, 21, 25, 17, 14), "1": (4, 12, 4, 4, 4, 4, 14), "2": (14, 17, 1, 2, 4, 8, 31), "3": (30, 1, 1, 14, 1, 1, 30), "4": (2, 6, 10, 18, 31, 2, 2), "5": (31, 16, 16, 30, 1, 1, 30), "6": (14, 16, 16, 30, 17, 17, 14), "7": (31, 1, 2, 4, 8, 8, 8), "8": (14, 17, 17, 14, 17, 17, 14), "9": (14, 17, 17, 15, 1, 1, 14), "a": (0, 0, 14, 1, 15, 17, 15), "b": (16, 16, 30, 17, 17, 17, 30), "c": (0, 0, 14, 16, 16, 17, 14), "d": (1, 1, 15, 17, 17, 17, 15), "e": (0, 0, 14, 17, 31, 16, 14), "f": (6, 9, 8, 28, 8, 8, 8), } def commit_layer(commit: str, bg: str) -> str: cells = [] for index, char in enumerate(commit[:7]): for row, bits in enumerate(HEX_GLYPHS[char]): for col in range(5): if bits & (1 << (4 - col)): x, y = 184 + (index * 6 + col) * 16, 48 + row * 16 cells.append(f"M{x} {y}h16v16h-16z") # The badge follows the tile's mac HIG inset, never the outer canvas. transform = f' transform="translate(100 100) scale({824 / 1024})"' if "-mac-" in bg else "" return ( f'' f'' ) def drag_bottom_nodes(path: ET.Element, *, cutoff: float, band: float, distance: float) -> None: """Drag lower nodes and curve handles, tapering to zero above the bottom band.""" y_scale, y_offset = 1.0, 0.0 transform = path.get("transform") if transform: matrix = re.fullmatch(r"matrix\(([^)]+)\)", transform) if matrix is None: raise ValueError("bottom node edits require an axis-aligned matrix") _, b, c, y_scale, _, y_offset = map(float, re.split(r"[\s,]+", matrix[1].strip())) if b != 0 or c != 0 or y_scale <= 0: raise ValueError("bottom node edits require an upright axis-aligned matrix") # The brand exports use explicit absolute M/L/C commands. Reject other # commands rather than silently corrupting relative coordinates or arcs. tokens = re.findall(r"[A-Za-z]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:[eE][-+]?\d+)?", path.attrib["d"]) counts = {"M": 2, "L": 2, "C": 6, "z": 0, "Z": 0} index = 0 while index < len(tokens): command = tokens[index] if command not in counts: raise ValueError("bottom node edits require explicit absolute M/L/C commands") count = counts[command] if index + count >= len(tokens): raise ValueError("incomplete SVG path command") for offset in range(2, count + 1, 2): token_index = index + offset raw_y = float(tokens[token_index]) amount = min(1.0, max(0.0, (raw_y * y_scale + y_offset - cutoff) / band)) if amount: weight = amount * amount * (3 - 2 * amount) tokens[token_index] = f"{raw_y + distance * weight / y_scale:.12g}" index += count + 1 path.set("d", " ".join(tokens)) def compose_svg(art: IconArt, girl: str, bg: str) -> str: """Full svg text: background + girl layer, in the background's native coordinate space (resvg scales to whatever output size is requested, so the composition is size-agnostic — no manual box scaling).""" inner, w, h = background_inner(art, bg) background = ET.fromstring(f"{inner}") tile = background.find("{http://www.w3.org/2000/svg}rect") assert tile is not None, f"no background rectangle in {bg}" geometry = {key: float(tile.attrib[key]) for key in ("x", "y", "width", "height", "rx")} thickness = geometry["width"] * BORDER_FRACTION # An inward stroke keeps the outer platform geometry unchanged. Subtracting # the same inset from rx (not scaling rx) keeps the corner thickness uniform. inset = {"x": 1, "y": 1, "width": -2, "height": -2, "rx": -1} silhouette = ET.Element("rect", {key: str(value) for key, value in geometry.items()}) for key, value in geometry.items(): tile.set(key, str(value + inset[key] * thickness / 2)) tile.set("stroke", "#000000" if girl == "black" else "#ffffff") tile.set("stroke-width", str(thickness)) inner = "".join(ET.tostring(child, encoding="unicode") for child in background) clip = ET.tostring(silhouette, encoding="unicode") box = GIRL_BOXES[bg] portrait = ET.fromstring(girl_layer(art, girl, box, align="xMidYMax")) _, y, portrait_width, portrait_height = box _, by, bw, bh = girl_bbox(art, girl) scale = min(portrait_width / bw, portrait_height / bh) join_bottom = geometry["y"] + geometry["height"] - thickness + 10 drag_bottom_nodes( portrait[0], cutoff=by + bh * 0.97, band=bh * 0.02, distance=max(0.0, join_bottom - (y + portrait_height)) / scale, ) # Keep the fitted viewBox fixed, but let edited nodes reach into the border. portrait.set("overflow", "visible") badge = f" {commit_layer(art.commit, bg)}\n" if art.commit else "" return ( f'\n' f' {clip}\n' f" {inner.strip()}\n" f"{badge}" f' {ET.tostring(portrait, encoding="unicode")}\n' "\n" ) # ─── rendering ────────────────────────────────────────────────────────────── def render(master: str, size: int, *, background: str | None = None) -> Image.Image: """Render a master to an RGBA PNG of `size`x`size`.""" data = resvg_py.svg_to_bytes( svg_string=master, width=size, height=size, background=background ) return Image.open(io.BytesIO(data)).convert("RGBA") def render_svg(svg: str, size: int | tuple[int, int]) -> Image.Image: if isinstance(size, int): w = h = size else: w, h = size data = resvg_py.svg_to_bytes(svg_string=svg, width=w, height=h) return Image.open(io.BytesIO(data)).convert("RGBA") def paste_centered(canvas: Image.Image, img: Image.Image) -> None: x = (canvas.width - img.width) // 2 y = (canvas.height - img.height) // 2 canvas.alpha_composite(img, (x, y)) def save_png(img: Image.Image, buf: io.BytesIO) -> None: """Save with alpha preserved. Flat art quantizes losslessly to an 8-bit palette (tRNS per-index alpha keeps the AA edges), so use that when the palette round-trips pixel-identically; fall back to RGBA otherwise.""" if img.mode != "RGBA": img.convert("RGBA").save(buf, "PNG", optimize=True) return quantized = img.quantize(colors=256, method=Image.FASTOCTREE, dither=Image.NONE) if quantized.convert("RGBA").tobytes() == img.tobytes(): quantized.save(buf, "PNG", optimize=True) else: img.save(buf, "PNG", optimize=True) def girl_mark(art: IconArt, kind: str, size: int) -> Image.Image: """The girl in the app-icon squircle (BrandMark asset) — the mark IS the icon shape. girl_light: black girl on white squircle. girl_dark: white girl on #0d1117 squircle.""" if kind == "girl_light": girl, bg = "black", "squircle-light.svg" else: girl, bg = "white", "squircle-dark.svg" return render_svg(compose_svg(art, girl, bg), size) def build_logo_image(art: IconArt, dark: bool = False) -> Image.Image: """1772x1799 wordmark: the girl alone on transparency (no frame), centered. Light = black girl, dark = white girl — the consuming surface's background (navbar light/dark) shows through.""" W, H = 1772, 1799 svg = f'\n {girl_layer(art, "white" if dark else "black", (0, 0, W, H))}\n\n' return render_svg(svg, (W, H)) def target_bytes(art: IconArt, kind: str, arg: object) -> bytes: """Produce the exact bytes for one target. Shared by write + check.""" if kind == "svg": return art.master.encode("utf-8") if kind == "svg_dark": return art.master_dark.encode("utf-8") if kind == "svg_copy": return art.master.encode("utf-8") buf = io.BytesIO() if kind == "png": save_png(render(art.master, arg), buf) elif kind == "png_mac": save_png(render(art.master_mac, arg), buf) elif kind == "png_dark": save_png(render(art.master_dark, arg), buf) elif kind == "png_white": render(art.master, arg, background="#ffffff").convert("RGB").save(buf, "PNG", optimize=True) elif kind == "png_dark_white": render(art.master_dark, arg, background=DARK_HEX).convert("RGB").save(buf, "PNG", optimize=True) elif kind in ("girl_light", "girl_dark"): save_png(girl_mark(art, kind, arg), buf) elif kind == "ico": img = render(art.master, max(arg)) img.save(buf, format="ICO", sizes=[(s, s) for s in arg]) elif kind == "ico_dark": img = render(art.master_dark, max(arg)) img.save(buf, format="ICO", sizes=[(s, s) for s in arg]) elif kind == "icns": img = render(art.master_mac, 1024) frames = [img.resize((s, s), Image.LANCZOS) for s in (16, 32, 64, 128, 256, 512, 1024)] img.save(buf, format="ICNS", append_images=frames[1:]) elif kind == "icns_dark": img = render(art.master_mac_dark, 1024) frames = [img.resize((s, s), Image.LANCZOS) for s in (16, 32, 64, 128, 256, 512, 1024)] img.save(buf, format="ICNS", append_images=frames[1:]) elif kind == "wide": w, h = arg canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0)) paste_centered(canvas, render(art.master, 100)) canvas.save(buf, "PNG") elif kind == "wide_dark": w, h = arg canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0)) paste_centered(canvas, render(art.master_dark, 100)) canvas.save(buf, "PNG") elif kind == "logo": build_logo_image(art, dark=False).save(buf, "PNG") elif kind == "logo_dark": build_logo_image(art, dark=True).save(buf, "PNG") else: raise ValueError(f"unknown kind {kind!r}") return buf.getvalue() def build_art(source: Path) -> tuple[IconArt, IconArt]: """Only desktop outputs carry build identity. Shared branding stays stable.""" art = IconArt(source) tag = os.environ.get("HERMES_PAYLOAD_TAG", "") commit = os.environ.get("HERMES_BUILD_COMMIT", "") if commit: if tag: raise ValueError("Commit builds cannot also select HERMES_PAYLOAD_TAG") if not re.fullmatch(r"[a-f0-9]{40}", commit): raise ValueError("HERMES_BUILD_COMMIT requires an exact full 40-character SHA") return art, IconArt(source, colors=("#e34850", "#4a1117"), commit=commit) if _CANARY_TAG_RE.match(tag.strip()): return art, IconArt(source, colors=("#f5cc32", "#443808")) return art, art def cmd_write(source: Path, out: Path) -> int: """Return failure when any target cannot be generated or verified.""" art, desktop_art = build_art(source) written = 0 failures = 0 for rel, kind, arg in TARGETS: path = out / rel try: path.parent.mkdir(parents=True, exist_ok=True) selected = desktop_art if rel.startswith("apps/desktop/") else art path.write_bytes(target_bytes(selected, kind, arg)) written += 1 except Exception as exc: # noqa: BLE001 - report all, then fail failures += 1 print(f" !! {rel}: FAILED ({exc})") print(f"[write] wrote {written}/{len(TARGETS)} files") print("\n[verify]") for rel, kind, arg in TARGETS: path = out / rel try: if kind in ("svg", "svg_dark", "svg_copy"): print(f" {rel}: {path.stat().st_size} bytes SVG") continue im = Image.open(path) if kind in ("ico", "ico_dark"): sizes = [] try: for i in range(im.n_frames): im.seek(i) sizes.append(im.size) except Exception: sizes = [im.size] print(f" {rel}: ICO {sorted(set(sizes))}") elif kind in ("icns", "icns_dark"): print(f" {rel}: ICNS {im.size} (container)") else: print(f" {rel}: {im.format} {im.size}") except Exception as exc: failures += 1 print(f" {rel}: VERIFY FAILED ({exc})") return int(failures > 0) def cmd_check(source: Path, out: Path) -> int: """Structural verification: regenerate every target in memory and assert the invariants that actually matter (there are no committed bytes to byte-compare — outputs are generated on demand).""" art, desktop_art = build_art(source) problems: list[str] = [] # every target must generate without error for rel, kind, arg in TARGETS: try: selected = desktop_art if rel.startswith("apps/desktop/") else art target_bytes(selected, kind, arg) except Exception as exc: # noqa: BLE001 problems.append(f"{rel}: REGENERATE FAILED ({exc})") # PNG targets must have the expected format + size for rel, (fmt, size) in CHECK_SIZES.items(): path = out / rel if not path.exists(): problems.append(f"{rel}: MISSING (expected generated file)") continue try: im = Image.open(path) if im.format != fmt or im.size != size: problems.append(f"{rel}: got {im.format} {im.size}, expected {fmt} {size}") except Exception as exc: # noqa: BLE001 problems.append(f"{rel}: UNREADABLE ({exc})") # squircles must keep transparent corners (alpha extrema include 0) for rel in ( "apps/desktop/assets/icon.png", "apps/desktop/assets/icon-dark.png", "apps/desktop/public/nous-girl.png", "apps/desktop/public/nous-girl-dark.png", "apps/desktop/public/apple-touch-icon.png", ): path = out / rel if not path.exists(): continue alpha = Image.open(path).convert("RGBA").getchannel("A") lo, hi = alpha.getextrema() if lo != 0 or hi != 255: problems.append(f"{rel}: alpha extrema {alpha.getextrema()}, expected (0, 255) transparent corners") # containers must have the right frame sets (parse ICO headers directly — # PIL's ICO n_frames is unreliable across versions) def ico_sizes(path: Path) -> list[int]: data = path.read_bytes() count = int.from_bytes(data[4:6], "little") sizes = [] for i in range(count): entry = data[6 + i * 16 : 6 + (i + 1) * 16] w = entry[0] or 256 h = entry[1] or 256 sizes.append(w) return sorted(set(sizes)) for rel, sizes in ( ("apps/desktop/assets/icon.ico", [16, 24, 32, 48, 64, 128, 256]), ("apps/desktop/assets/icon-dark.ico", [16, 24, 32, 48, 64, 128, 256]), ("apps/bootstrap-installer/src-tauri/icons/icon.ico", [16, 32, 64, 128, 256]), ): path = out / rel if not path.exists(): problems.append(f"{rel}: MISSING") continue try: got = ico_sizes(path) if got != sizes: problems.append(f"{rel}: ICO frames {got}, expected {sizes}") except Exception as exc: # noqa: BLE001 problems.append(f"{rel}: UNREADABLE ({exc})") if not problems: print(f"[ok] all {len(TARGETS)} targets generate and pass structural checks") return 0 print(f"[check] {len(problems)} problem(s):") for line in problems: print(f" - {line}") return 1 def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--source", type=Path, default=Path(__file__).resolve().parent.parent) parser.add_argument("--out", type=Path, help="Output root (defaults to source for developer builds)") parser.add_argument("--check", action="store_true") args = parser.parse_args() source = args.source.resolve() out = (args.out or source).resolve() command = cmd_check if args.check else cmd_write sys.exit(command(source, out)) if __name__ == "__main__": main()