@gjsify/cli 0.64.1-next.249 → 0.64.2-next.253

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.
@@ -94,6 +94,22 @@ export declare class BuildAction {
94
94
  * on the prediction, not a second opinion.
95
95
  */
96
96
  private assertNodeOutputGlobals;
97
+ /**
98
+ * Post-bundle gate for `--app nativescript`: every GI namespace the emitted
99
+ * bundle loads has to be one the APK was told to carry (ADR 0117 § 2).
100
+ *
101
+ * THE ORACLE IS THE EMITTED FILE, like the two gates above and for the same
102
+ * reason — that is the artifact that gets installed. What it reads there is a
103
+ * LOWER BOUND, which is why only one direction is fatal: a namespace the scan
104
+ * found and the config omits WILL be missing from the APK, while a namespace the
105
+ * config names and the scan missed may simply be reached in a way no static read
106
+ * can see (`planGiSelection` carries the full argument).
107
+ *
108
+ * Android is the one target where this is not advice. A `.deb` that under-declares
109
+ * its typelibs can still be fixed by installing a package; an APK carries its
110
+ * whole GI stack or does without it.
111
+ */
112
+ private assertNativescriptGiSelected;
97
113
  /**
98
114
  * Post-bundle step for a `--app browser` HTML entry: write the processed
99
115
  * `index.html` beside the JS bundle, with the entry `<script>`'s `src`
@@ -15,6 +15,8 @@ import { collectEntryPaths, describeJsxConfig, findJsxEntryPoint, jsxConfigMissi
15
15
  import { giSystemProbes } from '../utils/gi-runtime-paths.js';
16
16
  import { escapeRawNulForGjs } from '../utils/gjs-source-escape.js';
17
17
  import { assertNodeBundleGlobalsShimmed } from '../utils/node-bundle-guard.js';
18
+ import { scanGiNamespaces } from '../utils/ship/gi-namespaces.js';
19
+ import { planGiSelection } from '../utils/ship/gi-selection.js';
18
20
  import { DEP_CHANGE_LOCKFILES } from '../utils/package-inputs.js';
19
21
  const DEFAULT_GJS_SHEBANG = '#!/usr/bin/env -S gjs -m';
20
22
  /**
@@ -439,6 +441,51 @@ export class BuildAction {
439
441
  assertNodeBundleGlobalsShimmed(code, item.fileName ? `${label} (${item.fileName})` : label);
440
442
  }
441
443
  }
444
+ /**
445
+ * Post-bundle gate for `--app nativescript`: every GI namespace the emitted
446
+ * bundle loads has to be one the APK was told to carry (ADR 0117 § 2).
447
+ *
448
+ * THE ORACLE IS THE EMITTED FILE, like the two gates above and for the same
449
+ * reason — that is the artifact that gets installed. What it reads there is a
450
+ * LOWER BOUND, which is why only one direction is fatal: a namespace the scan
451
+ * found and the config omits WILL be missing from the APK, while a namespace the
452
+ * config names and the scan missed may simply be reached in a way no static read
453
+ * can see (`planGiSelection` carries the full argument).
454
+ *
455
+ * Android is the one target where this is not advice. A `.deb` that under-declares
456
+ * its typelibs can still be fixed by installing a package; an APK carries its
457
+ * whole GI stack or does without it.
458
+ */
459
+ assertNativescriptGiSelected(result, outfile, outdir) {
460
+ const label = outfile ?? outdir ?? 'the nativescript bundle';
461
+ const scanned = new Set();
462
+ for (const item of result.output ?? []) {
463
+ if (item.type !== 'chunk')
464
+ continue;
465
+ const code = item.code;
466
+ if (typeof code !== 'string' || code.length === 0)
467
+ continue;
468
+ for (const namespace of scanGiNamespaces(code))
469
+ scanned.add(namespace);
470
+ }
471
+ let selection;
472
+ try {
473
+ selection = planGiSelection({
474
+ declared: this.configData.nativescript?.giNamespaces ?? [],
475
+ scanned: [...scanned],
476
+ });
477
+ }
478
+ catch (error) {
479
+ // Re-thrown with the artifact in it: `planGiSelection` is pure and knows
480
+ // the namespaces but not which file they were read from.
481
+ throw new Error(`gjsify build: ${label}: ${error.message}`);
482
+ }
483
+ for (const namespace of selection.unscanned) {
484
+ console.warn(`[gjsify] --app nativescript: nativescript.giNamespaces declares ${namespace}, which ` +
485
+ `${label} does not appear to import. The APK carries it anyway — drop it if nothing ` +
486
+ 'needs it, since every selected namespace costs APK size.');
487
+ }
488
+ }
442
489
  /**
443
490
  * Post-bundle step for a `--app browser` HTML entry: write the processed
444
491
  * `index.html` beside the JS bundle, with the entry `<script>`'s `src`
@@ -849,6 +896,13 @@ export class BuildAction {
849
896
  if (app === 'node' && autoMode && !pluginOpts.nodeGiGlobalsInject) {
850
897
  this.assertNodeOutputGlobals(writeResult, outfile, outdir);
851
898
  }
899
+ // Refuse to hand back a `--app nativescript` bundle that imports a GI namespace
900
+ // the APK will not carry. The payload is selected from a pinned sysroot at
901
+ // package-build time (ADR 0117), so there is no host to fall back on: the
902
+ // alternative to this gate is an APK that installs and throws at the import.
903
+ if (app === 'nativescript') {
904
+ this.assertNativescriptGiSelected(writeResult, outfile, outdir);
905
+ }
852
906
  // Browser HTML entry: emit the processed `index.html` beside the bundle
853
907
  // (engine-portable post-step — the native rolldown engine has no
854
908
  // `emitFile`, so this can't live in a plugin). `outfile` is guaranteed
@@ -454,13 +454,21 @@ async function assemble(args) {
454
454
  })),
455
455
  ];
456
456
  // THE APPLICATION ICON, rendered once for the sizes this layout's writers
457
- // embed — six for the `.exe`'s resource directory, seven for the `.icns` —
458
- // and nothing for a CLI or for Linux, where the theme carries the SVG the
459
- // project declared. `resolveAppIcon` REFUSES an app that has no source for a
460
- // size rather than staging without it: the released artifacts this closes
461
- // shipped for a year with the generic icon on two platforms, and the only
462
- // reason was that nothing said so (`utils/ship/icons.ts`).
463
- const icon = settings.kind === 'app' && layout.icon !== undefined
457
+ // embed — six for the `.exe`'s resource directory, seven for the `.icns`,
458
+ // three for the hicolor theme — and nothing for a CLI. `resolveAppIcon`
459
+ // REFUSES an app that has no source for a size rather than staging without
460
+ // it: the released artifacts this closes shipped for a year with the generic
461
+ // icon on two platforms, and the only reason was that nothing said so
462
+ // (`utils/ship/icons.ts`).
463
+ //
464
+ // …EXCEPT WHERE THE ROW SAYS THE ICON IS NOT REQUIRED, which is Linux and is
465
+ // not a softening of that rule. An icon-less app there is reported by
466
+ // `resolveShipSettings`'s warning and then by its own menu entry, so refusing
467
+ // would stop a project that has not drawn its icon yet from building a `.deb`
468
+ // over a defect it can already see (`LayoutIcon.required`). The guard belongs
469
+ // HERE as well as in `iconFor`, because `resolveAppIcon` is the half that
470
+ // refuses an empty `iconFiles` — reaching it at all is what has to not happen.
471
+ const icon = settings.kind === 'app' && layout.icon !== undefined && (layout.icon.required || settings.iconFiles.length > 0)
464
472
  ? await resolveAppIcon({
465
473
  iconFiles: settings.iconFiles,
466
474
  appId: settings.appId,
package/lib/config.js CHANGED
@@ -317,6 +317,25 @@ export class Config {
317
317
  'introspection to answer `gi://` with. `--app gjs` resolves it natively and `--app node` ' +
318
318
  'through @gjsify/node-gi, so neither needs a widget renderer standing in for it.');
319
319
  }
320
+ // And the same rule once more for the namespace selection, which only an
321
+ // Android payload can act on: on every other target `gi://` is answered by a
322
+ // system typelib or by `requireGi`, so a list here would be read, validated
323
+ // and then change nothing about the artifact.
324
+ if (configData.nativescript?.giNamespaces !== undefined && configData.app !== 'nativescript') {
325
+ throw new Error(`gjsify build: nativescript.giNamespaces has no effect on --app ${configData.app}. ` +
326
+ 'It selects what an Android APK carries (ADR 0117); every other target resolves ' +
327
+ '`gi://` against a GI stack the host already has.');
328
+ }
329
+ for (const entry of configData.nativescript?.giNamespaces ?? []) {
330
+ // `Ns` or `Ns-Version`, because that is what the AAR builder resolves
331
+ // against the typelib names in the sysroot. A free-form string here
332
+ // surfaces as "the prefix has no typelib for …" hours later, naming the
333
+ // typo rather than the shape.
334
+ if (!/^[A-Za-z][A-Za-z\d_]*(-\d+(\.\d+)*)?$/.test(entry)) {
335
+ throw new Error(`gjsify build: nativescript.giNamespaces entry "${entry}" is not a GI namespace. ` +
336
+ 'Write it as `Gtk` or `Gtk-4.0`.');
337
+ }
338
+ }
320
339
  if (cliArgs.globals !== undefined)
321
340
  configData.globals = cliArgs.globals;
322
341
  // Fallback applied post-merge (not as a yargs `default:`) so a
@@ -213,6 +213,37 @@ export interface ConfigData {
213
213
  format?: ConfigDataFormat;
214
214
  /** Config for `gjsify test`. `--entry`, `--outdir`, `--runtime` override these. */
215
215
  test?: ConfigDataTest;
216
+ /** Config for the `nativescript` target — what the APK carries (ADR 0117). */
217
+ nativescript?: ConfigDataNativescript;
218
+ }
219
+ /**
220
+ * What a NativeScript build decides that no other target has to.
221
+ *
222
+ * Its own namespace rather than top-level keys, for the reason `flatpak` and `ship`
223
+ * have one: these settings mean nothing on `gjs`, `node` or `browser`, and a
224
+ * top-level `giNamespaces` would read like a bundler option on every target.
225
+ */
226
+ export interface ConfigDataNativescript {
227
+ /**
228
+ * The GI namespaces this APK carries, as `Ns` or `Ns-Version` (ADR 0117 § 2).
229
+ *
230
+ * ANDROID HAS NO SYSTEM GI STACK, so unlike a `.deb` there is no `Depends:` that
231
+ * a host can satisfy: every typelib and every backing `.so` is in the APK or is
232
+ * not reachable at all. The payload is cross-compiled against a sha256-pinned
233
+ * sysroot long before an app build, so this list cannot conjure a namespace the
234
+ * sysroot lacks — it SELECTS from what the sysroot has.
235
+ *
236
+ * Declared rather than inferred, and the asymmetry is the point: the scan of the
237
+ * emitted bundle is a LOWER BOUND (a namespace reached through a computed
238
+ * specifier is invisible to it), so an imported namespace missing from this list
239
+ * fails the build by name, while a listed one the scan did not see is only a
240
+ * warning about a larger-than-necessary APK.
241
+ *
242
+ * Unset is not the same as `[]` in intent but is in effect: both mean the
243
+ * measured core set (GLib, GObject, Gio and friends), which backs the runtime
244
+ * itself and ships whether or not anything declares it.
245
+ */
246
+ giNamespaces?: string[];
216
247
  }
217
248
  /** Optional pointer to a non-default oxc config file. */
218
249
  export interface ConfigDataFormat {
@@ -43,6 +43,39 @@ export interface PickedFlatpakBuildKeys {
43
43
  export declare function pickFlatpakBuildKeys(preferred: ShipFlatpakOptions | undefined, legacy: ConfigDataFlatpak): PickedFlatpakBuildKeys;
44
44
  /** The branch a `ship` Flatpak is exported under when the project names none. */
45
45
  export declare const DEFAULT_SHIP_FLATPAK_BRANCH = "stable";
46
+ /**
47
+ * The scalable icon directory a `kind: 'app'` Flatpak drops before
48
+ * `appstreamcli compose` reads the tree.
49
+ *
50
+ * THE OTHER HALF OF `LAYOUTS.linux.icon`, and the half that cannot live in the
51
+ * layout: the stage is one payload for four Linux formats (ADR 0024 § 2) and the
52
+ * `.deb`, the `.rpm` and the AppImage all WANT the SVG — it is the icon GTK
53
+ * prefers, and `selectAppDirIcon` ranks it above every raster. Only the Flatpak
54
+ * has a build step that reads the theme, so only the Flatpak removes it, and it
55
+ * does so inside the sandbox rather than in the tree `ship` staged.
56
+ *
57
+ * WHY IT IS REMOVED AT ALL. `flatpak-builder` runs `appstreamcli compose` on the
58
+ * HOST to produce `/app/share/app-info`; AppStream 1.1.3 decodes icons through
59
+ * gdk-pixbuf, which reads SVG only through a loader module Fedora 44 no longer
60
+ * ships (`LAYOUTS.linux.icon` carries the measurement). Three probes: with the
61
+ * SVG present compose fails whether or not rasters are there, because it PREFERS
62
+ * the scalable icon and does not fall back; with the SVG gone and the rasters
63
+ * present it succeeds and emits 64×64 and 128×128 into the catalogue. So the
64
+ * rasters are necessary and this is what makes them sufficient.
65
+ *
66
+ * UNCONDITIONAL, not host-detected, and that is the point. `org.flatpak.Builder`
67
+ * on Flathub bundles a gdk-pixbuf that CAN read SVG, so probing the host would
68
+ * make the same source produce a different `.flatpak` depending on where it was
69
+ * built — the one property ADR 0024 § A2 asks a pack to keep. One tree, every
70
+ * host, at the cost of a scalable icon inside a sandbox whose only reader
71
+ * (`Icon=<appId>` in the exported desktop entry) resolves the 256 px raster.
72
+ *
73
+ * THE UPSTREAM FIX IS NOT OURS: `libappstream-compose.so.0` already links
74
+ * `librsvg-2.so.2` and uses it for fonts alone, so AppStream could render the
75
+ * icon without gdk-pixbuf at all. Reported as an `Upstream (AppStream, file by
76
+ * hand)` issue rather than filed there (root AGENTS.md).
77
+ */
78
+ export declare const SCALABLE_ICONS_CLEANUP = "/share/icons/hicolor/scalable";
46
79
  export interface ShipFlatpakInput {
47
80
  ship: ConfigDataShip;
48
81
  flatpak: ConfigDataFlatpak;
@@ -88,6 +88,39 @@ export function pickFlatpakBuildKeys(preferred, legacy) {
88
88
  }
89
89
  /** The branch a `ship` Flatpak is exported under when the project names none. */
90
90
  export const DEFAULT_SHIP_FLATPAK_BRANCH = 'stable';
91
+ /**
92
+ * The scalable icon directory a `kind: 'app'` Flatpak drops before
93
+ * `appstreamcli compose` reads the tree.
94
+ *
95
+ * THE OTHER HALF OF `LAYOUTS.linux.icon`, and the half that cannot live in the
96
+ * layout: the stage is one payload for four Linux formats (ADR 0024 § 2) and the
97
+ * `.deb`, the `.rpm` and the AppImage all WANT the SVG — it is the icon GTK
98
+ * prefers, and `selectAppDirIcon` ranks it above every raster. Only the Flatpak
99
+ * has a build step that reads the theme, so only the Flatpak removes it, and it
100
+ * does so inside the sandbox rather than in the tree `ship` staged.
101
+ *
102
+ * WHY IT IS REMOVED AT ALL. `flatpak-builder` runs `appstreamcli compose` on the
103
+ * HOST to produce `/app/share/app-info`; AppStream 1.1.3 decodes icons through
104
+ * gdk-pixbuf, which reads SVG only through a loader module Fedora 44 no longer
105
+ * ships (`LAYOUTS.linux.icon` carries the measurement). Three probes: with the
106
+ * SVG present compose fails whether or not rasters are there, because it PREFERS
107
+ * the scalable icon and does not fall back; with the SVG gone and the rasters
108
+ * present it succeeds and emits 64×64 and 128×128 into the catalogue. So the
109
+ * rasters are necessary and this is what makes them sufficient.
110
+ *
111
+ * UNCONDITIONAL, not host-detected, and that is the point. `org.flatpak.Builder`
112
+ * on Flathub bundles a gdk-pixbuf that CAN read SVG, so probing the host would
113
+ * make the same source produce a different `.flatpak` depending on where it was
114
+ * built — the one property ADR 0024 § A2 asks a pack to keep. One tree, every
115
+ * host, at the cost of a scalable icon inside a sandbox whose only reader
116
+ * (`Icon=<appId>` in the exported desktop entry) resolves the 256 px raster.
117
+ *
118
+ * THE UPSTREAM FIX IS NOT OURS: `libappstream-compose.so.0` already links
119
+ * `librsvg-2.so.2` and uses it for fonts alone, so AppStream could render the
120
+ * icon without gdk-pixbuf at all. Reported as an `Upstream (AppStream, file by
121
+ * hand)` issue rather than filed there (root AGENTS.md).
122
+ */
123
+ export const SCALABLE_ICONS_CLEANUP = '/share/icons/hicolor/scalable';
91
124
  /**
92
125
  * Resolve the Flatpak half of `gjsify.ship`, reading the legacy block where the
93
126
  * new one is silent, and applying `ship`'s own defaults.
@@ -116,7 +149,13 @@ export function resolveShipFlatpakSettings(input) {
116
149
  // extension not being installed.
117
150
  appendPath: appendPath ?? (extensions.length > 0 ? deriveAppendPath(extensions) : []),
118
151
  finishArgs: finishArgs ?? (input.kind === 'cli' ? DEFAULT_CLI_FINISH_ARGS : DEFAULT_GUI_FINISH_ARGS),
119
- cleanup: cleanup ?? [],
152
+ // APPENDED to what the project declared, not defaulted to — the one entry
153
+ // here that is not the author's choice. A project with its own `cleanup`
154
+ // list would otherwise lose the entry that keeps its own Flatpak buildable
155
+ // (see {@link SCALABLE_ICONS_CLEANUP}), and `??` cannot express "ours as
156
+ // well as theirs". Deduplicated so a project that already wrote the glob —
157
+ // the workaround this replaces — gets one, and in ITS position.
158
+ cleanup: [...new Set([...(cleanup ?? []), ...(input.kind === 'app' ? [SCALABLE_ICONS_CLEANUP] : [])])],
120
159
  };
121
160
  return { settings, warnings: fromLegacy.length === 0 ? [] : [deprecationWarning(fromLegacy)] };
122
161
  }
@@ -0,0 +1,18 @@
1
+ /** What {@link planGiSelection} decided, and what it wants the build to say out loud. */
2
+ export interface GiSelection {
3
+ /** The namespaces to hand the AAR builder, as the config spells them, deduped and sorted. */
4
+ selected: string[];
5
+ /** Declared but not found in the bundle — worth a warning, never a refusal. */
6
+ unscanned: string[];
7
+ }
8
+ /**
9
+ * Reconcile the configured namespace set with the one the emitted bundle imports.
10
+ *
11
+ * @param spec.declared `nativescript.giNamespaces`, as `Ns` or `Ns-Version`
12
+ * @param spec.scanned what {@link scanGiNamespaces} read off the emitted bundle
13
+ * @throws when the bundle imports a namespace the config does not cover
14
+ */
15
+ export declare function planGiSelection({ declared, scanned }: {
16
+ declared: string[];
17
+ scanned: string[];
18
+ }): GiSelection;
@@ -0,0 +1,73 @@
1
+ // Which GI namespaces does this Android APK carry? — ADR 0117 § 2, the CLI half.
2
+ //
3
+ // THE CONFIG DECIDES AND THE BUNDLE IS CHECKED AGAINST IT, which is the asymmetry
4
+ // the ADR turns on. The payload is cross-compiled hours earlier by
5
+ // `build-gi-runtime-android.mjs` against a sha256-pinned sysroot; nothing at app-build
6
+ // time can conjure a typelib that is not in it. So the config is the input, and the
7
+ // scan of the emitted bundle is the EVIDENCE that the input is complete.
8
+ //
9
+ // The scan is a LOWER BOUND, and the two directions of disagreement are therefore not
10
+ // symmetric:
11
+ //
12
+ // * IMPORTED, NOT CONFIGURED → build error naming the namespace. The scanner found a
13
+ // real import; the APK will not have the typelib; the device throws at that import,
14
+ // after the download, reading like an application bug. This is the same failure
15
+ // `deriveDepends` refuses for distro packages (ADR 0024 § 6), reached from Android.
16
+ // * CONFIGURED, NOT IMPORTED → warning. `scanGiNamespaces` reads the emitted file and
17
+ // cannot see a namespace reached through a computed specifier, nor one loaded by a
18
+ // native dependency. Refusing here would make a correct project unbuildable on the
19
+ // scanner's blind spot — so it reports a probably-larger-than-necessary APK and
20
+ // builds it.
21
+ //
22
+ // COMPARED BY NAMESPACE, NOT BY STRING. A config may pin an ABI (`Gtk-4.0`) where the
23
+ // bundle's specifier carries no version (`import 'gi://Gtk'`), and vice versa. Both
24
+ // sides mean the same namespace, and string equality would fail a correct project on
25
+ // the spelling.
26
+ /** `Gtk-4.0` → `Gtk`; `Gtk` → `Gtk`. */
27
+ function namespaceOf(entry) {
28
+ const dash = entry.indexOf('-');
29
+ return dash === -1 ? entry : entry.slice(0, dash);
30
+ }
31
+ /**
32
+ * The namespaces every AAR carries whether or not anything declares them.
33
+ *
34
+ * These back `libnode_gi.so` itself, so they are in the payload's DT_NEEDED closure
35
+ * before any selection happens (ADR 0110's measured core set). Demanding them in the
36
+ * config would make the key boilerplate and fail every project that exists today, for
37
+ * a selection that cannot change the bytes.
38
+ */
39
+ const ALWAYS_PRESENT = new Set(['GLib', 'GObject', 'Gio', 'GioUnix', 'GLibUnix', 'GModule', 'GIRepository']);
40
+ /**
41
+ * Reconcile the configured namespace set with the one the emitted bundle imports.
42
+ *
43
+ * @param spec.declared `nativescript.giNamespaces`, as `Ns` or `Ns-Version`
44
+ * @param spec.scanned what {@link scanGiNamespaces} read off the emitted bundle
45
+ * @throws when the bundle imports a namespace the config does not cover
46
+ */
47
+ export function planGiSelection({ declared, scanned }) {
48
+ // A bare and a pinned declaration of one namespace are one selection, and the
49
+ // pinned spelling wins: it says something the bare one does not.
50
+ const byNamespace = new Map();
51
+ for (const entry of declared) {
52
+ const ns = namespaceOf(entry);
53
+ const held = byNamespace.get(ns);
54
+ if (held === undefined || (held === ns && entry !== ns))
55
+ byNamespace.set(ns, entry);
56
+ }
57
+ const missing = [
58
+ ...new Set(scanned.map(namespaceOf).filter((ns) => !byNamespace.has(ns) && !ALWAYS_PRESENT.has(ns))),
59
+ ].sort();
60
+ if (missing.length > 0) {
61
+ throw new Error(`The bundle imports ${missing.join(', ')}, which nativescript.giNamespaces does not declare. ` +
62
+ 'An Android APK carries only the typelibs the config selects (ADR 0117), so this would ' +
63
+ `install and then throw at the import. Add ${missing.length === 1 ? 'it' : 'them'} to ` +
64
+ 'nativescript.giNamespaces, or stop importing ' +
65
+ `${missing.length === 1 ? 'it' : 'them'}.`);
66
+ }
67
+ const seen = new Set(scanned.map(namespaceOf));
68
+ const unscanned = [...byNamespace.entries()]
69
+ .filter(([ns]) => !seen.has(ns))
70
+ .map(([, entry]) => entry)
71
+ .sort();
72
+ return { selected: [...byNamespace.values()].sort(), unscanned };
73
+ }
@@ -70,15 +70,44 @@ export interface LayoutMetadataInput extends LayoutIdentity {
70
70
  * Declared on the row rather than computed from it because the orchestrator
71
71
  * renders the rasters BEFORE the row runs — `resolveAppIcon` is asynchronous (a
72
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
73
+ * and receives exactly those sizes.
74
+ *
75
+ * ON ALL THREE ROWS, and it was absent from the Linux one until a Flatpak build
76
+ * measured why that was wrong. The reasoning it was absent for — "the hicolor
74
77
  * theme carries the SVG the project declared and GTK renders it itself, so there
75
- * is nothing to convert and nothing to refuse.
78
+ * is nothing to convert" — is true of GTK and NOT of the tooling that reads the
79
+ * same theme at BUILD time: `appstreamcli compose` resolves an SVG through
80
+ * gdk-pixbuf, which needs an SVG LOADER MODULE, and a host without one fails the
81
+ * whole compose run rather than falling back to a raster. The same host-packaging
82
+ * fact `icons.ts` records for the rasterizer, one phase to the right — see
83
+ * {@link LAYOUTS.linux.icon}.
76
84
  */
77
85
  export interface LayoutIcon {
78
86
  /** Square edge lengths the writer embeds. */
79
87
  sizes: readonly number[];
80
88
  /** What is written, for the refusal: "the Windows launcher's icon". */
81
89
  writes: string;
90
+ /**
91
+ * Whether a `kind: 'app'` stage is REFUSED when the project declares no icon
92
+ * source, or assembled without one.
93
+ *
94
+ * REQUIRED on every row, like {@link Layout.metadata} and
95
+ * {@link Layout.arches}: `false` is an answer, and an optional field is one a
96
+ * fourth OS forgets and then silently inherits whichever default was typed
97
+ * first.
98
+ *
99
+ * The two answers are different FAILURE MODES, not different strictnesses.
100
+ * On macOS and Windows the icon lives inside a container — an `.icns` named by
101
+ * `CFBundleIconFile`, a `.rsrc` in the launcher — and an application that
102
+ * ships without it shows the generic icon with nothing anywhere saying so:
103
+ * measured on released artifacts, for a year (`icons.ts`). On Linux the icon
104
+ * is a FILE in a theme, the desktop entry's `Icon=` names it, and an
105
+ * icon-less app is visibly icon-less in the menu the first time it is
106
+ * installed — which `resolveShipSettings` already warns about. Turning that
107
+ * warning into a refusal would stop a project that has not drawn its icon yet
108
+ * from building a `.deb` at all, for a defect its own menu entry reports.
109
+ */
110
+ required: boolean;
82
111
  }
83
112
  /** The four places a payload file can belong, as stage-relative directories. */
84
113
  export interface LayoutDirs {
@@ -252,6 +281,15 @@ export interface LayoutArches {
252
281
  /** What a reader must know to act on the refusal: who blocks it, and where it is tracked. */
253
282
  why: string;
254
283
  }
284
+ /**
285
+ * The square sizes the Linux row writes into the hicolor theme.
286
+ *
287
+ * Here and not in a writer module like {@link ICO_SIZES} and {@link ICNS_SIZES},
288
+ * because there is no writer: the rasters ARE the artifact, one file per size,
289
+ * and the row below is the only thing that names them. See
290
+ * {@link LAYOUTS.linux.icon} for what asked for each of the three.
291
+ */
292
+ export declare const HICOLOR_RASTER_SIZES: readonly [64, 128, 256];
255
293
  export declare const LAYOUTS: Record<LayoutName, Layout>;
256
294
  /** Every layout name, derived from the table so a new row cannot be missed. */
257
295
  export declare const LAYOUT_NAMES: LayoutName[];
@@ -36,6 +36,15 @@ import { SHARE } from './share-dirs.js';
36
36
  * `hdiutil` time, which is three milestones away and on another host.
37
37
  */
38
38
  const BUNDLE_NAME_FORBIDDEN = /[/:\\]/;
39
+ /**
40
+ * The square sizes the Linux row writes into the hicolor theme.
41
+ *
42
+ * Here and not in a writer module like {@link ICO_SIZES} and {@link ICNS_SIZES},
43
+ * because there is no writer: the rasters ARE the artifact, one file per size,
44
+ * and the row below is the only thing that names them. See
45
+ * {@link LAYOUTS.linux.icon} for what asked for each of the three.
46
+ */
47
+ export const HICOLOR_RASTER_SIZES = [64, 128, 256];
39
48
  /**
40
49
  * The value `ATSApplicationFontsPath` takes for this bundle, or nothing when the
41
50
  * payload carries no face (ADR 0038).
@@ -81,6 +90,10 @@ function iconFor(input, layout) {
81
90
  if (input.kind !== 'app')
82
91
  return undefined;
83
92
  if (input.icon === undefined) {
93
+ // The row's own answer, not a uniform one — {@link LayoutIcon.required}
94
+ // carries which failure mode this layout has.
95
+ if (LAYOUTS[layout].icon?.required !== true)
96
+ return undefined;
84
97
  throw new Error(`gjsify ship: the ${layout} layout writes the application's icon and was given none. ` +
85
98
  'A `kind: "app"` stage is rendered its icon by `resolveAppIcon` before the layout runs; ' +
86
99
  'reaching this row without one is a caller that skipped that step.');
@@ -105,10 +118,78 @@ export const LAYOUTS = {
105
118
  data: 'share',
106
119
  other: '',
107
120
  }),
121
+ // THE RASTERS THE BUILD-TIME READERS OF THE THEME NEED, and the one row
122
+ // whose icon is written as ordinary payload files rather than into a
123
+ // container. A GNOME app declares one scalable SVG, `planIcons` stages it
124
+ // at `share/icons/hicolor/scalable/apps/<appId>.svg`, and GTK renders it —
125
+ // so for three milestones this row wrote nothing.
126
+ //
127
+ // WHAT READS THE THEME BEFORE GTK DOES is `appstreamcli compose`, which
128
+ // `flatpak-builder` runs to produce `/app/share/app-info` — the catalogue
129
+ // every app store shows the app out of. AppStream 1.1.3 loads an icon
130
+ // through gdk-pixbuf, gdk-pixbuf reads SVG only through a LOADER MODULE,
131
+ // and that module is a property of the host's packaging rather than of
132
+ // this payload: on Fedora 44, workstation and `ghcr.io/gjsify/ci-fedora:44`
133
+ // alike, `/usr/lib64/gdk-pixbuf-2.0/2.10.0/loaders/` holds the xpm loader
134
+ // and nothing else, and `dnf provides '*/libpixbufloader-svg.so'` answers
135
+ // with no package at all — librsvg dropped it. So the compose run dies
136
+ // `file-read-error` ("image file format unknown") on the SVG and takes the
137
+ // whole `gjsify ship linux --target flatpak` with it. Exactly the fact
138
+ // `icons.ts` records for the rasterizer, one phase to the right, and the
139
+ // reason this is the SAME list of sizes rather than a flatpak-only patch:
140
+ // a `.deb` installing rasters beside its SVG is what every other GNOME app
141
+ // does, and the artifact stays one payload.
142
+ //
143
+ // IT IS NOT ENOUGH TO ADD THEM, measured in three probes: with the SVG
144
+ // present appstreamcli PREFERS it and does not fall back, so SVG + 128 and
145
+ // SVG + 64/128/256 both still fail and PNG-only succeeds. The SVG has to
146
+ // leave the tree the compose run sees — which is a Flatpak-only move,
147
+ // because only there is the tree built, and it is the generated manifest's
148
+ // `cleanup` that makes it (`flatpak-config.ts`).
149
+ //
150
+ // 64/128/256 is what a store needs and what the theme spec's own
151
+ // `index.theme` sizes cover: AppStream asks for 64 and 128 (it emitted
152
+ // exactly those two from the PNG-only probe), and 256 is the HiDPI entry
153
+ // Flathub's quality guidelines expect.
154
+ icon: {
155
+ sizes: HICOLOR_RASTER_SIZES,
156
+ writes: "the hicolor theme's raster icons (`share/icons/hicolor/<n>x<n>/apps/<appId>.png`)",
157
+ // The ONE row that assembles without an icon rather than refusing —
158
+ // `LayoutIcon.required` carries why.
159
+ required: false,
160
+ },
108
161
  // A `/usr` prefix has no manifest of its own — everything a Linux package
109
162
  // says about itself is in the `.deb`/`.rpm` header or in the freedesktop
110
- // files the payload already carries.
111
- metadata: () => [],
163
+ // files the payload already carries. What it owns is the rasters above.
164
+ //
165
+ // SKIPPING A SIZE THE PAYLOAD ALREADY STAGES is not an optimisation: a
166
+ // project shipping its own hand-tuned `data/icons/hicolor/128x128/apps/…`
167
+ // has it planned by `planIcons` at the identical path, and `placeStage`
168
+ // REFUSES two sources for one destination (`claim`). The project's file is
169
+ // the one to keep — it is drawn for that size, which is the whole point of
170
+ // the hicolor convention (`icons.ts`) — so the row yields rather than
171
+ // colliding. `resolveAppIcon` has already read that same PNG into the
172
+ // raster map, so the bytes agree either way; what differs is only which
173
+ // file the stage records as the source.
174
+ metadata: (input, payload) => {
175
+ const icon = iconFor(input, 'linux');
176
+ if (icon === undefined)
177
+ return [];
178
+ const planned = new Set(payload.map((file) => file.path));
179
+ const out = [];
180
+ for (const size of [...icon.png.keys()].sort((a, b) => a - b)) {
181
+ const rel = `${SHARE.icons}/${size}x${size}/apps/${input.appId}.png`;
182
+ const data = icon.png.get(size);
183
+ if (data === undefined || planned.has(rel))
184
+ continue;
185
+ out.push({
186
+ path: place(LAYOUTS.linux, input, rel),
187
+ mode: 0o644,
188
+ source: { kind: 'bytes', data },
189
+ });
190
+ }
191
+ return out;
192
+ },
112
193
  // Every architecture Debian and rpm have a name for, which is what
113
194
  // `DEBIAN_ARCH`/`RPM_ARCH` already enumerate — the layout adds nothing.
114
195
  arches: null,
@@ -213,7 +294,11 @@ export const LAYOUTS = {
213
294
  // released 0.8.0 bundle of one app (macOS 15.7, 2026-09-13): no key, no
214
295
  // `.icns`, generic icon — the twin of the Windows defect `pe-launcher.ts`
215
296
  // records.
216
- icon: { sizes: ICNS_SIZES, writes: 'the macOS bundle icon (`Contents/Resources/<binaryName>.icns`)' },
297
+ icon: {
298
+ sizes: ICNS_SIZES,
299
+ writes: 'the macOS bundle icon (`Contents/Resources/<binaryName>.icns`)',
300
+ required: true,
301
+ },
217
302
  metadata: (input, payload) => {
218
303
  const icon = iconFor(input, 'darwin');
219
304
  const iconFile = icon === undefined ? undefined : `${input.binaryName}.icns`;
@@ -359,7 +444,11 @@ export const LAYOUTS = {
359
444
  // the target's out of its resource directory — so the `.exe` carries the
360
445
  // six sizes as `RT_ICON`/`RT_GROUP_ICON` (`ico.ts`, `pe-launcher.ts`) and
361
446
  // there is no `.ico` file in the program directory to fall out of sync.
362
- icon: { sizes: ICO_SIZES, writes: 'the Windows launcher icon (`.rsrc` of `<binaryName>.exe`)' },
447
+ icon: {
448
+ sizes: ICO_SIZES,
449
+ writes: 'the Windows launcher icon (`.rsrc` of `<binaryName>.exe`)',
450
+ required: true,
451
+ },
363
452
  metadata: (input) => {
364
453
  const icon = iconFor(input, 'windows');
365
454
  return input.arch === 'x64'
@@ -181,9 +181,10 @@ export function resolveShipSettings(input) {
181
181
  const description = descriptionParagraphs(metadata.description) ?? [summary];
182
182
  const sections = sectionsFor(metadata.categories);
183
183
  if (kind === 'app' && discovered.iconFiles.length === 0) {
184
- warnings.push(`gjsify ship: no icon found — the installed app will show a placeholder in menus and app stores ` +
185
- 'on Linux, and the darwin and windows layouts refuse to stage without one. Point ' +
186
- '`gjsify.ship.icon` at an SVG (or a directory of sized PNGs).');
184
+ warnings.push(`gjsify ship: no icon found — the installed app will show a placeholder in menus on Linux, the ` +
185
+ 'Flatpak will carry no catalogue icon for an app store to show, and the darwin and windows ' +
186
+ 'layouts refuse to stage without one. Point `gjsify.ship.icon` at an SVG (or a directory of ' +
187
+ 'sized PNGs).');
187
188
  }
188
189
  // Resolved here rather than in the packer, because the packer may run on a
189
190
  // host with no `package.json` at all (ADR 0024 § A2) — and because the