@gjsify/cli 0.51.0 → 0.52.0

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.
Files changed (66) hide show
  1. package/dist/cli.gjs.mjs +505 -490
  2. package/lib/cli-app.js +3 -1
  3. package/lib/commands/affected-classify.d.ts +7 -1
  4. package/lib/commands/affected-classify.js +66 -9
  5. package/lib/commands/affected-script-refs.d.ts +20 -0
  6. package/lib/commands/affected-script-refs.js +218 -0
  7. package/lib/commands/affected.js +24 -2
  8. package/lib/commands/flatpak/diff.js +8 -2
  9. package/lib/commands/flatpak/init.js +4 -1
  10. package/lib/commands/flatpak/sources.js +4 -3
  11. package/lib/commands/flatpak/sync-flathub.d.ts +2 -0
  12. package/lib/commands/flatpak/sync-flathub.js +149 -7
  13. package/lib/commands/flatpak/utils.d.ts +10 -0
  14. package/lib/commands/flatpak/utils.js +10 -0
  15. package/lib/commands/index.d.ts +1 -0
  16. package/lib/commands/index.js +1 -0
  17. package/lib/commands/install.js +76 -0
  18. package/lib/commands/link.d.ts +13 -0
  19. package/lib/commands/link.js +243 -0
  20. package/lib/commands/onboard.js +18 -2
  21. package/lib/commands/publish.d.ts +34 -0
  22. package/lib/commands/publish.js +153 -8
  23. package/lib/commands/ship.js +46 -4
  24. package/lib/types/config-data.d.ts +1 -1
  25. package/lib/utils/check-system-deps.d.ts +1 -17
  26. package/lib/utils/check-system-deps.js +10 -68
  27. package/lib/utils/dev-link.d.ts +222 -0
  28. package/lib/utils/dev-link.js +632 -0
  29. package/lib/utils/hang-watchdog.d.ts +95 -0
  30. package/lib/utils/hang-watchdog.js +194 -0
  31. package/lib/utils/install-backend-native.d.ts +14 -0
  32. package/lib/utils/install-backend-native.js +91 -13
  33. package/lib/utils/install-extraneous.d.ts +90 -0
  34. package/lib/utils/install-extraneous.js +120 -0
  35. package/lib/utils/install-tarball-cache.d.ts +37 -9
  36. package/lib/utils/install-tarball-cache.js +59 -14
  37. package/lib/utils/normalize-bundler-options.js +11 -0
  38. package/lib/utils/publish-pins.d.ts +38 -0
  39. package/lib/utils/publish-pins.js +47 -0
  40. package/lib/utils/publish-readback.d.ts +103 -17
  41. package/lib/utils/publish-readback.js +326 -47
  42. package/lib/utils/run-gjs.js +82 -6
  43. package/lib/utils/ship/appimage.d.ts +24 -4
  44. package/lib/utils/ship/appimage.js +51 -6
  45. package/lib/utils/ship/formats.js +44 -11
  46. package/lib/utils/ship/icns.d.ts +14 -0
  47. package/lib/utils/ship/icns.js +83 -0
  48. package/lib/utils/ship/ico.d.ts +25 -0
  49. package/lib/utils/ship/ico.js +120 -0
  50. package/lib/utils/ship/icons.d.ts +191 -0
  51. package/lib/utils/ship/icons.js +398 -0
  52. package/lib/utils/ship/launcher.js +16 -1
  53. package/lib/utils/ship/layout.d.ts +32 -0
  54. package/lib/utils/ship/layout.js +112 -38
  55. package/lib/utils/ship/msi.d.ts +1 -1
  56. package/lib/utils/ship/msi.js +30 -5
  57. package/lib/utils/ship/pe-launcher.d.ts +18 -0
  58. package/lib/utils/ship/pe-launcher.js +125 -4
  59. package/lib/utils/ship/plan.d.ts +1 -34
  60. package/lib/utils/ship/plan.js +7 -56
  61. package/lib/utils/ship/plist.d.ts +20 -10
  62. package/lib/utils/ship/plist.js +29 -12
  63. package/lib/utils/ship/schemas.d.ts +46 -0
  64. package/lib/utils/ship/schemas.js +92 -11
  65. package/lib/utils/ship/settings.js +3 -2
  66. package/package.json +20 -20
@@ -0,0 +1,398 @@
1
+ // The application icon: one source, one raster step, and the sizes each OS
2
+ // wants — so the `.ico`, the `.icns` and the hicolor theme all come from the
3
+ // same pixels.
4
+ //
5
+ // THE DEFECT THIS CLOSES, measured on the released artifacts of one app
6
+ // (Learn6502 0.8.0, 2026-09-13). On Windows 11 the Start-menu entry showed the
7
+ // generic blank-document icon: the shortcut asks the `.exe`, and the emitted
8
+ // launcher carried no resource directory at all. On macOS 15 the bundle had no
9
+ // `CFBundleIconFile`, no `CFBundleIconName` and no `.icns` anywhere under
10
+ // `Contents/`, so the Finder drew the generic application icon. The app declares
11
+ // ONE icon, a scalable SVG under `hicolor/scalable/apps/`, which is exactly what a
12
+ // GNOME app ships — and neither OS reads an SVG. So every SVG-only app shipped to
13
+ // those two platforms with no icon, and nothing said so.
14
+ //
15
+ // WHY `gjsify ship` CONVERTS rather than asking the project for rasters. The
16
+ // alternative — refuse the pack until the author adds `16x16/… 256x256/…` PNGs —
17
+ // costs every GNOME app a project-side change to ship the icon it already has,
18
+ // and it costs it per platform: Windows wants six sizes, macOS ten elements over
19
+ // seven. A build system owns that translation the way it owns the launcher and
20
+ // the desktop entry. The author declares the icon once; each layout says which
21
+ // sizes it writes ({@link Layout.icon}); this module renders them.
22
+ //
23
+ // THE RASTERIZER IS librsvg THROUGH GJS, IN A CHILD PROCESS, and each half of
24
+ // that was measured rather than chosen:
25
+ //
26
+ // * librsvg, not ImageMagick or `rsvg-convert`: neither binary is in the CI
27
+ // image (`.docker/ci-fedora.Dockerfile`), and `rsvg-convert` is not on the
28
+ // workstation either. `Rsvg-2.0.typelib` IS in the image, because GTK needs
29
+ // it, and so is `cairo-1.0.typelib`.
30
+ // * `Rsvg.Handle` rendered onto a cairo surface, not `GdkPixbuf.new_from_file_at_size`
31
+ // on the SVG. The pixbuf route depends on a loader MODULE: on the workstation
32
+ // `/usr/lib64/gdk-pixbuf-2.0/2.10.0/loaders/` holds only the xpm loader and the
33
+ // CI image has no loaders directory at all, so whether an SVG decodes that way
34
+ // is a property of the host's gdk-pixbuf packaging. `rsvg_handle_render_document`
35
+ // depends on librsvg alone.
36
+ // * a `gjs -c` child, not an in-process `gi://` import. `gjsify ship` runs under
37
+ // GJS when installed the Node-free way and under Node (`lib/index.js`) in the
38
+ // CI leg that assembles the Windows artifact — and the CLI's own source has no
39
+ // `gi://` import anywhere, because Node reaches GI only through a node-gi
40
+ // prebuild that leg does not carry. GJS is on every host that assembles (ADR
41
+ // 0024 § A2: "assemble anywhere, under GJS"), so a child process is the one
42
+ // path that is the same on both runtimes. The script is a string constant
43
+ // here, not a file in `lib/templates`: `dist/cli.gjs.mjs` is a single bundled
44
+ // file and a template it would have to locate on disk is a second thing to
45
+ // ship.
46
+ //
47
+ // DETERMINISTIC ACROSS HOSTS, as far as measured: the same SVG rendered at 16
48
+ // and 256 px on the workstation and inside `ghcr.io/gjsify/ci-fedora:44` gave
49
+ // byte-identical PNGs (one sha256, librsvg 2.62.3 on both). A different librsvg
50
+ // may antialias differently, which makes the raster a function of the assembling
51
+ // host's library like every compiled artifact — the STAGE stays reproducible, and
52
+ // repacking it (ADR 0024 § A2) renders nothing.
53
+ //
54
+ // A PROJECT'S OWN PNG WINS at its size. A hand-tuned 16 px icon is drawn for that
55
+ // size and an SVG rendered at 16 px is not, and the hicolor convention exists to
56
+ // carry exactly that distinction. The PNG's IHDR is what decides its size, not its
57
+ // path: a `32x32/apps/x.png` that is 48 px wide is refused by name rather than
58
+ // embedded under the wrong header.
59
+ //
60
+ // WHAT IS REFUSED, and why refusal is the mechanism that matters more than the
61
+ // conversion. An app with no icon source at all, or with PNGs that leave a
62
+ // required size uncovered and no SVG to render it from, fails the STAGE naming
63
+ // the file to add. The previous behaviour was a warning on Linux and silence on
64
+ // the other two, and silence is how two platforms shipped without an icon for
65
+ // every release until someone opened the Start menu.
66
+ import { readFileSync } from 'node:fs';
67
+ import { basename, extname } from 'node:path';
68
+ import { buildNativeEnv } from '../detect-native-packages.js';
69
+ import { spawnToCompletion } from '../spawn.js';
70
+ /** The runtime that renders. On `PATH`, like `glib-compile-schemas` — a GJS host has it by definition. */
71
+ export const RASTERIZER = 'gjs';
72
+ /**
73
+ * How to get the typelib the script imports, in the two distributions' words.
74
+ *
75
+ * `librsvg2` on Fedora carries the typelib itself (measured: `rpm -qf
76
+ * /usr/lib64/girepository-1.0/Rsvg-2.0.typelib` → `librsvg2-2.62.3`); Debian
77
+ * splits it into `gir1.2-rsvg-2.0`.
78
+ *
79
+ * The macOS row IS measured, on the `macos-suites` darwin leg, and its first
80
+ * spelling was WRONG: `brew install gjs librsvg` alone still failed, with the
81
+ * error moving from `namespace 'Rsvg' not found` to `namespace 'cairo', version
82
+ * '1.0' not found`. The script imports both, and `Cairo-1.0.typelib` belongs to
83
+ * `gobject-introspection` rather than to the `cairo` formula (`rpm -qf` says so
84
+ * for the Fedora twin). A hint that names two thirds of what is needed sends a
85
+ * stranger round the same loop this one went round.
86
+ *
87
+ * INSTALLING ALL THREE IS STILL NOT ENOUGH on macOS, and this sentence cost the
88
+ * third red run: the dylib then EXISTS and dyld does not look where it is. That
89
+ * half is not a hint, it is {@link rasterizerEnv} — and the message deliberately
90
+ * stays quiet about it, because a host that reaches this line after the repair
91
+ * really is missing a package.
92
+ *
93
+ * Assembly is a Linux step by ADR 0024 § A2, so a darwin host reaching this line
94
+ * is running `ship` outside the documented arrangement; the message should still
95
+ * tell it what to install rather than name two Linuxes at it.
96
+ */
97
+ export const RASTERIZER_HINT = 'Fedora: `sudo dnf install gjs librsvg2`, Debian/Ubuntu: `sudo apt install gjs gir1.2-rsvg-2.0`, ' +
98
+ 'macOS: `brew install gjs librsvg gobject-introspection`';
99
+ /**
100
+ * Whether an icon belongs in the theme's SYMBOLIC context rather than at a size.
101
+ *
102
+ * `symbolic` is a directory of its own in a hicolor theme and NOT a size value.
103
+ * Measured in `refs/adwaita-icon-theme/index.theme`, which lists `symbolic/apps`
104
+ * in `Directories=` beside `scalable/apps` and `16x16/apps` and gives it its own
105
+ * section — `Context=Applications`, `Size=16`, `MinSize=8`, `MaxSize=512`,
106
+ * `Type=Scalable` — against the scalable row's `Size=128`. The freedesktop icon
107
+ * theme specification has no notion of `symbolic` at all: a theme expresses it by
108
+ * giving the directory a section, which is exactly why reading the EXTENSION
109
+ * cannot see it, and why this question has to be asked before the size one.
110
+ *
111
+ * TWO SIGNALS, the same pair `iconThemeDir` already reads for a size: the
112
+ * directory an author put the file in, and the name they gave it. GTK resolves a
113
+ * symbolic icon by the `-symbolic` name suffix, so an author who wrote the name
114
+ * has said as much as one who wrote the path.
115
+ *
116
+ * SVG ONLY, because the symbolic directory is declared `Type=Scalable`: the
117
+ * raster form GTK's own `gtk-encode-symbolic-svg` produces
118
+ * (`<name>-symbolic.symbolic.png`) is installed at a SIZE instead. So a PNG keeps
119
+ * answering the size question, and one that cannot answer it stays refused rather
120
+ * than quietly becoming a scalable icon that does not scale.
121
+ */
122
+ export function isSymbolicIcon(iconPath) {
123
+ if (extname(iconPath).toLowerCase() !== '.svg')
124
+ return false;
125
+ if (/(?:^|[\\/])symbolic[\\/]/.test(iconPath))
126
+ return true;
127
+ return basename(iconPath, extname(iconPath)).endsWith('-symbolic');
128
+ }
129
+ /**
130
+ * The hicolor subdirectory for an icon: the `symbolic` CONTEXT, else `scalable`
131
+ * for an SVG, else the pixel size read from a `<n>x<n>` path component or a
132
+ * trailing number in the filename.
133
+ *
134
+ * Named for the theme DIRECTORY rather than a size from the moment the first of
135
+ * those answers stopped being one: `iconSizeDir` returning `symbolic` would be a
136
+ * name every caller has to read past.
137
+ */
138
+ export function iconThemeDir(iconPath) {
139
+ if (isSymbolicIcon(iconPath))
140
+ return 'symbolic';
141
+ if (extname(iconPath).toLowerCase() === '.svg')
142
+ return 'scalable';
143
+ const square = /(?:^|[\\/])(\d{1,4})x\1(?:[\\/]|$)/.exec(iconPath);
144
+ if (square)
145
+ return `${square[1]}x${square[1]}`;
146
+ const tokens = basename(iconPath, extname(iconPath)).split(/[-_.]/);
147
+ for (let i = tokens.length - 1; i >= 0; i--) {
148
+ const token = tokens[i];
149
+ if (token !== undefined && /^\d{1,4}$/.test(token))
150
+ return `${token}x${token}`;
151
+ }
152
+ throw new Error(`gjsify ship: cannot tell what size ${iconPath} is. ` +
153
+ 'Put it in a `<size>x<size>/` directory, end its name with the size (`icon-128.png`), or ship an SVG.');
154
+ }
155
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
156
+ /** `IEND` with its zero length and its fixed CRC — the last twelve bytes of every whole PNG. */
157
+ const PNG_IEND = [0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82];
158
+ /**
159
+ * A PNG's pixel size, read from its IHDR — with the file's two ends checked.
160
+ *
161
+ * The signature and the IHDR say what the file CLAIMS to be; the `IEND` trailer
162
+ * says it is all there. A PNG truncated by a half-written copy still has a valid
163
+ * header, and an icon container built around one embeds a picture that decodes
164
+ * to nothing on the target OS with no error at pack time. Twelve bytes at the end
165
+ * is the cheapest check that catches that whole class.
166
+ */
167
+ export function readPngSize(bytes, what) {
168
+ if (bytes.length < 33 || PNG_SIGNATURE.some((byte, index) => bytes[index] !== byte)) {
169
+ throw new Error(`gjsify ship: ${what} is not a PNG — it does not start with the PNG signature.`);
170
+ }
171
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
172
+ // The first chunk must be IHDR: 13 bytes, type `IHDR`, then width and height big-endian.
173
+ if (view.getUint32(8) !== 13 || Buffer.from(bytes.subarray(12, 16)).toString('latin1') !== 'IHDR') {
174
+ throw new Error(`gjsify ship: ${what} is not a PNG this command can read — its first chunk is not IHDR.`);
175
+ }
176
+ const tail = bytes.length - PNG_IEND.length;
177
+ if (PNG_IEND.some((byte, index) => bytes[tail + index] !== byte)) {
178
+ throw new Error(`gjsify ship: ${what} is truncated — it does not end with an IEND chunk, so it would embed as a ` +
179
+ 'picture that decodes to nothing.');
180
+ }
181
+ return { width: view.getUint32(16), height: view.getUint32(20) };
182
+ }
183
+ /**
184
+ * The program the child GJS runs. `ARGV` is the SVG path followed by the sizes;
185
+ * stdout is one JSON object mapping each size to the PNG, base64.
186
+ *
187
+ * Legacy `imports`, deliberately: `gjs -c` takes a script, not a module, and the
188
+ * legacy namespace is the one a script can reach synchronously. `imports.cairo`
189
+ * is GJS's own cairo binding — `ImageSurface` and `writeToPNG` are its, not the
190
+ * typelib's, which has no constructors.
191
+ *
192
+ * `render_document` scales the document to the viewport, so each size is a
193
+ * fresh vector render and never a resample of a larger one — the difference
194
+ * between a crisp 16 px icon and a blurred one, which is the whole reason to
195
+ * render per size rather than once.
196
+ *
197
+ * Through a file rather than a memory surface's bytes: `writeToPNG` is the PNG
198
+ * ENCODER, and cairo is the one this process has. Base64 on stdout because it is
199
+ * the transport both runtimes' `child_process` read the same way.
200
+ */
201
+ export const RASTERIZE_SCRIPT = `
202
+ imports.gi.versions.Rsvg = '2.0';
203
+ const { Rsvg, GLib } = imports.gi;
204
+ const Cairo = imports.cairo;
205
+ const [svg, ...sizes] = ARGV;
206
+ const handle = Rsvg.Handle.new_from_file(svg);
207
+ const out = {};
208
+ for (const text of sizes) {
209
+ const size = Number(text);
210
+ const surface = new Cairo.ImageSurface(Cairo.Format.ARGB32, size, size);
211
+ const cr = new Cairo.Context(surface);
212
+ handle.render_document(cr, new Rsvg.Rectangle({ x: 0, y: 0, width: size, height: size }));
213
+ cr.$dispose();
214
+ surface.flush();
215
+ const path = GLib.build_filenamev([GLib.get_tmp_dir(), \`gjsify-ship-icon-\${GLib.get_monotonic_time()}-\${size}.png\`]);
216
+ surface.writeToPNG(path);
217
+ const [, bytes] = GLib.file_get_contents(path);
218
+ GLib.unlink(path);
219
+ out[size] = GLib.base64_encode(bytes);
220
+ }
221
+ print(JSON.stringify(out));
222
+ `.trim();
223
+ /**
224
+ * The launch env for the rasterizing child — the HOST's GI libdirs put back.
225
+ *
226
+ * THE INCIDENT, measured on both `macos-suites` legs (2026-09-13). `brew install
227
+ * gjs librsvg gobject-introspection` installs everything the script imports, and
228
+ * the render still died:
229
+ *
230
+ * Failed to load shared library 'librsvg-2.2.dylib' referenced by the typelib:
231
+ * dlopen(librsvg-2.2.dylib): tried: 'librsvg-2.2.dylib' (no such file),
232
+ * '/usr/local/Cellar/gjs/1.88.1/bin/../../../../opt/glib/lib/librsvg-2.2.dylib',
233
+ * '/usr/lib/librsvg-2.2.dylib' (not in dyld cache)
234
+ * JS ERROR: Error: Unsupported type void, deriving from fundamental void
235
+ *
236
+ * `/usr/local/lib` (x64) and `/opt/homebrew/lib` (arm64) — where Homebrew puts
237
+ * the dylib — are in NEITHER list, because a typelib names its library by bare
238
+ * leaf and Homebrew's `gjs` carries one rpath, into GLIB's keg alone. The second
239
+ * line is the symptom, not the cause: the typelib loads, its library does not,
240
+ * and the first call then marshals a type that is no longer resolvable. It reads
241
+ * like GJS was built without cairo, and this comment exists because that is what
242
+ * it was first diagnosed as.
243
+ *
244
+ * {@link buildNativeEnv} already repairs exactly this, and its own header calls
245
+ * itself "the ONE place `gjsify run`, `showcase`, `storybook`, `tsc` and `info`
246
+ * all pass through". `ship` opened a SECOND gjs child that went around it — which
247
+ * is the class, not the bug: the repair is per-call-site, so every new child
248
+ * inherits the gap until someone reads that header. `rasterizerEnv` is what the
249
+ * spec beside it pins, so the wiring is asserted and not just the composition.
250
+ *
251
+ * No packages are passed: the gap is in the host's loader, not in a prebuild we
252
+ * ship, and the child is a bare `gjs -c` that loads no `.node` at all.
253
+ *
254
+ * `env` FIRST, so the composed values win over the inherited ones rather than
255
+ * being shadowed by them.
256
+ */
257
+ export function rasterizerEnv(opts = {}) {
258
+ return { ...(opts.env ?? process.env), ...buildNativeEnv([], opts) };
259
+ }
260
+ /**
261
+ * Render an SVG at every size through librsvg, in a child GJS.
262
+ *
263
+ * Every PNG that comes back is read for its IHDR before it is returned: the size
264
+ * the child rendered is the size the caller asked for or the whole batch is
265
+ * refused, because the writer that would embed it trusts the map's key.
266
+ */
267
+ export async function rasterizeSvg(input) {
268
+ if (input.sizes.length === 0)
269
+ return new Map();
270
+ // THE CLI'S OWN SPAWN WRAPPER, not `promisify(execFile)`. Measured under the
271
+ // GJS-built CLI (`dist/cli.gjs.mjs`, gjs 1.88): `node:util`'s `promisify` over
272
+ // `@gjsify/child_process`'s `execFile` resolves with the bare stdout string —
273
+ // the polyfill has no `promisify.custom` producing Node's `{ stdout, stderr }`
274
+ // — so a destructured `stdout` was `undefined` on that runtime and only that
275
+ // one. `schemas.ts` uses the same pattern and never noticed, because it ignores
276
+ // the result. `spawnToCompletion` captures on both paths and picks the blocking
277
+ // one under GJS per its teardown contract; `'return'` is what every packer here
278
+ // declares, for the reason `msi.ts` gives at its own call.
279
+ const result = await (input.spawn ?? spawnToCompletion)(RASTERIZER, ['-c', RASTERIZE_SCRIPT, input.svg, ...input.sizes.map(String)], {
280
+ completion: 'return',
281
+ stdio: 'capture',
282
+ // The host's own GI libdirs — see {@link rasterizerEnv}. Without it a
283
+ // Mac with librsvg installed still cannot render.
284
+ env: rasterizerEnv(),
285
+ // A 1024 px PNG of a detailed icon is a few hundred kilobytes; base64 adds
286
+ // a third. Node's default 1 MiB is within reach of one large element.
287
+ maxBuffer: 64 * 1024 * 1024,
288
+ notFound: () => new Error(`gjsify ship: \`${RASTERIZER}\` is not on PATH, and it is what renders ${input.svg} into the ` +
289
+ `sizes an icon needs on this OS. ${RASTERIZER_HINT}.`),
290
+ });
291
+ const stdout = result.stdout ?? '';
292
+ if (result.code !== 0) {
293
+ const stderr = (result.stderr ?? '').trim();
294
+ // The two typelib-shaped failures GJS reports, spelled by their symptom
295
+ // rather than by GJS's wording, which is translated and changes.
296
+ const hint = /Rsvg|Typelib|cairo/i.test(stderr)
297
+ ? ` The Rsvg typelib or GJS's cairo binding is missing. ${RASTERIZER_HINT}.`
298
+ : '';
299
+ throw new Error(`gjsify ship: rendering ${input.svg} through ${RASTERIZER} failed (exit ${result.code ?? result.signal}).` +
300
+ `${hint}${stderr === '' ? '' : `\n ${stderr.split('\n').join('\n ')}`}`);
301
+ }
302
+ let parsed;
303
+ try {
304
+ parsed = JSON.parse(stdout);
305
+ }
306
+ catch {
307
+ throw new Error(`gjsify ship: ${RASTERIZER} printed something other than the rendered icon while rendering ` +
308
+ `${input.svg}: ${stdout.slice(0, 200)}`);
309
+ }
310
+ const out = new Map();
311
+ for (const size of input.sizes) {
312
+ const encoded = parsed[String(size)];
313
+ if (encoded === undefined) {
314
+ throw new Error(`gjsify ship: ${RASTERIZER} rendered no ${size} px image of ${input.svg}.`);
315
+ }
316
+ const png = new Uint8Array(Buffer.from(encoded, 'base64'));
317
+ const { width, height } = readPngSize(png, `the ${size} px render of ${input.svg}`);
318
+ if (width !== size || height !== size) {
319
+ throw new Error(`gjsify ship: asked ${RASTERIZER} for a ${size} px render of ${input.svg} and got ${width}×${height}.`);
320
+ }
321
+ out.set(size, png);
322
+ }
323
+ return out;
324
+ }
325
+ /**
326
+ * The raster set a layout needs, from whatever the project declares.
327
+ *
328
+ * Sized PNGs the project ships are used at their (IHDR-read) size; every size
329
+ * they leave uncovered is rendered from the scalable SVG; a size neither can
330
+ * answer is a refusal that names the file to add. The `-symbolic` icon is never
331
+ * a source: it is the monochrome status-bar glyph, and an app icon drawn from it
332
+ * would be a grey silhouette on both platforms.
333
+ */
334
+ export async function resolveAppIcon(input) {
335
+ const scalable = input.iconFiles.filter((file) => extname(file).toLowerCase() === '.svg' && !isSymbolicIcon(file));
336
+ const rasters = input.iconFiles.filter((file) => extname(file).toLowerCase() === '.png' && !isSymbolicIcon(file));
337
+ const suggestedSvg = `data/icons/hicolor/scalable/apps/${input.appId}.svg`;
338
+ if (scalable.length === 0 && rasters.length === 0) {
339
+ throw new Error(`gjsify ship: ${input.target} needs the app's icon and the project declares none. Add a scalable SVG ` +
340
+ `at ${suggestedSvg} (or point \`gjsify.ship.icon\` at one) and \`gjsify ship\` renders every ` +
341
+ 'size from it. An app with no icon here ships with the generic one, and nothing on the target ' +
342
+ 'OS says so.');
343
+ }
344
+ // The SVG the sizes are rendered from. More than one non-symbolic SVG is
345
+ // possible (a `scalable/` and a stray `data/icons/logo.svg`); the one under a
346
+ // `scalable/` directory is the hicolor convention's answer, else the first by
347
+ // path order — and the choice is printed by `source` either way.
348
+ const svg = scalable.find((file) => /(?:^|[\\/])scalable[\\/]/.test(file)) ?? scalable[0];
349
+ const png = new Map();
350
+ const providedBy = new Map();
351
+ for (const file of rasters) {
352
+ const bytes = new Uint8Array(readFileSync(file));
353
+ const { width, height } = readPngSize(bytes, file);
354
+ if (width !== height) {
355
+ throw new Error(`gjsify ship: ${file} is ${width}×${height}, and an app icon is square on every platform that ` +
356
+ 'reads one — Windows and macOS both index icon images by one edge length.');
357
+ }
358
+ const claimed = iconThemeDir(file);
359
+ if (claimed !== `${width}x${width}`) {
360
+ throw new Error(`gjsify ship: ${file} is ${width}×${height} but its path says ${claimed}. The theme looks the file ` +
361
+ 'up by the path, so this icon would be served at the wrong size — move it or resize it.');
362
+ }
363
+ if (!input.sizes.includes(width))
364
+ continue;
365
+ const previous = providedBy.get(width);
366
+ if (previous !== undefined) {
367
+ throw new Error(`gjsify ship: ${file} and ${previous} are both ${width} px icons. Keep one.`);
368
+ }
369
+ providedBy.set(width, file);
370
+ png.set(width, bytes);
371
+ }
372
+ const missing = input.sizes.filter((size) => !png.has(size));
373
+ if (missing.length > 0) {
374
+ if (svg === undefined) {
375
+ throw new Error(`gjsify ship: ${input.target} needs the app's icon at ${missing.join(', ')} px and the project ` +
376
+ `ships PNGs only for ${[...png.keys()].sort((a, b) => a - b).join(', ') || 'no size'}. Add a ` +
377
+ `scalable SVG at ${suggestedSvg} and the rest is rendered from it, or add ` +
378
+ `${missing.map((size) => `data/icons/hicolor/${size}x${size}/apps/${input.appId}.png`).join(', ')}.`);
379
+ }
380
+ const rendered = await (input.rasterize ?? rasterizeSvg)({ svg, sizes: missing });
381
+ for (const size of missing) {
382
+ const bytes = rendered.get(size);
383
+ if (bytes === undefined)
384
+ throw new Error(`gjsify ship: ${svg} rendered no ${size} px image.`);
385
+ png.set(size, bytes);
386
+ }
387
+ }
388
+ const source = providedBy.size === 0
389
+ ? `rendered from ${svg}`
390
+ : missing.length === 0
391
+ ? `${providedBy.size} sized PNG(s) the project ships`
392
+ : `${providedBy.size} sized PNG(s) the project ships, the rest rendered from ${svg}`;
393
+ return { source, png };
394
+ }
395
+ /** The sizes in a raster set, ascending — for log lines and error messages. */
396
+ export function iconSizes(icon) {
397
+ return [...icon.png.keys()].sort((a, b) => a - b);
398
+ }
@@ -234,7 +234,22 @@ function renderAppBundleLauncher(settings, bundleRelPath, layout, runtime) {
234
234
  'set -e',
235
235
  'here=$(cd -- "$(dirname -- "$0")" && pwd)',
236
236
  'contents=$(dirname -- "$here")',
237
- `XDG_DATA_DIRS="$contents/${under(dirs.data)}:\${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"`,
237
+ // NO SYSTEM DEFAULT, which is where this line differs from the prefix form's
238
+ // and is the one place the two must NOT be copies. `/usr/local/share:/usr/share`
239
+ // is the XDG base-directory spec's default for a LINUX system, and macOS has
240
+ // neither: `/usr/local/share` is Intel Homebrew's (Apple Silicon puts it under
241
+ // `/opt/homebrew`) and `/usr/share` holds Apple's own data, no `glib-2.0/schemas`
242
+ // and no `icons/hicolor`. So the baked pair either named nothing or named a
243
+ // package manager the user never opted into — and a `.app` carries its own
244
+ // closure precisely so it depends on neither.
245
+ //
246
+ // `${VAR:+:$VAR}` rather than `${VAR:-<default>}`: a developer who HAS exported
247
+ // `XDG_DATA_DIRS` still keeps it, appended after the bundle's own, and an unset
248
+ // one contributes nothing rather than an empty entry (which GLib reads as the
249
+ // current directory). Same semantics the `.cmd` form gets from `prependVar`,
250
+ // which is why the two now agree — before this they did not, and neither form
251
+ // said so.
252
+ `XDG_DATA_DIRS="$contents/${under(dirs.data)}"\${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}`,
238
253
  'export XDG_DATA_DIRS',
239
254
  ];
240
255
  // The two locators a carried runtime needs, and both are read by node-gi ITSELF
@@ -1,3 +1,4 @@
1
+ import type { AppIconRasters } from './icons.js';
1
2
  import type { HostOs, StagedFile } from './types.js';
2
3
  /**
3
4
  * The positional `gjsify ship <os>` takes, in ADR 0024 § A2's spelling.
@@ -49,6 +50,35 @@ export interface LayoutMetadataInput extends LayoutIdentity {
49
50
  * architecture; this is the same fact where the file is written.
50
51
  */
51
52
  arch: string;
53
+ /** `'app'` or `'cli'`: whether the layout is owed an icon at all. */
54
+ kind: 'app' | 'cli';
55
+ /**
56
+ * The app icon's rasters, or `undefined` for a `kind: 'cli'` project.
57
+ *
58
+ * A REQUIRED KEY that may hold `undefined`, not an optional one, for the
59
+ * reason this interface's own header gives: a call site has to SAY "no icon",
60
+ * and a row that writes one refuses an `app` that says it (`iconFor`). The
61
+ * orchestrator renders these for exactly the rows that declare
62
+ * {@link Layout.icon}, so the two guards meet in the middle and neither can
63
+ * quietly stage an iconless application again.
64
+ */
65
+ icon: AppIconRasters | undefined;
66
+ }
67
+ /**
68
+ * What a layout WRITES the app icon as, and the sizes it needs to do it.
69
+ *
70
+ * Declared on the row rather than computed from it because the orchestrator
71
+ * renders the rasters BEFORE the row runs — `resolveAppIcon` is asynchronous (a
72
+ * child GJS) and `Layout.metadata` is not — so the row states its needs up front
73
+ * and receives exactly those sizes. Absent on Linux, deliberately: the hicolor
74
+ * theme carries the SVG the project declared and GTK renders it itself, so there
75
+ * is nothing to convert and nothing to refuse.
76
+ */
77
+ export interface LayoutIcon {
78
+ /** Square edge lengths the writer embeds. */
79
+ sizes: readonly number[];
80
+ /** What is written, for the refusal: "the Windows launcher's icon". */
81
+ writes: string;
52
82
  }
53
83
  /** The four places a payload file can belong, as stage-relative directories. */
54
84
  export interface LayoutDirs {
@@ -146,6 +176,8 @@ export interface Layout {
146
176
  * so a wrapper directory here would become a doubled path there.
147
177
  */
148
178
  root: (identity: LayoutIdentity) => string;
179
+ /** The icon this layout writes from the shared rasters, or absent where the theme carries the source. */
180
+ icon?: LayoutIcon;
149
181
  /** The four destinations, resolved against one app's names. */
150
182
  dirs: (identity: LayoutIdentity) => LayoutDirs;
151
183
  /**