diff --git a/AGENTS.md b/AGENTS.md index 5f63d7b..a8bb99f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,10 +161,30 @@ bun run --filter gridline-desktop build # Desktop production build bun run dev:www # Website dev server (Astro) bun run build:www # Website static build (www/dist/) bun run test # Desktop frontend tests (vitest) +bun run icons # Regenerate desktop/src-tauri/icons/* from icon.svg +bun run icons:verify # Check the macOS icon isn't rendered on a legacy plate cargo build # Rust backend only (from desktop/src-tauri/) cargo test # Rust tests (from desktop/src-tauri/) ``` +### App icon + +`desktop/src-tauri/icons/icon.svg` is the source of truth; every PNG/ICNS/ICO beside it is generated output. + +Regenerate **only** through the script — never with a bare `tauri icon`: + +```bash +bun run icons # tauri icon -> temp dir, copy back the desktop set, then fix the ICNS +bun run icons:verify # asserts AppKit renders the icon cleanly at 16px..512px +``` + +Two reasons the wrapper is mandatory: + +1. `tauri icon` takes one output directory for all platforms, so running it in place would drop generated `android/` and `ios/` trees into the desktop-only icons directory. The script generates into a temp dir and copies back just the desktop files. +2. `tauri icon` emits legacy `is32`/`s8mk`/`il32`/`l8mk` (+ `ic12`) representations. **macOS 26+ then draws a grey "legacy plate" behind the icon for every render size below ~72px** — a white border around the app icon in the Dock and in any launcher that resolves icons via `NSWorkspace.icon(forFile:)` (Spotlight, Vicinae, Raycast). `scripts/fix-icns.mjs` strips those chunks, which makes the icon render cleanly at every size. + +`scripts/verify-app-icon.mjs` is the regression check for that: it builds a throwaway `.app` around the committed `icon.icns`, asks AppKit for the icon (the same call launchers make) and fails if any size comes back plated. Run it after touching icons, and after bumping Tauri (a newer `tauri icon` may change what it emits). + ### Branch & PR workflow **GitHub is the only remote that matters.** The canonical repo is `github` → [AdrianBonpin/gridline](https://github.com/AdrianBonpin/gridline) (default branch `prod`, issues, PRs, Actions, releases). The `origin` remote (`git.ranio.xyz/adrianbonpin/gridline`) is a **just-in-case mirror** — do not push branches, open PRs, or publish releases/tags there. If you must mirror something manually, push to `github` first. diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png index f518e2b..0d20461 100644 Binary files a/desktop/src-tauri/icons/128x128.png and b/desktop/src-tauri/icons/128x128.png differ diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png index 56b88c4..969794f 100644 Binary files a/desktop/src-tauri/icons/128x128@2x.png and b/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png index ac23d27..1e2baf5 100644 Binary files a/desktop/src-tauri/icons/32x32.png and b/desktop/src-tauri/icons/32x32.png differ diff --git a/desktop/src-tauri/icons/64x64.png b/desktop/src-tauri/icons/64x64.png index ffe865c..c7f74e0 100644 Binary files a/desktop/src-tauri/icons/64x64.png and b/desktop/src-tauri/icons/64x64.png differ diff --git a/desktop/src-tauri/icons/Square107x107Logo.png b/desktop/src-tauri/icons/Square107x107Logo.png index 3e7ad58..19f6cd6 100644 Binary files a/desktop/src-tauri/icons/Square107x107Logo.png and b/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/desktop/src-tauri/icons/Square142x142Logo.png b/desktop/src-tauri/icons/Square142x142Logo.png index 9ba93c5..0611c12 100644 Binary files a/desktop/src-tauri/icons/Square142x142Logo.png and b/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/desktop/src-tauri/icons/Square150x150Logo.png b/desktop/src-tauri/icons/Square150x150Logo.png index 2e5db62..6b6fd33 100644 Binary files a/desktop/src-tauri/icons/Square150x150Logo.png and b/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/desktop/src-tauri/icons/Square284x284Logo.png b/desktop/src-tauri/icons/Square284x284Logo.png index 9e6aa18..9d4fd22 100644 Binary files a/desktop/src-tauri/icons/Square284x284Logo.png and b/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/desktop/src-tauri/icons/Square30x30Logo.png b/desktop/src-tauri/icons/Square30x30Logo.png index 320969f..b6a0ee9 100644 Binary files a/desktop/src-tauri/icons/Square30x30Logo.png and b/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/desktop/src-tauri/icons/Square310x310Logo.png b/desktop/src-tauri/icons/Square310x310Logo.png index 83e38f8..dd46acc 100644 Binary files a/desktop/src-tauri/icons/Square310x310Logo.png and b/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/desktop/src-tauri/icons/Square44x44Logo.png b/desktop/src-tauri/icons/Square44x44Logo.png index 2fe1cfa..b51fca8 100644 Binary files a/desktop/src-tauri/icons/Square44x44Logo.png and b/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/desktop/src-tauri/icons/Square71x71Logo.png b/desktop/src-tauri/icons/Square71x71Logo.png index 5a3bee0..11db2a4 100644 Binary files a/desktop/src-tauri/icons/Square71x71Logo.png and b/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/desktop/src-tauri/icons/Square89x89Logo.png b/desktop/src-tauri/icons/Square89x89Logo.png index ad436c2..fdae39b 100644 Binary files a/desktop/src-tauri/icons/Square89x89Logo.png and b/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/desktop/src-tauri/icons/StoreLogo.png b/desktop/src-tauri/icons/StoreLogo.png index 2994200..c2447bf 100644 Binary files a/desktop/src-tauri/icons/StoreLogo.png and b/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns index 1b61d30..0be39a5 100644 Binary files a/desktop/src-tauri/icons/icon.icns and b/desktop/src-tauri/icons/icon.icns differ diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico index de821ac..9911f38 100644 Binary files a/desktop/src-tauri/icons/icon.ico and b/desktop/src-tauri/icons/icon.ico differ diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png index 3729f95..c2d622b 100644 Binary files a/desktop/src-tauri/icons/icon.png and b/desktop/src-tauri/icons/icon.png differ diff --git a/package.json b/package.json index 8341de3..96a313f 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "tauri:dev": "bun run --filter gridline-desktop tauri:dev", "dev:www": "bun run --filter gridline-www dev", "build:www": "bun run --filter gridline-www build", - "test": "bun run --filter gridline-desktop test" + "test": "bun run --filter gridline-desktop test", + "icons": "bash scripts/regenerate-icons.sh", + "icons:verify": "bun scripts/verify-app-icon.mjs" } } diff --git a/scripts/fix-icns.mjs b/scripts/fix-icns.mjs new file mode 100755 index 0000000..23fc131 --- /dev/null +++ b/scripts/fix-icns.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env bun +/** + * Strip the icon representations that make macOS render Gridline's icon on a + * "legacy plate" at small sizes. + * + * ## Why this exists + * + * On macOS 26+ (Tahoe) IconServices wraps *legacy* app icons in a light + * rounded-square plate for render sizes below ~72px. `tauri icon` emits exactly + * those legacy representations, so the artifact shows up in the Dock and in any + * launcher that resolves app icons through `NSWorkspace.icon(forFile:)` + * (Spotlight, Vicinae, Raycast, …): the app icon gets a white/grey border. + * + * Two representations in `tauri icon` output trigger it, independently: + * + * - `ic12` (64x64) -> plates the 40-64px range + * - `is32` / `s8mk` (16x16 RGB+mask) + * `il32` / `l8mk` (32x32 RGB+mask) -> plates the 16-32px range + * + * Dropping them leaves a perfectly valid family (ic07/ic08/ic09/ic10/ic11/ic13/ + * ic14 — 32, 128, 256, 512 and 1024px), and macOS then renders cleanly at every + * size from 16px to 1024px. There is no visible quality cost: every small size + * is resampled from the 128px+ reps with high-quality interpolation. + * + * Note `tauri icon` re-emits all of these chunks on every run, which is why this + * post-processing step is committed rather than done once by hand. Always + * regenerate through `scripts/regenerate-icons.sh`, never with bare + * `tauri icon`. + * + * ## Usage + * + * bun scripts/fix-icns.mjs [icns-path] [--keep-ic12] + * + * Defaults to desktop/src-tauri/icons/icon.icns. Edits the file in place and + * prints a before/after chunk summary. + */ +import { readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const DEFAULT_ICNS = "desktop/src-tauri/icons/icon.icns"; + +// 16x16 / 32x32 RGB + 8-bit mask pairs. These predate macOS 10.7's PNG chunks +// and are what IconServices uses for the smallest sizes. +const LEGACY_CHUNKS = ["is32", "s8mk", "il32", "l8mk"]; + +// 32x32@2x = 64x64. Legitimate modern chunk, but with legacy (padded) artwork +// macOS plates it across the 40-64px range. +const IC12 = "ic12"; + +const args = process.argv.slice(2); +const keepIc12 = args.includes("--keep-ic12"); +const pathArg = args.find((a) => !a.startsWith("-")); + +const unknown = args.filter((a) => a.startsWith("-") && a !== "--keep-ic12"); +if (unknown.length > 0) { + console.error(`fix-icns: unknown option(s): ${unknown.join(", ")}`); + console.error("usage: bun scripts/fix-icns.mjs [icns-path] [--keep-ic12]"); + process.exit(2); +} + +const target = resolve(REPO_ROOT, pathArg ?? DEFAULT_ICNS); + +/** Read an .icns container into its ordered chunks. */ +function readIcns(buf) { + if (buf.length < 8 || buf.toString("latin1", 0, 4) !== "icns") { + throw new Error(`not an .icns file: ${target}`); + } + const declared = buf.readUInt32BE(4); + if (declared !== buf.length) { + throw new Error( + `truncated/oversized .icns: header says ${declared} bytes, file is ${buf.length}`, + ); + } + const chunks = []; + let offset = 8; + while (offset + 8 <= buf.length) { + const type = buf.toString("latin1", offset, offset + 4); + const size = buf.readUInt32BE(offset + 4); + if (size < 8 || offset + size > buf.length) { + throw new Error( + `corrupt .icns: chunk "${type}" at offset ${offset} declares ${size} bytes`, + ); + } + chunks.push({ type, bytes: buf.subarray(offset, offset + size) }); + offset += size; + } + return chunks; +} + +function writeIcns(chunks) { + const body = Buffer.concat(chunks.map((c) => c.bytes)); + const header = Buffer.alloc(8); + header.write("icns", 0, "latin1"); + header.writeUInt32BE(body.length + 8, 4); + return Buffer.concat([header, body]); +} + +const drop = new Set(LEGACY_CHUNKS); +if (!keepIc12) drop.add(IC12); + +let original; +try { + original = readFileSync(target); +} catch (err) { + console.error(`fix-icns: cannot read ${target}: ${err.message}`); + process.exit(1); +} + +let chunks; +try { + chunks = readIcns(original); +} catch (err) { + console.error(`fix-icns: ${err.message}`); + process.exit(1); +} + +const before = chunks.map((c) => c.type); +const kept = chunks.filter((c) => !drop.has(c.type)); +const removed = before.filter((t) => drop.has(t)); + +if (removed.length === 0) { + console.log(`fix-icns: ${target} is already clean (no ${[...drop].join("/")} chunks)`); + process.exit(0); +} + +if (kept.length === 0) { + console.error("fix-icns: refusing to write an icon family with no chunks left"); + process.exit(1); +} + +const out = writeIcns(kept); +// Write to a sibling temp file then rename, so an interrupted run can't leave a +// half-written icon in the tree. +const tmp = `${target}.tmp`; +writeFileSync(tmp, out); +renameSync(tmp, target); + +console.log(`fix-icns: ${target}`); +console.log(` removed: ${removed.join(", ")}`); +console.log(` chunks: ${kept.map((c) => c.type).join(", ")}`); +console.log(` ${original.length} -> ${out.length} bytes`); diff --git a/scripts/regenerate-icons.sh b/scripts/regenerate-icons.sh new file mode 100755 index 0000000..ec8b0e7 --- /dev/null +++ b/scripts/regenerate-icons.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# +# Regenerate desktop/src-tauri/icons/* from icons/icon.svg. +# +# Usage: +# scripts/regenerate-icons.sh +# +# `icon.svg` is the source of truth for the app icon; the PNG/ICNS/ICO set next +# to it is generated output and must be regenerated whenever the SVG changes. +# +# Always regenerate through this script rather than calling `tauri icon` +# directly, for two reasons: +# +# 1. `tauri icon` only supports a single output directory for all platforms, so +# running it in place would also drop generated `android/` and `ios/` icon +# trees into the (desktop-only) icons directory. This generates into a temp +# dir and copies back just the desktop set. +# +# 2. `tauri icon` emits the legacy `is32`/`s8mk`/`il32`/`l8mk` and `ic12` +# representations, which make macOS 26+ draw the app icon on a grey +# "legacy plate" at small sizes (a white border in the Dock and in any +# launcher that uses NSWorkspace). fix-icns.mjs strips them — see that file +# for the details and the measurements. +# +# The committed icon set was last regenerated because those stale rasters meant +# the shipped icon no longer matched icon.svg at all. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DESKTOP_DIR="$REPO_ROOT/desktop" +ICONS_DIR="$DESKTOP_DIR/src-tauri/icons" +SOURCE_SVG="$ICONS_DIR/icon.svg" + +if ! command -v bun >/dev/null 2>&1; then + echo "error: bun is required (https://bun.sh)" >&2 + exit 1 +fi + +if [ ! -f "$SOURCE_SVG" ]; then + echo "error: missing icon source $SOURCE_SVG" >&2 + exit 1 +fi + +# Files `tauri icon` produces for the desktop/Windows/macOS targets. The +# android/ and ios/ output is intentionally ignored. +DESKTOP_ICON_FILES=( + 32x32.png + 64x64.png + 128x128.png + 128x128@2x.png + icon.png + icon.icns + icon.ico + Square30x30Logo.png + Square44x44Logo.png + Square71x71Logo.png + Square89x89Logo.png + Square107x107Logo.png + Square142x142Logo.png + Square150x150Logo.png + Square284x284Logo.png + Square310x310Logo.png + StoreLogo.png +) + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +echo "==> rendering $SOURCE_SVG with tauri icon" +( cd "$DESKTOP_DIR" && bunx tauri icon "src-tauri/icons/icon.svg" -o "$TMP_DIR" ) + +for file in "${DESKTOP_ICON_FILES[@]}"; do + if [ ! -f "$TMP_DIR/$file" ]; then + echo "error: tauri icon did not produce $file" >&2 + exit 1 + fi + cp "$TMP_DIR/$file" "$ICONS_DIR/$file" + echo " updated icons/$file" +done + +echo "==> stripping legacy ICNS representations" +bun "$REPO_ROOT/scripts/fix-icns.mjs" "$ICONS_DIR/icon.icns" + +echo +echo "icons regenerated. Verify with:" +echo " bun $REPO_ROOT/scripts/verify-app-icon.mjs" diff --git a/scripts/verify-app-icon.mjs b/scripts/verify-app-icon.mjs new file mode 100755 index 0000000..c46b1cb --- /dev/null +++ b/scripts/verify-app-icon.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env bun +/** + * Verify that the repo's macOS app icon is not being rendered on a "legacy + * plate" — the grey/white rounded-square border macOS 26+ draws behind app + * icons at small sizes (Dock, Spotlight, Vicinae, Raycast, …). + * + * Usage: + * bun scripts/verify-app-icon.mjs + * + * Builds a throwaway .app around desktop/src-tauri/icons/icon.icns, asks AppKit + * (`NSWorkspace.icon(forFile:)` — the same API launchers use) to render it at a + * range of sizes, and samples the pixels to detect the plate. Exits non-zero if + * any size comes out plated. + * + * This is the regression check for `fix-icns.mjs`: `tauri icon` re-adds the + * offending representations every time it runs, so run this (or + * `scripts/regenerate-icons.sh`, which calls the fix) after touching icons. + * + * macOS-only. Requires Swift (Xcode or the Command Line Tools). + */ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const ICNS = join(REPO_ROOT, "desktop/src-tauri/icons/icon.icns"); + +if (process.platform !== "darwin") { + console.log("verify-app-icon: not macOS, nothing to check"); + process.exit(0); +} + +if (!existsSync(ICNS)) { + console.error(`verify-app-icon: missing ${ICNS}`); + process.exit(1); +} + +const work = mkdtempSync(join(tmpdir(), "gridline-icon-check-")); +process.on("exit", () => rmSync(work, { recursive: true, force: true })); + +// The synthetic bundle just needs to be a directory with an Info.plist pointing +// at the icon. A unique path + bundle identifier each run keeps IconServices' +// cache from serving a previous run's verdict. +const appDir = join(work, "IconCheck.app"); +mkdirSync(join(appDir, "Contents", "Resources"), { recursive: true }); +writeFileSync(join(appDir, "Contents", "Resources", "icon.icns"), readFileSync(ICNS)); +writeFileSync( + join(appDir, "Contents", "Info.plist"), + ` + + + + CFBundleIdentifiercom.gridline.iconcheck.${process.pid}${Date.now()} + CFBundleNameIconCheck + CFBundleIconFileicon.icns + CFBundlePackageTypeAPPL + + +`, +); + +// Samples the pixel just inside the icon square at 12% height. A legacy plate +// there is a neutral light grey; the artwork itself is dark at that point. +const SWIFT = ` +import AppKit + +let appPath = CommandLine.arguments[1] +let sizes = CommandLine.arguments[2].split(separator: ",").compactMap { Int($0) } +let image = NSWorkspace.shared.icon(forFile: appPath) + +for px in sizes { + let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: px, pixelsHigh: px, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .calibratedRGB, bytesPerRow: px * 4, bitsPerPixel: 32)! + let ctx = NSGraphicsContext(bitmapImageRep: rep)! + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = ctx + ctx.imageInterpolation = .high + NSColor.clear.setFill() + NSRect(x: 0, y: 0, width: px, height: px).fill(using: .copy) + let natural = image.size + let scale = min(CGFloat(px) / natural.width, CGFloat(px) / natural.height) + let w = natural.width * scale + let h = natural.height * scale + image.draw(in: NSRect(x: (CGFloat(px) - w) / 2, y: (CGFloat(px) - h) / 2, width: w, height: h), + from: .zero, operation: .sourceOver, fraction: 1.0, respectFlipped: true, hints: nil) + NSGraphicsContext.restoreGraphicsState() + + let probe = rep.colorAt(x: px / 2, y: max(1, Int(Double(px) * 0.12))) + let r = probe?.redComponent ?? 0 + let g = probe?.greenComponent ?? 0 + let b = probe?.blueComponent ?? 0 + let a = probe?.alphaComponent ?? 0 + let v = Int((r * 255).rounded()) + let plated = a > 0.78 && abs(r - g) < 0.04 && abs(g - b) < 0.05 && v > 170 && v < 245 + print("\\(px) \\(plated ? "PLATED" : "OK") v=\\(v)") +} +`; + +const swiftFile = join(work, "probe.swift"); +writeFileSync(swiftFile, SWIFT); +const sizes = [16, 24, 32, 40, 48, 64, 72, 96, 128, 256, 512]; + +function runSwift(env) { + return spawnSync("swift", [swiftFile, appDir, sizes.join(",")], { + encoding: "utf8", + env: { ...process.env, ...env }, + }); +} + +// `swift` from a full Xcode install refuses to run until its licence is accepted; +// the Command Line Tools toolchain is a drop-in fallback for a plain AppKit program. +let result = runSwift({}); +if (result.error || result.status !== 0) { + const clt = "/Library/Developer/CommandLineTools"; + if (existsSync(join(clt, "usr/bin/swift"))) { + result = runSwift({ DEVELOPER_DIR: clt }); + } +} + +if (result.error || !result.stdout) { + console.error("verify-app-icon: could not run Swift/AppKit"); + console.error(result.error?.message ?? result.stderr?.trim() ?? ""); + process.exit(1); +} + +const lines = result.stdout.trim().split("\n").filter((l) => l.includes("PLATED") || l.includes("OK")); +const plated = lines.filter((l) => l.split(" ")[1] === "PLATED"); + +console.log("macOS icon render check (NSWorkspace.icon(forFile:))"); +for (const line of lines) { + const [size, verdict] = line.split(" "); + console.log(` ${size.padStart(4)}px ${verdict === "PLATED" ? "PLATED — grey legacy plate" : "ok"}`); +} + +if (plated.length > 0) { + console.error( + `\nFAIL: ${plated.length}/${lines.length} sizes render on a legacy plate.\n` + + "The icns still carries the legacy representations (ic12 / is32 / s8mk / il32 / l8mk).\n" + + "Run: bun scripts/fix-icns.mjs && bun scripts/regenerate-icons.sh", + ); + process.exit(1); +} + +console.log(`\nPASS: clean at every tested size (${sizes.join(", ")}px).`);