fix(icons): stop macOS drawing the app icon on a legacy plate (#40)
On macOS 26+ the Dock and any launcher that resolves app icons through
NSWorkspace.icon(forFile:) (Spotlight, Vicinae, Raycast, ...) drew a grey
rounded-square "legacy plate" behind Gridline's icon, i.e. a white border
around the app icon at every render size below ~72px.
Reproduced outside any launcher: NSWorkspace.icon(forFile:) on
Gridline.app returns the plated icon at 16-64px and the clean icon at
>=72px. The trigger lives in icon.icns, not in the app -- moving that file
into Zed.app reproduces the border, moving Zed's icns into Gridline.app
removes it.
Two things in the file cause it:
- ic12 (64x64) -> plates the 40-64px range
- is32/s8mk (16x16 RGB+mask)
il32/l8mk (32x32 RGB+mask) -> plates the 16-32px range
macOS treats padded small representations as legacy, and this icns carries
them in both places.
Fix, in two parts.
1. Strip those chunks. scripts/fix-icns.mjs does it; `tauri icon` re-emits
them on every run, so it has to be a committed, repeatable step.
scripts/verify-app-icon.mjs is the regression check: it builds a
throwaway .app around the committed icon.icns, asks AppKit to render it
at 16..512px and fails if any size comes back plated. It fails (6/11
plated) on the previous icns and passes on this one.
2. Regenerate the raster set from icon.svg via
scripts/regenerate-icons.sh, so the icons directory matches its source
of truth again. The SVG has been full-bleed since the initial commit
(md5 unchanged at d872e42/3dab09f/6854d88/HEAD) while the rasters were
replaced with a hand-tuned padded version at v0.2.0 and had drifted
from it ever since. The script generates into a temp dir because
`tauri icon` only takes one output directory and would otherwise drop
generated android/ and ios/ trees into the desktop-only icons dir.
No visual change on macOS: IconServices normalizes app-icon artwork when
rendering, so padded and full-bleed sets come out pixel-identical there
(same bbox and art fraction at 48/128/512px).
Windows and Linux do consume the PNG/ICO directly, so the icon there does
change from padded to full-bleed -- consistent with the SVG, but a
visible difference worth eyeballing before release.
Verified: verify-app-icon passes at 16, 24, 32, 40, 48, 64, 72, 96, 128,
256, 512px; iconutil and `file` still decode the new .icns and .ico
(6 sizes); frontend tests pass (131 files / 1134 tests).
@@ -161,10 +161,30 @@ bun run --filter gridline-desktop build # Desktop production build
|
|||||||
bun run dev:www # Website dev server (Astro)
|
bun run dev:www # Website dev server (Astro)
|
||||||
bun run build:www # Website static build (www/dist/)
|
bun run build:www # Website static build (www/dist/)
|
||||||
bun run test # Desktop frontend tests (vitest)
|
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 build # Rust backend only (from desktop/src-tauri/)
|
||||||
cargo test # Rust tests (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
|
### 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.
|
**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.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1001 B |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 20 KiB |
@@ -8,6 +8,8 @@
|
|||||||
"tauri:dev": "bun run --filter gridline-desktop tauri:dev",
|
"tauri:dev": "bun run --filter gridline-desktop tauri:dev",
|
||||||
"dev:www": "bun run --filter gridline-www dev",
|
"dev:www": "bun run --filter gridline-www dev",
|
||||||
"build:www": "bun run --filter gridline-www build",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`);
|
||||||
@@ -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"
|
||||||
@@ -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"),
|
||||||
|
`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleIdentifier</key><string>com.gridline.iconcheck.${process.pid}${Date.now()}</string>
|
||||||
|
<key>CFBundleName</key><string>IconCheck</string>
|
||||||
|
<key>CFBundleIconFile</key><string>icon.icns</string>
|
||||||
|
<key>CFBundlePackageType</key><string>APPL</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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).`);
|
||||||