@nativedesktop/cli 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nativedesktop/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "The nd CLI: dev-runs, builds, and packages NativeDesktop apps",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,17 +12,19 @@
12
12
  "homepage": "https://github.com/FormalSnake/NativeDesktop#readme",
13
13
  "bugs": "https://github.com/FormalSnake/NativeDesktop/issues",
14
14
  "publishConfig": {
15
- "access": "public"
16
- },
17
- "files": ["src"],
18
- "bin": {
19
- "nd": "src/index.ts"
20
- },
21
- "exports": {
22
- ".": "./src/index.ts",
23
- "./config": "./src/config.ts"
24
- },
25
- "dependencies": {
26
- "@nativedesktop/host": "0.1.0"
27
- }
15
+ "access": "public"
16
+ },
17
+ "files": [
18
+ "src"
19
+ ],
20
+ "bin": {
21
+ "nd": "src/index.ts"
22
+ },
23
+ "exports": {
24
+ ".": "./src/index.ts",
25
+ "./config": "./src/config.ts"
26
+ },
27
+ "dependencies": {
28
+ "@nativedesktop/host": "0.1.2"
29
+ }
28
30
  }
package/src/config.ts CHANGED
@@ -32,11 +32,45 @@ export interface UrlScheme {
32
32
  name?: string;
33
33
  }
34
34
 
35
+ /**
36
+ * A solid color, or a two-stop gradient running top to bottom. Colors are hex
37
+ * (`#0a84ff`, `#0a84ffcc`) or an Icon Composer color string
38
+ * (`display-p3:0.04,0.52,1,1`).
39
+ */
40
+ export type AppIconFill = string | { gradient: [string, string] };
41
+
42
+ export interface AppIconLayer {
43
+ /** App-relative .svg or .png, drawn full-bleed on the 1024x1024 icon grid. */
44
+ image: string;
45
+ /** Layer name shown in Icon Composer. Default: the file's basename. */
46
+ name?: string;
47
+ /** Liquid Glass specular highlight. Default true. */
48
+ specular?: boolean;
49
+ /** Glass translucency 0..1, or false to turn it off. Default 0.5. */
50
+ translucency?: number | false;
51
+ /** Drop shadow opacity 0..1, or false to turn it off. Default 0.5. */
52
+ shadow?: number | false;
53
+ }
54
+
55
+ /** A macOS 26 layered icon, built into a .icon bundle at package time. */
56
+ export interface AppIconLayered {
57
+ /** Fill behind the layers. Omitted leaves the icon body transparent. */
58
+ background?: AppIconFill;
59
+ /** 1 to 4 layers, back to front. A bare string is shorthand for `{ image }`. */
60
+ layers: Array<string | AppIconLayer>;
61
+ }
62
+
35
63
  export interface AppIcon {
36
- /** Shared icon source (.png/.svg/.icns path, app-relative). */
64
+ /** Shared icon source (.png/.svg/.icns/.iconset/.icon path, app-relative). */
37
65
  source?: string;
66
+ /** macOS override: .icon, .icns, .iconset, .png, or .svg. */
38
67
  macos?: string;
68
+ /** Linux override: .png, .svg, or .icon. */
39
69
  linux?: string;
70
+ /** Layered macOS 26 icon composed from separate art files. Used on macOS
71
+ * unless `macos` overrides it, and flattens to the Linux icon when neither
72
+ * `linux` nor `source` is set. */
73
+ layered?: AppIconLayered;
40
74
  }
41
75
 
42
76
  export interface AppIdentity {
@@ -19,6 +19,31 @@ function which(tool: string): boolean {
19
19
  return Bun.which(tool) !== null;
20
20
  }
21
21
 
22
+ /** Every mac icon source needs a different converter, so the check follows the
23
+ * same resolution order `installMacIcon` uses. */
24
+ function macIconChecks(config: NativeDesktopConfig): Check[] {
25
+ const icon = typeof config.app?.icon === "string" ? { source: config.app.icon } : config.app?.icon;
26
+ if (!icon) return [];
27
+ const source = icon.layered ? ".icon" : icon.macos ?? icon.source;
28
+ if (!source) return [];
29
+ if (source.endsWith(".icon")) {
30
+ return [which("xcrun")
31
+ ? { name: "icon-tools", status: "ok", detail: "layered icon configured, actool available" }
32
+ : { name: "icon-tools", status: "error", detail: "layered icon configured but Xcode's actool is missing" }];
33
+ }
34
+ if (source.endsWith(".icns")) return [];
35
+ if (source.endsWith(".iconset")) {
36
+ return which("iconutil") ? [] : [{ name: "icon-tools", status: "error", detail: "iconset icon configured but iconutil is missing" }];
37
+ }
38
+ if (source.endsWith(".svg") && !["rsvg-convert", "magick", "convert", "qlmanage"].some(which)) {
39
+ return [{ name: "icon-tools", status: "error", detail: "SVG icon configured but no rasterizer (librsvg, ImageMagick, or qlmanage) is available" }];
40
+ }
41
+ if (!(which("sips") && which("iconutil"))) {
42
+ return [{ name: "icon-tools", status: "error", detail: `${source} icon configured but sips/iconutil are missing` }];
43
+ }
44
+ return [];
45
+ }
46
+
22
47
  export async function collectChecks(cwd: string): Promise<Check[]> {
23
48
  const checks: Check[] = [];
24
49
  const configPath = resolve(cwd, "nativedesktop.config.ts");
@@ -63,10 +88,17 @@ export async function collectChecks(cwd: string): Promise<Check[]> {
63
88
  try {
64
89
  const backend = resolveBackend(process.platform === "darwin" ? { backend: config.package?.mac?.backend ?? "appkit" } : { backend: "gtk" });
65
90
  const { fresh } = hostBinaryCandidates(backend);
66
- const found = prebuiltHostBinary(backend) ?? fresh.find(existsSync);
67
- checks.push(found
68
- ? { name: "host", status: "ok", detail: found }
69
- : { name: "host", status: "warn", detail: `no ${backend} host binary built yet (nd dev / nd package builds it on first run in a source checkout)` });
91
+ // ND_HOST_BINARY is what `nd dev` will actually run, so report that rather
92
+ // than the prebuilt it overrides.
93
+ const explicit = process.env.ND_HOST_BINARY;
94
+ const found = explicit || prebuiltHostBinary(backend) || fresh.find(existsSync);
95
+ if (explicit && !existsSync(explicit)) {
96
+ checks.push({ name: "host", status: "error", detail: `ND_HOST_BINARY points at ${explicit}, which does not exist` });
97
+ } else {
98
+ checks.push(found
99
+ ? { name: "host", status: "ok", detail: explicit ? `${found} (ND_HOST_BINARY)` : found }
100
+ : { name: "host", status: "warn", detail: `no ${backend} host binary built yet (nd dev / nd package builds it on first run in a source checkout)` });
101
+ }
70
102
  } catch (err) {
71
103
  checks.push({ name: "host", status: "error", detail: String(err) });
72
104
  }
@@ -75,10 +107,7 @@ export async function collectChecks(cwd: string): Promise<Check[]> {
75
107
  checks.push(which("codesign")
76
108
  ? { name: "codesign", status: "ok", detail: "codesign available" }
77
109
  : { name: "codesign", status: "error", detail: "codesign not found (install the Xcode command line tools)" });
78
- const icon = typeof config.app?.icon === "string" ? config.app.icon : config.app?.icon?.macos ?? config.app?.icon?.source;
79
- if (icon && icon.endsWith(".png") && !(which("sips") && which("iconutil"))) {
80
- checks.push({ name: "icon-tools", status: "error", detail: "PNG icon configured but sips/iconutil are missing" });
81
- }
110
+ checks.push(...macIconChecks(config));
82
111
  } else if (process.platform === "linux") {
83
112
  checks.push(which("appimagetool") || which("mksquashfs")
84
113
  ? { name: "appimage", status: "ok", detail: which("appimagetool") ? "appimagetool available" : "mksquashfs fallback available" }
@@ -0,0 +1,315 @@
1
+ // macOS 26 layered app icons. An Icon Composer ".icon" is a directory holding an
2
+ // icon.json manifest next to an Assets/ folder of layer art; actool compiles one
3
+ // into the Assets.car macOS 26 renders plus the .icns older releases fall back
4
+ // to. Two constraints shape the code here: actool matches --app-icon against the
5
+ // bundle's BASENAME and exits 0 having written nothing when they disagree, and
6
+ // icon.json colors must be "<space>:r,g,b,a" floats (a hex string is rejected).
7
+ //
8
+ // The same composition also flattens to a single SVG for Linux, so one set of
9
+ // layer art dresses both platforms.
10
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { basename, extname, join, resolve } from "node:path";
13
+ import type { AppIconFill, AppIconLayer, AppIconLayered } from "../config.ts";
14
+
15
+ /** Runs an icon tool, surfacing its output only when it fails (actool prints a
16
+ * result plist to stdout on every successful compile). */
17
+ export async function runTool(command: string[]): Promise<void> {
18
+ const proc = Bun.spawn(command, { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
19
+ const [out, err, code] = await Promise.all([
20
+ new Response(proc.stdout).text(),
21
+ new Response(proc.stderr).text(),
22
+ proc.exited,
23
+ ]);
24
+ if (code === 0) return;
25
+ if (out) process.stderr.write(out);
26
+ if (err) process.stderr.write(err);
27
+ throw new Error(`nd: icon tool failed (${command.join(" ")})`);
28
+ }
29
+
30
+ type Rgba = [number, number, number, number];
31
+
32
+ /** Accepts #rgb/#rgba/#rrggbb/#rrggbbaa or an Icon Composer "<space>:r,g,b,a" string. */
33
+ function parseColor(value: string): Rgba {
34
+ const colon = value.indexOf(":");
35
+ if (colon >= 0) {
36
+ const parts = value.slice(colon + 1).split(",").map(Number);
37
+ if (parts.length < 3 || parts.some(Number.isNaN)) throw new Error(`nd: unreadable icon color "${value}"`);
38
+ return [parts[0]!, parts[1]!, parts[2]!, parts[3] ?? 1];
39
+ }
40
+ const hex = value.startsWith("#") ? value.slice(1) : value;
41
+ const wide = hex.length <= 4 ? hex.replace(/./g, (c) => c + c) : hex;
42
+ if (!/^[0-9a-f]{6}([0-9a-f]{2})?$/i.test(wide)) {
43
+ throw new Error(`nd: unsupported icon color "${value}" (want #rrggbb, #rrggbbaa, or "display-p3:r,g,b,a")`);
44
+ }
45
+ const channel = (at: number) => parseInt(wide.slice(at, at + 2), 16) / 255;
46
+ return [channel(0), channel(2), channel(4), wide.length === 8 ? channel(6) : 1];
47
+ }
48
+
49
+ /** A color string icon.json accepts. Color-space inputs pass through untouched;
50
+ * hex is normalized into extended sRGB floats. */
51
+ function iconColor(value: string): string {
52
+ if (value.includes(":")) return value;
53
+ return `extended-srgb:${parseColor(value).map((c) => c.toFixed(5)).join(",")}`;
54
+ }
55
+
56
+ function cssColor(value: string): string {
57
+ const byte = (c: number) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, "0");
58
+ const [r, g, b] = parseColor(value);
59
+ return `#${byte(r)}${byte(g)}${byte(b)}`;
60
+ }
61
+
62
+ function cssOpacity(value: string): number {
63
+ return Math.min(1, Math.max(0, parseColor(value)[3]));
64
+ }
65
+
66
+ function fillManifest(fill: AppIconFill): Record<string, unknown> {
67
+ if (typeof fill === "string") return { solid: iconColor(fill) };
68
+ const [top, bottom] = fill.gradient;
69
+ return { "linear-gradient": [iconColor(top), iconColor(bottom)] };
70
+ }
71
+
72
+ const LAYER_EXTENSIONS = new Set([".svg", ".png"]);
73
+ /** Icon Composer stacks at most four groups, one per depth level. */
74
+ const MAX_LAYERS = 4;
75
+
76
+ interface ResolvedLayer {
77
+ path: string;
78
+ /** Filename inside Assets/, deduplicated across layers from different directories. */
79
+ file: string;
80
+ name: string;
81
+ layer: AppIconLayer;
82
+ }
83
+
84
+ function resolveLayers(layered: AppIconLayered, appDir: string): ResolvedLayer[] {
85
+ const entries = layered.layers ?? [];
86
+ if (!entries.length) throw new Error("nd: app.icon.layered needs at least one layer");
87
+ if (entries.length > MAX_LAYERS) {
88
+ throw new Error(`nd: app.icon.layered takes at most ${MAX_LAYERS} layers (got ${entries.length})`);
89
+ }
90
+ const taken = new Set<string>();
91
+ return entries.map((entry) => {
92
+ const layer = typeof entry === "string" ? { image: entry } : entry;
93
+ const path = resolve(appDir, layer.image);
94
+ const ext = extname(path).toLowerCase();
95
+ if (!LAYER_EXTENSIONS.has(ext)) throw new Error(`nd: unsupported icon layer "${layer.image}" (want .svg or .png)`);
96
+ if (!existsSync(path)) throw new Error(`nd: icon layer not found: ${path}`);
97
+ const stem = basename(path, ext);
98
+ let file = `${stem}${ext}`;
99
+ for (let n = 2; taken.has(file); n++) file = `${stem}-${n}${ext}`;
100
+ taken.add(file);
101
+ return { path, file, name: layer.name ?? stem, layer };
102
+ });
103
+ }
104
+
105
+ /** The shape readIconBundle returns, for a layered config instead of a bundle. */
106
+ export function layeredComposition(layered: AppIconLayered, appDir: string): { background?: AppIconFill; layers: string[] } {
107
+ return { background: layered.background, layers: resolveLayers(layered, appDir).map((layer) => layer.path) };
108
+ }
109
+
110
+ /** The icon.json a layered config compiles to. */
111
+ export function iconManifest(layered: AppIconLayered, appDir: string): string {
112
+ return manifestJson(layered, resolveLayers(layered, appDir));
113
+ }
114
+
115
+ /** One group per layer, so the glass controls stay per-layer in the config. */
116
+ function manifestJson(layered: AppIconLayered, layers: ResolvedLayer[]): string {
117
+ const manifest: Record<string, unknown> = {};
118
+ if (layered.background) manifest.fill = fillManifest(layered.background);
119
+ manifest.groups = layers.map(({ file, name, layer }) => ({
120
+ layers: [{ "image-name": file, name }],
121
+ specular: layer.specular ?? true,
122
+ translucency: layer.translucency === false
123
+ ? { enabled: false, value: 0 }
124
+ : { enabled: true, value: layer.translucency ?? 0.5 },
125
+ shadow: layer.shadow === false
126
+ ? { kind: "none", opacity: 0 }
127
+ : { kind: "neutral", opacity: layer.shadow ?? 0.5 },
128
+ }));
129
+ return `${JSON.stringify(manifest, null, 2)}\n`;
130
+ }
131
+
132
+ /** Writes a .icon bundle at `bundle` from a layered config. */
133
+ export function writeIconBundle(layered: AppIconLayered, appDir: string, bundle: string): void {
134
+ const assets = join(bundle, "Assets");
135
+ mkdirSync(assets, { recursive: true });
136
+ const layers = resolveLayers(layered, appDir);
137
+ for (const { path, file } of layers) cpSync(path, join(assets, file));
138
+ writeFileSync(join(bundle, "icon.json"), manifestJson(layered, layers));
139
+ }
140
+
141
+ /**
142
+ * Compiles a .icon into Assets.car + the legacy .icns inside `resourcesDir`.
143
+ * Returns the CFBundleIconName/CFBundleIconFile value.
144
+ */
145
+ export async function compileIconBundle(bundle: string, resourcesDir: string, deploymentTarget: string): Promise<string> {
146
+ if (!Bun.which("xcrun")) {
147
+ throw new Error(`nd: compiling ${basename(bundle)} needs Xcode's actool (install Xcode, or set app.icon.macos to a prebuilt .icns)`);
148
+ }
149
+ const name = basename(bundle, ".icon");
150
+ const out = mkdtempSync(join(tmpdir(), "nd-actool-"));
151
+ try {
152
+ await runTool([
153
+ "xcrun", "actool", bundle,
154
+ "--app-icon", name,
155
+ "--compile", out,
156
+ "--output-partial-info-plist", join(out, "partial.plist"),
157
+ "--minimum-deployment-target", deploymentTarget,
158
+ "--platform", "macosx",
159
+ "--target-device", "mac",
160
+ ]);
161
+ const car = join(out, "Assets.car");
162
+ // actool keys off the bundle basename and reports success while emitting
163
+ // nothing when the icon it was asked for isn't there, so a missing
164
+ // Assets.car is the only signal that the compile didn't take.
165
+ if (!existsSync(car)) throw new Error(`nd: actool compiled no icon out of ${bundle}`);
166
+ cpSync(car, join(resourcesDir, "Assets.car"));
167
+ const icns = join(out, `${name}.icns`);
168
+ if (existsSync(icns)) cpSync(icns, join(resourcesDir, `${name}.icns`));
169
+ return name;
170
+ } finally {
171
+ rmSync(out, { recursive: true, force: true });
172
+ }
173
+ }
174
+
175
+ /** Icon Composer writes richer fills than the Linux flatten can draw, so an
176
+ * unreadable one is an error naming the escape hatch rather than a wrong icon. */
177
+ function readFill(fill: Record<string, unknown> | undefined, bundle: string): AppIconFill | undefined {
178
+ if (!fill) return undefined;
179
+ const gradient = fill["linear-gradient"];
180
+ if (Array.isArray(gradient) && gradient.length >= 2 && gradient.every((stop) => typeof stop === "string")) {
181
+ return { gradient: [gradient[0] as string, gradient[1] as string] };
182
+ }
183
+ // An automatic gradient is derived by the renderer from one seed color; Linux
184
+ // has no equivalent, so it flattens to that color.
185
+ const solid = fill.solid ?? fill["automatic-gradient"];
186
+ if (typeof solid === "string") return solid;
187
+ throw new Error(`nd: cannot flatten ${bundle}'s background for Linux - set app.icon.linux to a PNG or SVG`);
188
+ }
189
+
190
+ /** The parts of an existing .icon the Linux flatten needs: the background fill
191
+ * and every layer's art, back to front. */
192
+ export function readIconBundle(bundle: string): { background?: AppIconFill; layers: string[] } {
193
+ const manifestPath = join(bundle, "icon.json");
194
+ if (!existsSync(manifestPath)) throw new Error(`nd: ${bundle} has no icon.json`);
195
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
196
+ fill?: Record<string, unknown>;
197
+ groups?: Array<{ layers?: Array<{ "image-name"?: string }> }>;
198
+ };
199
+ const background = readFill(manifest.fill, basename(bundle));
200
+ const layers: string[] = [];
201
+ for (const group of manifest.groups ?? []) {
202
+ for (const layer of group.layers ?? []) {
203
+ const file = layer["image-name"];
204
+ if (!file) continue;
205
+ const path = join(bundle, "Assets", file);
206
+ if (!existsSync(path)) throw new Error(`nd: ${basename(bundle)} references a missing layer: Assets/${file}`);
207
+ layers.push(path);
208
+ }
209
+ }
210
+ if (!layers.length) throw new Error(`nd: ${basename(bundle)} declares no layers`);
211
+ return { background, layers };
212
+ }
213
+
214
+ /** Icon Composer's design grid. */
215
+ const GRID = 1024;
216
+ // GNOME draws an app icon as a 104x104 body on a 128x128 canvas with a 24px
217
+ // corner radius. Mapped onto the 1024 grid that is an 832px body inset by 96
218
+ // with a 192px radius, which is what lets one composition serve both platforms.
219
+ const BODY = 832;
220
+ const INSET = 96;
221
+ const RADIUS = 192;
222
+
223
+ /** Flattens a layered composition into one SVG sized to the GNOME icon grid. */
224
+ export function composeSvg(background: AppIconFill | undefined, layers: string[]): string {
225
+ const defs: string[] = [];
226
+ let fill = "none";
227
+ let fillOpacity = 1;
228
+ if (typeof background === "string") {
229
+ fill = cssColor(background);
230
+ fillOpacity = cssOpacity(background);
231
+ } else if (background) {
232
+ const [top, bottom] = background.gradient;
233
+ defs.push(
234
+ `<linearGradient id="nd-background" x1="0" y1="0" x2="0" y2="1">`
235
+ + `<stop offset="0" stop-color="${cssColor(top)}" stop-opacity="${cssOpacity(top)}"/>`
236
+ + `<stop offset="1" stop-color="${cssColor(bottom)}" stop-opacity="${cssOpacity(bottom)}"/>`
237
+ + `</linearGradient>`,
238
+ );
239
+ fill = "url(#nd-background)";
240
+ }
241
+ const art = layers.map((path, index) => inlineLayer(path, `nd${index}`)).join("");
242
+ return [
243
+ `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"`
244
+ + ` width="${GRID}" height="${GRID}" viewBox="0 0 ${GRID} ${GRID}">`,
245
+ defs.length ? `<defs>${defs.join("")}</defs>` : "",
246
+ background
247
+ ? `<rect x="${INSET}" y="${INSET}" width="${BODY}" height="${BODY}" rx="${RADIUS}" ry="${RADIUS}"`
248
+ + ` fill="${fill}" fill-opacity="${fillOpacity}"/>`
249
+ : "",
250
+ `<g transform="translate(${INSET} ${INSET}) scale(${BODY / GRID})">${art}</g>`,
251
+ `</svg>`,
252
+ "",
253
+ ].join("\n");
254
+ }
255
+
256
+ function inlineLayer(path: string, prefix: string): string {
257
+ if (extname(path).toLowerCase() === ".png") {
258
+ const data = readFileSync(path).toString("base64");
259
+ const href = `data:image/png;base64,${data}`;
260
+ return `<image x="0" y="0" width="${GRID}" height="${GRID}" preserveAspectRatio="xMidYMid meet"`
261
+ + ` href="${href}" xlink:href="${href}"/>`;
262
+ }
263
+ return nestSvg(readFileSync(path, "utf8"), prefix, path);
264
+ }
265
+
266
+ const ATTRIBUTE = `\\s*=\\s*("[^"]*"|'[^']*')`;
267
+
268
+ /**
269
+ * Inlines an SVG file as a nested <svg> on the 1024 grid. Nested <svg> is plain
270
+ * SVG 1.1 that every renderer handles, unlike an SVG data URI inside <image>,
271
+ * which only librsvg-backed rasterizers read. Ids stay document-global through
272
+ * the nesting, so each layer's are namespaced before the layers are merged.
273
+ */
274
+ function nestSvg(source: string, prefix: string, path: string): string {
275
+ const document = source.replace(/<\?xml[\s\S]*?\?>/g, "").replace(/<!DOCTYPE[\s\S]*?>/gi, "").trim();
276
+ const open = document.match(/<svg\b([^>]*)>/i);
277
+ const close = document.lastIndexOf("</svg>");
278
+ if (!open || close < 0) throw new Error(`nd: icon layer is not an SVG document: ${path}`);
279
+ const attributes = open[1]!;
280
+ const viewBox = attribute(attributes, "viewBox") ?? intrinsicViewBox(attributes);
281
+ const kept = attributes.replace(
282
+ new RegExp(`\\s(?:width|height|viewBox|x|y|preserveAspectRatio)${ATTRIBUTE}`, "gi"),
283
+ "",
284
+ );
285
+ const inner = document.slice(open.index! + open[0].length, close);
286
+ return `<svg${kept} x="0" y="0" width="${GRID}" height="${GRID}" viewBox="${viewBox}"`
287
+ + ` preserveAspectRatio="xMidYMid meet">${namespaceIds(inner, prefix)}</svg>`;
288
+ }
289
+
290
+ function attribute(attributes: string, name: string): string | undefined {
291
+ const match = attributes.match(new RegExp(`\\s${name}${ATTRIBUTE}`, "i"));
292
+ return match ? match[1]!.slice(1, -1) : undefined;
293
+ }
294
+
295
+ /** An SVG without a viewBox is measured by its width/height, dropping any unit suffix. */
296
+ function intrinsicViewBox(attributes: string): string {
297
+ const width = Number.parseFloat(attribute(attributes, "width") ?? "");
298
+ const height = Number.parseFloat(attribute(attributes, "height") ?? "");
299
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return `0 0 ${GRID} ${GRID}`;
300
+ return `0 0 ${width} ${height}`;
301
+ }
302
+
303
+ function namespaceIds(svg: string, prefix: string): string {
304
+ const ids = new Set<string>();
305
+ for (const [, id] of svg.matchAll(/\sid\s*=\s*["']([^"']+)["']/g)) ids.add(id!);
306
+ let out = svg;
307
+ for (const id of ids) {
308
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
309
+ out = out
310
+ .replace(new RegExp(`(\\sid\\s*=\\s*["'])${escaped}(["'])`, "g"), `$1${prefix}-${id}$2`)
311
+ .replace(new RegExp(`url\\((["']?)#${escaped}\\1\\)`, "g"), `url($1#${prefix}-${id}$1)`)
312
+ .replace(new RegExp(`((?:xlink:)?href\\s*=\\s*["'])#${escaped}(["'])`, "g"), `$1#${prefix}-${id}$2`);
313
+ }
314
+ return out;
315
+ }
@@ -3,9 +3,14 @@
3
3
  // whose PATH holds only the fixture's fake tools, because Bun.which and spawn
4
4
  // resolve against the process's startup PATH.
5
5
  import { describe, expect, test } from "bun:test";
6
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
6
+ import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
+ import type { AppIconLayered, AppIcon } from "../config.ts";
10
+ import { installLinuxIcon, installMacIcon } from "./icons.ts";
11
+ import { composeSvg, iconManifest, layeredComposition } from "./iconcomposer.ts";
12
+ import type { ResolvedIdentity } from "./identity.ts";
13
+ import { PLACEHOLDER_PNG } from "./templates.ts";
9
14
 
10
15
  const ICONS_MODULE = join(import.meta.dir, "icons.ts");
11
16
 
@@ -59,3 +64,153 @@ describe("installLinuxIcon", () => {
59
64
  expect(existsSync(join(appdir, "usr/share/icons/hicolor"))).toBe(false);
60
65
  });
61
66
  });
67
+
68
+ const GLASS_SVG = `<?xml version="1.0"?>\n<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512">`
69
+ + `<defs><linearGradient id="g"><stop offset="0" stop-color="#fff"/></linearGradient></defs>`
70
+ + `<circle cx="256" cy="256" r="200" fill="url(#g)"/></svg>`;
71
+
72
+ function layeredFixture(layered: AppIconLayered): { appDir: string; identity: ResolvedIdentity } {
73
+ const appDir = mkdtempSync(join(tmpdir(), "nd-layered-"));
74
+ writeFileSync(join(appDir, "glyph.svg"), GLASS_SVG);
75
+ writeFileSync(join(appDir, "shine.png"), PLACEHOLDER_PNG);
76
+ return { appDir, identity: identityWith({ layered }) };
77
+ }
78
+
79
+ function identityWith(icon: AppIcon): ResolvedIdentity {
80
+ return { name: "Fixture", displayName: "Fixture", slug: "fixture", version: "1.0.0", categories: ["Utility"], icon };
81
+ }
82
+
83
+ describe("iconManifest", () => {
84
+ test("hex background becomes an extended-sRGB gradient and each layer gets its own group", () => {
85
+ const { appDir } = layeredFixture({ layers: [] });
86
+ const manifest = JSON.parse(iconManifest(
87
+ { background: { gradient: ["#0a84ff", "#5e5ce6"] }, layers: ["glyph.svg", { image: "shine.png", shadow: false, translucency: 0.2 }] },
88
+ appDir,
89
+ ));
90
+ expect(manifest.fill["linear-gradient"]).toEqual(["extended-srgb:0.03922,0.51765,1.00000,1.00000", "extended-srgb:0.36863,0.36078,0.90196,1.00000"]);
91
+ expect(manifest.groups).toHaveLength(2);
92
+ expect(manifest.groups[0].layers[0]).toEqual({ "image-name": "glyph.svg", name: "glyph" });
93
+ expect(manifest.groups[0].shadow).toEqual({ kind: "neutral", opacity: 0.5 });
94
+ expect(manifest.groups[1].shadow).toEqual({ kind: "none", opacity: 0 });
95
+ expect(manifest.groups[1].translucency).toEqual({ enabled: true, value: 0.2 });
96
+ });
97
+
98
+ test("a color-space string passes through untouched", () => {
99
+ const { appDir } = layeredFixture({ layers: [] });
100
+ const manifest = JSON.parse(iconManifest({ background: "display-p3:0,0.5,1,1", layers: ["glyph.svg"] }, appDir));
101
+ expect(manifest.fill).toEqual({ solid: "display-p3:0,0.5,1,1" });
102
+ });
103
+
104
+ test("more than four layers is rejected", () => {
105
+ const { appDir } = layeredFixture({ layers: [] });
106
+ const layers = ["glyph.svg", "glyph.svg", "glyph.svg", "glyph.svg", "glyph.svg"];
107
+ expect(() => iconManifest({ layers }, appDir)).toThrow(/at most 4 layers/);
108
+ });
109
+ });
110
+
111
+ describe("composeSvg", () => {
112
+ test("layers nest as SVG with namespaced ids over the GNOME icon body", () => {
113
+ const { appDir } = layeredFixture({ layers: [] });
114
+ const layered: AppIconLayered = { background: "#0a84ff", layers: ["glyph.svg", "shine.png"] };
115
+ const { background, layers } = layeredComposition(layered, appDir);
116
+ const svg = composeSvg(background, layers);
117
+ expect(svg).toContain(`<rect x="96" y="96" width="832" height="832" rx="192"`);
118
+ expect(svg).toContain(`fill="#0a84ff"`);
119
+ // The layer's own gradient id is rewritten on both the definition and the reference.
120
+ expect(svg).toContain(`id="nd0-g"`);
121
+ expect(svg).toContain(`url(#nd0-g)`);
122
+ // The nested <svg> keeps the source viewBox but is stretched onto the 1024 grid.
123
+ expect(svg).toContain(`viewBox="0 0 512 512" preserveAspectRatio="xMidYMid meet"`);
124
+ expect(svg).toContain(`data:image/png;base64,`);
125
+ expect(svg).not.toContain("<?xml version");
126
+ });
127
+ });
128
+
129
+ const rasterizer = ["rsvg-convert", "magick", "convert"].some((tool) => Bun.which(tool) !== null);
130
+
131
+ function iconBundleFixture(fill: unknown): string {
132
+ const appDir = mkdtempSync(join(tmpdir(), "nd-bundle-"));
133
+ mkdirSync(join(appDir, "Brand.icon", "Assets"), { recursive: true });
134
+ writeFileSync(join(appDir, "Brand.icon", "Assets", "glyph.svg"), GLASS_SVG);
135
+ writeFileSync(join(appDir, "Brand.icon", "icon.json"), JSON.stringify({
136
+ fill,
137
+ groups: [{ layers: [{ "image-name": "glyph.svg", name: "glyph" }] }],
138
+ }));
139
+ return appDir;
140
+ }
141
+
142
+ describe.if(rasterizer)("installLinuxIcon", () => {
143
+ test("an Icon Composer bundle flattens into the linux icon set", async () => {
144
+ const appDir = iconBundleFixture({ "linear-gradient": ["extended-srgb:0,0.5,1,1", "extended-srgb:0.4,0.3,0.9,1"] });
145
+ const appdir = mkdtempSync(join(tmpdir(), "nd-appdir-"));
146
+ await installLinuxIcon(identityWith({ source: "Brand.icon" }), appDir, appdir);
147
+ expect(existsSync(join(appdir, "usr/share/icons/hicolor/scalable/apps/fixture.svg"))).toBe(true);
148
+ expect(existsSync(join(appdir, "usr/share/icons/hicolor/256x256/apps/fixture.png"))).toBe(true);
149
+ });
150
+
151
+ test("a fill the flatten cannot draw names the escape hatch", async () => {
152
+ const appDir = iconBundleFixture({ "automatic-gradient": { seed: "extended-srgb:0,0.5,1,1" } });
153
+ const appdir = mkdtempSync(join(tmpdir(), "nd-appdir-"));
154
+ await expect(installLinuxIcon(identityWith({ source: "Brand.icon" }), appDir, appdir)).rejects.toThrow(/app\.icon\.linux/);
155
+ });
156
+
157
+ test("the linux override beats a layered composition", async () => {
158
+ const { appDir, identity } = layeredFixture({ background: "#0a84ff", layers: ["glyph.svg"] });
159
+ const appdir = mkdtempSync(join(tmpdir(), "nd-appdir-"));
160
+ await installLinuxIcon(identityWith({ layered: identity.icon!.layered, linux: "shine.png" }), appDir, appdir);
161
+ // The override is a PNG, so the flatten never ran and there is no scalable icon.
162
+ expect(existsSync(join(appdir, "usr/share/icons/hicolor/scalable/apps/fixture.svg"))).toBe(false);
163
+ expect(existsSync(join(appdir, "fixture.png"))).toBe(true);
164
+ });
165
+
166
+ test("a layered config flattens to the scalable icon plus the hicolor raster set", async () => {
167
+ const { appDir, identity } = layeredFixture({ background: { gradient: ["#0a84ff", "#5e5ce6"] }, layers: ["glyph.svg"] });
168
+ const appdir = mkdtempSync(join(tmpdir(), "nd-appdir-"));
169
+ await installLinuxIcon(identity, appDir, appdir);
170
+ expect(existsSync(join(appdir, "usr/share/icons/hicolor/scalable/apps/fixture.svg"))).toBe(true);
171
+ expect(existsSync(join(appdir, "usr/share/icons/hicolor/512x512/apps/fixture.png"))).toBe(true);
172
+ expect(existsSync(join(appdir, "usr/share/icons/hicolor/16x16/apps/fixture.png"))).toBe(true);
173
+ expect(existsSync(join(appdir, "fixture.svg"))).toBe(true);
174
+ });
175
+ });
176
+
177
+ const actool = process.platform === "darwin" && Bun.which("xcrun") !== null;
178
+
179
+ describe.if(actool)("installMacIcon", () => {
180
+ test("a layered config compiles to Assets.car plus the legacy icns", async () => {
181
+ const { appDir, identity } = layeredFixture({ background: "#0a84ff", layers: ["glyph.svg"] });
182
+ const resources = mkdtempSync(join(tmpdir(), "nd-resources-"));
183
+ const iconFile = await installMacIcon(identity, appDir, resources, "26.0");
184
+ expect(iconFile).toBe("fixture");
185
+ expect(existsSync(join(resources, "Assets.car"))).toBe(true);
186
+ expect(readFileSync(join(resources, "fixture.icns")).subarray(0, 4).toString()).toBe("icns");
187
+ });
188
+
189
+ test("the macos override beats a layered composition", async () => {
190
+ const { appDir, identity } = layeredFixture({ background: "#0a84ff", layers: ["glyph.svg"] });
191
+ const prebuilt = mkdtempSync(join(tmpdir(), "nd-resources-"));
192
+ await installMacIcon(identityWith({ layered: identity.icon!.layered }), appDir, prebuilt, "26.0");
193
+ cpSync(join(prebuilt, "fixture.icns"), join(appDir, "brand.icns"));
194
+
195
+ const resources = mkdtempSync(join(tmpdir(), "nd-resources-"));
196
+ const iconFile = await installMacIcon(
197
+ identityWith({ layered: identity.icon!.layered, macos: "brand.icns" }),
198
+ appDir,
199
+ resources,
200
+ "26.0",
201
+ );
202
+ expect(iconFile).toBe("fixture");
203
+ // The override is a flat icns, so nothing was compiled through actool.
204
+ expect(existsSync(join(resources, "Assets.car"))).toBe(false);
205
+ });
206
+
207
+ test("an SVG source rasterizes into a flat icns", async () => {
208
+ const appDir = mkdtempSync(join(tmpdir(), "nd-flat-"));
209
+ writeFileSync(join(appDir, "icon.svg"), GLASS_SVG);
210
+ const resources = mkdtempSync(join(tmpdir(), "nd-resources-"));
211
+ const iconFile = await installMacIcon(identityWith({ source: "icon.svg" }), appDir, resources, "26.0");
212
+ expect(iconFile).toBe("fixture");
213
+ expect(readFileSync(join(resources, "fixture.icns")).subarray(0, 4).toString()).toBe("icns");
214
+ expect(existsSync(join(resources, "Assets.car"))).toBe(false);
215
+ });
216
+ });
@@ -1,18 +1,22 @@
1
- // App icon installation. mac: .icns/.iconset passthrough or a PNG source
2
- // rasterized through sips + iconutil. linux: hicolor theme set + the AppDir
3
- // root icon appimagetool requires. Resizers are probed, never hard-required:
4
- // sips/iconutil are macOS-only tools, and a Linux box degrades with a marker.
5
- import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
1
+ // App icon installation. One source per platform is resolved from app.icon and
2
+ // converted to whatever the platform actually reads: mac gets Assets.car +
3
+ // .icns, linux gets the hicolor theme set plus the AppDir root icon
4
+ // appimagetool requires. Resizers are probed, never hard-required: sips and
5
+ // iconutil are macOS-only, and a Linux box degrades with a marker.
6
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
7
  import { tmpdir } from "node:os";
7
- import { extname, join, resolve } from "node:path";
8
+ import { basename, extname, join, resolve } from "node:path";
9
+ import {
10
+ compileIconBundle,
11
+ composeSvg,
12
+ layeredComposition,
13
+ readIconBundle,
14
+ runTool,
15
+ writeIconBundle,
16
+ } from "./iconcomposer.ts";
8
17
  import type { ResolvedIdentity } from "./identity.ts";
9
18
  import { PLACEHOLDER_PNG } from "./templates.ts";
10
19
 
11
- async function run(command: string[]): Promise<void> {
12
- const proc = Bun.spawn(command, { stdin: "ignore", stdout: "inherit", stderr: "inherit" });
13
- if (await proc.exited !== 0) throw new Error(`nd: icon tool failed (${command.join(" ")})`);
14
- }
15
-
16
20
  let warnedNoIcon = false;
17
21
  function warnNoIcon(): void {
18
22
  if (warnedNoIcon) return;
@@ -34,99 +38,171 @@ const ICONSET_SIZES: Array<{ file: string; px: number }> = [
34
38
  { file: "icon_512x512@2x.png", px: 1024 },
35
39
  ];
36
40
 
37
- /**
38
- * Installs the mac icon into Contents/Resources/<slug>.icns. Returns the
39
- * CFBundleIconFile value, or undefined when no icon is configured.
40
- */
41
- export async function installMacIcon(identity: ResolvedIdentity, appDir: string, resourcesDir: string): Promise<string | undefined> {
42
- const source = identity.icon?.macos ?? identity.icon?.source;
43
- if (!source) {
44
- warnNoIcon();
45
- return undefined;
46
- }
47
- const src = resolve(appDir, source);
48
- const ext = extname(src).toLowerCase();
49
- const icns = join(resourcesDir, `${identity.slug}.icns`);
50
- if (ext === ".icns") {
51
- cpSync(src, icns);
52
- } else if (ext === ".iconset") {
53
- await run(["iconutil", "-c", "icns", "-o", icns, src]);
54
- } else if (ext === ".png") {
55
- const tmp = mkdtempSync(join(tmpdir(), "nd-iconset-"));
56
- const iconset = join(tmp, `${identity.slug}.iconset`);
57
- mkdirSync(iconset, { recursive: true });
58
- for (const { file, px } of ICONSET_SIZES) {
59
- await run(["sips", "-z", String(px), String(px), src, "--out", join(iconset, file)]);
60
- }
61
- await run(["iconutil", "-c", "icns", "-o", icns, iconset]);
62
- rmSync(tmp, { recursive: true, force: true });
63
- } else if (ext === ".svg") {
64
- throw new Error(
65
- "nd: macOS cannot rasterize an SVG icon. Supply a 1024px PNG (app.icon.source) or a prebuilt .icns (app.icon.macos).",
66
- );
67
- } else {
68
- throw new Error(`nd: unsupported mac icon source "${source}" (want .png, .icns, or .iconset)`);
69
- }
70
- return identity.slug;
71
- }
72
-
73
41
  const HICOLOR_SIZES = [16, 32, 48, 64, 128, 256, 512];
74
42
 
75
43
  function firstOnPath(candidates: string[]): string | undefined {
76
44
  return candidates.find((tool) => Bun.which(tool) !== null);
77
45
  }
78
46
 
79
- async function resizePng(tool: string, src: string, px: number, out: string): Promise<void> {
47
+ async function resize(tool: string, src: string, px: number, out: string): Promise<void> {
80
48
  switch (tool) {
81
49
  case "sips":
82
- await run(["sips", "-z", String(px), String(px), src, "--out", out]);
50
+ await runTool(["sips", "-z", String(px), String(px), src, "--out", out]);
83
51
  return;
84
52
  case "magick":
85
- await run(["magick", src, "-resize", `${px}x${px}`, out]);
53
+ await runTool(["magick", src, "-resize", `${px}x${px}`, out]);
86
54
  return;
87
55
  case "convert":
88
- await run(["convert", src, "-resize", `${px}x${px}`, out]);
56
+ await runTool(["convert", src, "-resize", `${px}x${px}`, out]);
89
57
  return;
90
58
  case "rsvg-convert":
91
- await run(["rsvg-convert", "-w", String(px), "-h", String(px), "-o", out, src]);
59
+ await runTool(["rsvg-convert", "-w", String(px), "-h", String(px), "-o", out, src]);
92
60
  return;
93
61
  default:
94
62
  throw new Error(`nd: unknown icon resizer "${tool}"`);
95
63
  }
96
64
  }
97
65
 
66
+ /**
67
+ * Rasterizes an SVG to a square PNG. qlmanage is the macOS fallback: it ships
68
+ * with the OS and renders through QuickLook, writing <name>.svg.png into the
69
+ * output directory.
70
+ */
71
+ async function rasterizeSvg(src: string, px: number, workDir: string): Promise<string> {
72
+ const tool = firstOnPath(["rsvg-convert", "magick", "convert"]);
73
+ if (tool) {
74
+ const out = join(workDir, "source.png");
75
+ await resize(tool, src, px, out);
76
+ return out;
77
+ }
78
+ if (process.platform === "darwin") {
79
+ await runTool(["qlmanage", "-t", "-s", String(px), "-o", workDir, src]);
80
+ const rendered = join(workDir, `${basename(src)}.png`);
81
+ if (existsSync(rendered)) return rendered;
82
+ }
83
+ throw new Error(`nd: cannot rasterize "${src}" (install librsvg or ImageMagick, or supply a 1024px PNG)`);
84
+ }
85
+
86
+ /** PNG -> .icns through a generated .iconset. */
87
+ async function buildIcns(src: string, icns: string, workDir: string): Promise<void> {
88
+ const iconset = join(workDir, `${basename(icns, ".icns")}.iconset`);
89
+ mkdirSync(iconset, { recursive: true });
90
+ for (const { file, px } of ICONSET_SIZES) {
91
+ await runTool(["sips", "-z", String(px), String(px), src, "--out", join(iconset, file)]);
92
+ }
93
+ await runTool(["iconutil", "-c", "icns", "-o", icns, iconset]);
94
+ }
95
+
96
+ /**
97
+ * Installs the mac icon into Contents/Resources. A layered source compiles to
98
+ * Assets.car (macOS 26 Liquid Glass) plus the .icns older releases fall back
99
+ * to; a flat source compiles to <slug>.icns only. Returns the
100
+ * CFBundleIconFile/CFBundleIconName value, or undefined when no icon is
101
+ * configured.
102
+ */
103
+ export async function installMacIcon(
104
+ identity: ResolvedIdentity,
105
+ appDir: string,
106
+ resourcesDir: string,
107
+ deploymentTarget = "26.0",
108
+ ): Promise<string | undefined> {
109
+ const override = identity.icon?.macos;
110
+ // The explicit per-platform override wins outright. Failing that macOS
111
+ // prefers a layered composition over the shared flat source, because layered
112
+ // is its native format; Linux resolves the mirror of this.
113
+ const layered = override ? undefined : identity.icon?.layered;
114
+ const source = override ?? (layered ? undefined : identity.icon?.source);
115
+ if (!layered && !source) {
116
+ warnNoIcon();
117
+ return undefined;
118
+ }
119
+ const work = mkdtempSync(join(tmpdir(), "nd-icon-"));
120
+ try {
121
+ if (layered) {
122
+ const bundle = join(work, `${identity.slug}.icon`);
123
+ writeIconBundle(layered, appDir, bundle);
124
+ return await compileIconBundle(bundle, resourcesDir, deploymentTarget);
125
+ }
126
+ const src = resolve(appDir, source!);
127
+ const ext = extname(src).toLowerCase();
128
+ if (ext === ".icon") return await compileIconBundle(src, resourcesDir, deploymentTarget);
129
+ const icns = join(resourcesDir, `${identity.slug}.icns`);
130
+ switch (ext) {
131
+ case ".icns":
132
+ cpSync(src, icns);
133
+ break;
134
+ case ".iconset":
135
+ await runTool(["iconutil", "-c", "icns", "-o", icns, src]);
136
+ break;
137
+ case ".png":
138
+ await buildIcns(src, icns, work);
139
+ break;
140
+ case ".svg":
141
+ await buildIcns(await rasterizeSvg(src, 1024, work), icns, work);
142
+ break;
143
+ default:
144
+ throw new Error(`nd: unsupported mac icon source "${source}" (want .icon, .icns, .iconset, .png, or .svg)`);
145
+ }
146
+ return identity.slug;
147
+ } finally {
148
+ rmSync(work, { recursive: true, force: true });
149
+ }
150
+ }
151
+
98
152
  /** Installs the linux icon set into the AppDir (hicolor theme + root icon). */
99
153
  export async function installLinuxIcon(identity: ResolvedIdentity, appDir: string, appdir: string): Promise<void> {
100
154
  const source = identity.icon?.linux ?? identity.icon?.source;
155
+ const layered = identity.icon?.layered;
101
156
  const rootIcon = join(appdir, `${identity.slug}.png`);
102
- if (!source) {
157
+ if (!source && !layered) {
103
158
  warnNoIcon();
104
159
  writeFileSync(rootIcon, PLACEHOLDER_PNG);
105
160
  return;
106
161
  }
107
- const src = resolve(appDir, source);
108
- const ext = extname(src).toLowerCase();
109
- if (ext === ".svg") {
110
- const scalable = join(appdir, "usr/share/icons/hicolor/scalable/apps");
111
- mkdirSync(scalable, { recursive: true });
112
- cpSync(src, join(scalable, `${identity.slug}.svg`));
113
- cpSync(src, join(appdir, `${identity.slug}.svg`));
114
- return;
115
- }
116
- if (ext !== ".png") throw new Error(`nd: unsupported linux icon source "${source}" (want .png or .svg)`);
117
- // rsvg-convert is deliberately absent: it only rasterizes SVGs and this
118
- // branch always feeds it a PNG.
119
- const resizer = firstOnPath(["sips", "magick", "convert"]);
120
- if (!resizer) {
121
- console.error("ND_PACKAGE_ICON_SKIPPED reason=no-resizer");
162
+ const work = mkdtempSync(join(tmpdir(), "nd-icon-"));
163
+ try {
164
+ let src = source ? resolve(appDir, source) : "";
165
+ let ext = src ? extname(src).toLowerCase() : "";
166
+ // A layered composition (config or an Icon Composer bundle) flattens to a
167
+ // single SVG so the same art dresses both platforms.
168
+ if (!src || ext === ".icon") {
169
+ const composition = src ? readIconBundle(src) : layeredComposition(layered!, appDir);
170
+ src = join(work, `${identity.slug}.svg`);
171
+ writeFileSync(src, composeSvg(composition.background, composition.layers));
172
+ ext = ".svg";
173
+ }
174
+ if (ext === ".svg") {
175
+ // Raster sizes first: a failed resizer clears the whole hicolor tree, and
176
+ // scalable/ is a complete install on its own, so it lands afterwards.
177
+ await installHicolor(identity, src, appdir, ["rsvg-convert", "magick", "convert"]);
178
+ const scalable = join(appdir, "usr/share/icons/hicolor/scalable/apps");
179
+ mkdirSync(scalable, { recursive: true });
180
+ cpSync(src, join(scalable, `${identity.slug}.svg`));
181
+ cpSync(src, join(appdir, `${identity.slug}.svg`));
182
+ return;
183
+ }
184
+ if (ext !== ".png") throw new Error(`nd: unsupported linux icon source "${source}" (want .png, .svg, or .icon)`);
185
+ // rsvg-convert is deliberately absent: it only rasterizes SVGs and this
186
+ // branch always feeds it a PNG.
187
+ if (!await installHicolor(identity, src, appdir, ["sips", "magick", "convert"])) {
188
+ console.error("ND_PACKAGE_ICON_SKIPPED reason=no-resizer");
189
+ }
122
190
  cpSync(src, rootIcon);
123
- return;
191
+ } finally {
192
+ rmSync(work, { recursive: true, force: true });
124
193
  }
194
+ }
195
+
196
+ /** Renders the hicolor raster set. Returns false when no resizer is installed,
197
+ * which the caller reports only when raster is the sole install path. */
198
+ async function installHicolor(identity: ResolvedIdentity, src: string, appdir: string, tools: string[]): Promise<boolean> {
199
+ const resizer = firstOnPath(tools);
200
+ if (!resizer) return false;
125
201
  try {
126
202
  for (const px of HICOLOR_SIZES) {
127
203
  const dir = join(appdir, `usr/share/icons/hicolor/${px}x${px}/apps`);
128
204
  mkdirSync(dir, { recursive: true });
129
- await resizePng(resizer, src, px, join(dir, `${identity.slug}.png`));
205
+ await resize(resizer, src, px, join(dir, `${identity.slug}.png`));
130
206
  }
131
207
  } catch (err) {
132
208
  // A broken resizer degrades like a missing one: drop the (possibly
@@ -135,5 +211,5 @@ export async function installLinuxIcon(identity: ResolvedIdentity, appDir: strin
135
211
  console.error(String(err));
136
212
  console.error("ND_PACKAGE_ICON_SKIPPED reason=resize-failed");
137
213
  }
138
- cpSync(src, rootIcon);
214
+ return true;
139
215
  }
@@ -55,7 +55,7 @@ export async function packageMacApp(
55
55
  cpSync(bunPath, join(c, "MacOS", "bun"), { dereference: true });
56
56
  chmodSync(join(c, "MacOS", "bun"), 0o755);
57
57
 
58
- const iconFile = await installMacIcon(identity, appDir, join(c, "Resources"));
58
+ const iconFile = await installMacIcon(identity, appDir, join(c, "Resources"), mac?.minimumSystemVersion ?? "26.0");
59
59
  const customXml = mac?.infoPlist ? readFileSync(resolve(appDir, mac.infoPlist), "utf8") : undefined;
60
60
  writeFileSync(join(c, "Info.plist"), buildInfoPlist(identity, mac, customXml, iconFile));
61
61