@gjsify/cli 0.45.0 → 0.47.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 (37) hide show
  1. package/dist/cli.gjs.mjs +144 -135
  2. package/lib/commands/onboard.js +11 -0
  3. package/lib/commands/publish.d.ts +41 -0
  4. package/lib/commands/publish.js +222 -2
  5. package/lib/commands/ship.js +89 -76
  6. package/lib/commands/upgrade.d.ts +23 -0
  7. package/lib/commands/upgrade.js +95 -12
  8. package/lib/templates/install.mjs.tmpl +2 -2
  9. package/lib/types/config-data.d.ts +64 -0
  10. package/lib/utils/gi-search-path.js +11 -3
  11. package/lib/utils/publish-readback.d.ts +132 -0
  12. package/lib/utils/publish-readback.js +273 -0
  13. package/lib/utils/ship/discover.js +85 -0
  14. package/lib/utils/ship/formats.d.ts +23 -0
  15. package/lib/utils/ship/formats.js +44 -0
  16. package/lib/utils/ship/launcher.js +57 -0
  17. package/lib/utils/ship/layout.d.ts +64 -2
  18. package/lib/utils/ship/layout.js +169 -12
  19. package/lib/utils/ship/msi.js +24 -7
  20. package/lib/utils/ship/payload.js +22 -3
  21. package/lib/utils/ship/pe-launcher.d.ts +29 -0
  22. package/lib/utils/ship/pe-launcher.js +663 -0
  23. package/lib/utils/ship/plan.js +35 -0
  24. package/lib/utils/ship/plist.d.ts +41 -1
  25. package/lib/utils/ship/plist.js +59 -1
  26. package/lib/utils/ship/rpm.js +7 -0
  27. package/lib/utils/ship/settings.d.ts +76 -5
  28. package/lib/utils/ship/settings.js +89 -8
  29. package/lib/utils/ship/share-dirs.d.ts +30 -0
  30. package/lib/utils/ship/share-dirs.js +30 -0
  31. package/lib/utils/ship/signing.d.ts +122 -6
  32. package/lib/utils/ship/signing.js +256 -67
  33. package/lib/utils/ship/stage-writer.js +20 -1
  34. package/lib/utils/ship/types.d.ts +34 -2
  35. package/lib/utils/system-gi.d.ts +20 -5
  36. package/lib/utils/system-gi.js +36 -13
  37. package/package.json +19 -19
@@ -25,6 +25,16 @@
25
25
  // the tree was installed. Getting that from the launcher rather than from the app
26
26
  // keeps the app free of install-layout knowledge.
27
27
  //
28
+ // IT ALSO CARRIES THE FONTS, and that reader is not GLib (ADR 0038). fontconfig
29
+ // expands the stock `fonts.conf`'s `<dir prefix="xdg">fonts</dir>` over
30
+ // `XDG_DATA_DIRS` as well as `XDG_DATA_HOME`, so this one export is the whole
31
+ // mechanism behind `gjsify.ship.fonts` outside a `/usr` prefix and is what
32
+ // reaches `/app/share/fonts` in a Flatpak. `fonts-conf(5)` documents only
33
+ // `XDG_DATA_HOME`: the behaviour is NOT re-derivable from the manual, so do not
34
+ // narrow this line to the GLib readers on the strength of what the man page
35
+ // says. Measured across fontconfig 2.14.1 → 2.18.3 — ADR 0038 § What was
36
+ // measured has the versions and the control.
37
+ //
28
38
  // THREE FORMS, because neither other OS runs this file. macOS runs a `/bin/sh`
29
39
  // script too but cannot use `readlink -f` and must not be handed a `DYLD_*`;
30
40
  // Windows runs `cmd.exe`, which shares no syntax with either. What they DO share
@@ -33,6 +43,20 @@
33
43
  // dispatch is a `switch` with a `never` guard, the same closed-vocabulary
34
44
  // discipline `packOne` uses for the packers.
35
45
  import { posix } from 'node:path';
46
+ import { SHARE } from './share-dirs.js';
47
+ /**
48
+ * `<the layout's data dir>/fonts/<appId>` — the staged face directory (ADR 0038).
49
+ *
50
+ * ONE spelling, and the leaf comes off `SHARE.fonts` rather than being typed out,
51
+ * because all three forms below want a path `planFonts` decides. A literal in each
52
+ * would keep naming the old directory the day that constant moves, and the suites
53
+ * would then pass PAST each other rather than fail: the launcher tests read this
54
+ * string, the payload test reads the plan, and neither can see that the two
55
+ * disagree.
56
+ */
57
+ function stagedFontDir(dataDir, appId) {
58
+ return `${dataDir}/${posix.basename(SHARE.fonts)}/${appId}`;
59
+ }
36
60
  /** POSIX-shell single-quote: the only escape inside `'…'` is `'\''`. */
37
61
  function shellQuote(value) {
38
62
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -128,6 +152,15 @@ function renderPrefixLauncher(settings, bundleRelPath, layout) {
128
152
  if (settings.localeFiles.length > 0) {
129
153
  lines.push(`GJSIFY_LOCALE_DIR="$prefix"/${dirs.data}/locale`, 'export GJSIFY_LOCALE_DIR');
130
154
  }
155
+ // Where the faces landed, handed over for the same reason and in the same
156
+ // shape as the catalogue directory above (ADR 0038) — and REDUNDANT on this
157
+ // OS, where fontconfig has already found them through XDG_DATA_DIRS. Exported
158
+ // anyway, because the one thing a consumer must not have to write is an OS
159
+ // branch around a path this command chose; `Layout.fontGap` says where the
160
+ // variable is the mechanism instead of a courtesy.
161
+ if (settings.fontFiles.length > 0) {
162
+ lines.push(`GJSIFY_FONT_DIR="$prefix"/${stagedFontDir(dirs.data, settings.appId)}`, 'export GJSIFY_FONT_DIR');
163
+ }
131
164
  lines.push(`exec ${execLine(layout, `"$prefix"/${dirs.bundle}/${bundleRelPath}`, settings)} "$@"`, '');
132
165
  return lines.join('\n');
133
166
  }
@@ -241,6 +274,15 @@ function renderAppBundleLauncher(settings, bundleRelPath, layout, runtime) {
241
274
  if (settings.localeFiles.length > 0) {
242
275
  lines.push(`GJSIFY_LOCALE_DIR="$contents/${under(dirs.data)}/locale"`, 'export GJSIFY_LOCALE_DIR');
243
276
  }
277
+ // The face directory, for the same reason as above — and on THIS OS it is
278
+ // informational rather than the mechanism: `Info.plist`'s
279
+ // `ATSApplicationFontsPath` has already had macOS activate the same directory
280
+ // before this script's interpreter started (ADR 0038). Exported all the same,
281
+ // so a consumer that wants to name a bundled family has one spelling on three
282
+ // operating systems instead of an OS branch.
283
+ if (settings.fontFiles.length > 0) {
284
+ lines.push(`GJSIFY_FONT_DIR="$contents/${stagedFontDir(under(dirs.data), settings.appId)}"`, 'export GJSIFY_FONT_DIR');
285
+ }
244
286
  // `"$here/node"`, not `node`. The bare name is true of a developer's machine
245
287
  // and false of a `.app` a stranger downloads, and the difference is not a
246
288
  // degraded experience: macOS ships no Node at all, so the launcher's first act
@@ -324,6 +366,16 @@ function renderWindowsLauncher(settings, bundleRelPath, layout, runtime) {
324
366
  if (settings.localeFiles.length > 0) {
325
367
  lines.push(`set "GJSIFY_LOCALE_DIR=${at(dirs.data)}\\locale"`);
326
368
  }
369
+ // THE ONE OS WHERE THIS VARIABLE IS THE WHOLE HANDOVER (ADR 0038), because
370
+ // Windows has no declarative font activation at all and the app must call
371
+ // `PangoCairo.FontMap.get_default().add_font_file()` itself. This line is how
372
+ // it is told where. Do NOT drop it as redundant with XDG_DATA_DIRS above —
373
+ // that one reaches no face here. Which three mechanisms look like they would
374
+ // and do not is `LAYOUTS.windows.fontGap` in `layout.ts`, beside the string
375
+ // the command prints.
376
+ if (settings.fontFiles.length > 0) {
377
+ lines.push(`set "GJSIFY_FONT_DIR=${at(stagedFontDir(dirs.data, settings.appId))}"`);
378
+ }
327
379
  // `"%HERE%node.exe"`, not `node`. The bare name is true of a developer's
328
380
  // machine and false of a program directory a stranger unzips: Windows ships no
329
381
  // Node at all, so the launcher's first act would be `'node' is not recognized
@@ -364,6 +416,11 @@ function windowsPath(rel) {
364
416
  * interpreter that can read the payload is the one the payload was BUILT for,
365
417
  * and that is `settings.app` on all three.
366
418
  *
419
+ * Since #1486 `settings.app` is itself resolved per target (`resolveShipApp`), so
420
+ * "NOT the layout" keeps meaning exactly what it meant: the layout does not DERIVE
421
+ * the runtime, an author STATES it per OS. Deriving it here would still stage
422
+ * `exec node` in front of a GJS bundle for everyone who said nothing.
423
+ *
367
424
  * `Layout.runtimeGap` is where the honest half of that lives: it says, per OS,
368
425
  * why the staged launcher does not yet name what § 4 derives, and `gjsify ship`
369
426
  * prints it.
@@ -38,6 +38,17 @@ export interface LayoutMetadataInput extends LayoutIdentity {
38
38
  version: string;
39
39
  /** Packaging revision within one upstream version — the second half of `CFBundleVersion`. */
40
40
  release: string;
41
+ /**
42
+ * `process.arch` the payload is LABELLED for — what `--arch` names.
43
+ *
44
+ * Here because a layout can own a PROGRAM and not only a manifest, and a
45
+ * program has an architecture: the windows row emits an x86-64 GUI launcher,
46
+ * and staging it into a tree labelled `arm64` would make
47
+ * `assertPayloadMatchesArch` refuse a stage over a file the author never
48
+ * named. `Layout.arches` already says the windows layout has no other
49
+ * architecture; this is the same fact where the file is written.
50
+ */
51
+ arch: string;
41
52
  }
42
53
  /** The four places a payload file can belong, as stage-relative directories. */
43
54
  export interface LayoutDirs {
@@ -84,7 +95,7 @@ export interface Layout {
84
95
  * M1 may honestly claim. A layout with no packer produces no installable
85
96
  * artifact, so writing `exec node` into a stage assembled from a `--app gjs`
86
97
  * bundle would put a runtime that cannot read the payload in front of it —
87
- * the exact defect `assertShippableTarget` exists to prevent, produced by the
98
+ * the exact defect `resolveShipApp`'s refusal exists to prevent, produced by the
88
99
  * code meant to prevent it. Measured before this was data: `gjsify ship
89
100
  * darwin --stage` on a project with no `gjsify.app` key exited 0 and staged
90
101
  * `exec node "$contents/Resources/lib/gjs.js"` in front of a bundle whose
@@ -100,6 +111,28 @@ export interface Layout {
100
111
  * know, and it is the same fact that retires this field when M0 lands.
101
112
  */
102
113
  runtimeGap?: string;
114
+ /**
115
+ * Why a face in the payload's `share/fonts` may not be REACHED on this OS, or
116
+ * absent where nothing stands in its way.
117
+ *
118
+ * Optional, mirroring {@link runtimeGap} rather than {@link metadata}'s
119
+ * required-and-empty shape, because it asks the same kind of question: is there
120
+ * something true about this OS that the staged tree cannot show? Linux answers
121
+ * by having none.
122
+ *
123
+ * THE GAP IS NOT THE PATH (ADR 0038). The faces are STAGED identically on all
124
+ * three layouts and `planStage` emits one plan for them; what differs is what
125
+ * READS the directory. On Linux fontconfig does, expanding
126
+ * `<dir prefix="xdg">fonts</dir>` over the `XDG_DATA_DIRS` every launcher
127
+ * already exports — measured in eight independent fontconfigs — so that row has
128
+ * nothing to report. On the other two the reader is a property of
129
+ * `@gjsify/gtk-runtime-<target>` rather than of this payload, and it is not
130
+ * fontconfig in either: CoreText on macOS, DirectWrite on Windows, the latter
131
+ * measured on hardware (`LAYOUTS.windows.fontGap` carries the numbers).
132
+ * `gjsify ship` prints this only when the payload actually carries a face, so a
133
+ * project shipping none is told nothing.
134
+ */
135
+ fontGap?: string;
103
136
  /** Suffix the launcher's filename carries, `''` where the OS needs none. */
104
137
  launcherExt: string;
105
138
  /**
@@ -136,8 +169,17 @@ export interface Layout {
136
169
  * The result is placed by `placeStage`, which applies the SAME uniqueness check
137
170
  * it applies to mapped files: an `extraFiles` destination that lands on
138
171
  * `Contents/Info.plist` is refused rather than silently replacing it.
172
+ *
173
+ * IT TAKES THE PAYLOAD, in the prefix-relative shape, because a manifest is a
174
+ * statement ABOUT a tree and one key already has to be (ADR 0038):
175
+ * `ATSApplicationFontsPath` names the directory the bundle's faces are in, and
176
+ * emitting it over a bundle that carries none would point macOS at a path that
177
+ * is not there. That is also the seam `CFBundleDocumentTypes` will need, which
178
+ * is ADR 0024 stages 4 and 5 — so the parameter is the payload rather than a
179
+ * `hasFonts` flag: the next key to need it will need a different question
180
+ * answered about the same tree.
139
181
  */
140
- metadata: (input: LayoutMetadataInput) => StagedFile[];
182
+ metadata: (input: LayoutMetadataInput, payload: readonly StagedFile[]) => StagedFile[];
141
183
  /**
142
184
  * The `process.arch` values this layout can be assembled for, or `null` when
143
185
  * it imposes no limit of its own.
@@ -182,6 +224,26 @@ export declare const LAYOUTS: Record<LayoutName, Layout>;
182
224
  export declare const LAYOUT_NAMES: LayoutName[];
183
225
  /** Stage-relative path of the launcher this layout puts in front of the app. */
184
226
  export declare function launcherPath(layout: Layout, identity: LayoutIdentity): string;
227
+ /**
228
+ * Stage-relative path of the windows layout's GUI launcher.
229
+ *
230
+ * IT MUST BE `<launcherPath minus `.cmd`>.exe` AND THE STUB DEPENDS ON IT:
231
+ * `buildGuiLauncher` finds its `.cmd` by overwriting the last three characters of
232
+ * its own module filename, which is a three-character store instead of a path
233
+ * search only because the two names differ in exactly those bytes. Deriving both
234
+ * from `LAYOUTS.windows.launcherExt` here is what keeps that true — renaming the
235
+ * batch launcher to `.bat` would otherwise leave a stub looking for a file that
236
+ * is not there, at exit 0, with a window that never appears.
237
+ */
238
+ export declare function windowsGuiLauncherPath(identity: LayoutIdentity): string;
239
+ /**
240
+ * What the windows GUI launcher calls the file it writes under `%TEMP%`.
241
+ *
242
+ * Named after the binary rather than the display name: `%TEMP%` is shared by
243
+ * every application on the machine, and `binaryName` is the one identifier this
244
+ * project already refuses to let collide.
245
+ */
246
+ export declare function windowsLaunchLogLeaf(identity: LayoutIdentity): string;
185
247
  /**
186
248
  * The layout for one `process.platform` token, refusing every alias.
187
249
  *
@@ -21,7 +21,9 @@
21
21
  // the data tree is addressed by `appId`. Nothing here is derived from gjsify's own
22
22
  // tree, and nothing here has a gjsify-specific default.
23
23
  import { posix } from 'node:path';
24
+ import { buildGuiLauncher } from './pe-launcher.js';
24
25
  import { BUNDLE_INFO_PLIST, BUNDLE_PKGINFO, renderInfoPlist, renderPkgInfo } from './plist.js';
26
+ import { SHARE } from './share-dirs.js';
25
27
  /**
26
28
  * Characters a `<App>.app` directory name may not contain.
27
29
  *
@@ -31,6 +33,27 @@ import { BUNDLE_INFO_PLIST, BUNDLE_PKGINFO, renderInfoPlist, renderPkgInfo } fro
31
33
  * `hdiutil` time, which is three milestones away and on another host.
32
34
  */
33
35
  const BUNDLE_NAME_FORBIDDEN = /[/:\\]/;
36
+ /**
37
+ * The value `ATSApplicationFontsPath` takes for this bundle, or nothing when the
38
+ * payload carries no face (ADR 0038).
39
+ *
40
+ * DERIVED THROUGH `place()`, over a file the payload actually holds, rather than
41
+ * written out as `share/fonts/<appId>`. The key is documented as a path relative
42
+ * to `Contents/Resources`, which is this layout's `dirs.other` — so running the
43
+ * real map on a real entry and taking the Resources prefix back off is the one
44
+ * spelling that cannot come apart from where the faces were staged. A literal
45
+ * would keep pointing at the old directory the day `dirs` changes, and macOS
46
+ * reports an empty font directory the way it reports everything else here: not at
47
+ * all.
48
+ */
49
+ function darwinFontsPath(identity, payload) {
50
+ const dir = `${SHARE.fonts}/${identity.appId}`;
51
+ const face = payload.find((file) => file.path.startsWith(`${dir}/`));
52
+ if (face === undefined)
53
+ return undefined;
54
+ const dirs = LAYOUTS.darwin.dirs(identity);
55
+ return posix.relative(dirs.other, posix.dirname(place(LAYOUTS.darwin, identity, face.path)));
56
+ }
34
57
  function appBundleDir(identity) {
35
58
  if (BUNDLE_NAME_FORBIDDEN.test(identity.name)) {
36
59
  throw new Error(`gjsify ship: the macOS layout would put this app in "${identity.name}.app", and a bundle ` +
@@ -79,6 +102,32 @@ export const LAYOUTS = {
79
102
  'macOS ships no Node, and there is no RELOCATABLE GJS (`build-gtk-runtime-darwin.mjs`: "GJS ' +
80
103
  'ships no relocation"), so ADR 0024 § 4 derives Node here. A self-contained bundle needs `@gjsify/node-runtime-darwin-<arch>` and a ' +
81
104
  '`--app node` payload (#1354 M0), or `@gjsify/gjs-runtime-darwin-<arch>` (ADR 0024 stage 7).',
105
+ // THE ONE LAYOUT WHERE THE ENV VARIABLE IS NOT THE MECHANISM, and assuming it
106
+ // was is the mistake this comment exists to stop being made again. macOS Pango
107
+ // is CORETEXT, not fontconfig: GTK's own `meson.build` carries
108
+ // `fontconfig_dep = [] # only used in x11 backend` and Homebrew's gtk4 formula
109
+ // builds `-Dx11-backend=false -Dmacos-backend=true`, while cairo's `quartz`
110
+ // option defaults to `auto` and auto-enables on darwin — so a `fonts.conf`
111
+ // inside a `.app` would be inert twice over, wrong Pango backend AND no
112
+ // GTK-side fontconfig. `XDG_DATA_DIRS` still carries the icons and the schemas
113
+ // here; it does not carry the faces.
114
+ //
115
+ // What does is `ATSApplicationFontsPath`, emitted into `Info.plist` by
116
+ // `metadata` above — Apple's own per-app activation, and the ORDERING is why it
117
+ // beats a runtime call rather than merely being tidier:
118
+ // `pango_core_text_font_map_changed()` only bumps a serial, there is no
119
+ // `kCTFontManagerRegisteredFontsChangedNotification` observer and no re-scan
120
+ // path in `pangocoretext-fontmap.c`, so a face registered after the font map
121
+ // initialises is not recoverable by poking it. The system activates this key's
122
+ // directory at LAUNCH, before any of the app's code runs.
123
+ fontGap: 'the faces are staged in `share/fonts/<appId>` and `Info.plist` carries `ATSApplicationFontsPath` ' +
124
+ "at it, which is macOS's own per-app activation — NOT the XDG_DATA_DIRS path, because Pango on " +
125
+ 'macOS is CoreText-backed and GTK is not built against fontconfig there. That the activation ' +
126
+ "reaches Pango's CoreText font map is UNVERIFIED: no leg in this repository runs a `.app`. " +
127
+ 'Confirm with `PangoCairo.FontMap.get_default().list_families()` in the shipped bundle, and ' +
128
+ '`PANGOCAIRO_BACKEND=bogus` to print which backend it was built with. An app that calls ' +
129
+ '`initFonts()` from `@gjsify/gtk-host/fonts` for the Windows row needs no branch around it: the ' +
130
+ 'CoreText map answers G_IO_ERROR_NOT_SUPPORTED and that call deliberately does nothing here.',
82
131
  launcherExt: '',
83
132
  root: appBundleDir,
84
133
  // Apple's, all four. `Contents/MacOS` holds executables, `Contents/Resources`
@@ -99,11 +148,11 @@ export const LAYOUTS = {
99
148
  // nothing tells LaunchServices which file under `Contents/MacOS` to exec,
100
149
  // and a `*.app` with no `Info.plist` is a folder with a suffix — which is
101
150
  // exactly what M1 staged. `plist.ts` carries the per-key citations.
102
- metadata: (input) => [
151
+ metadata: (input, payload) => [
103
152
  {
104
153
  path: `${appBundleDir(input)}/${BUNDLE_INFO_PLIST}`,
105
154
  mode: 0o644,
106
- source: { kind: 'text', text: renderInfoPlist(input) },
155
+ source: { kind: 'text', text: renderInfoPlist(input, darwinFontsPath(input, payload)) },
107
156
  },
108
157
  {
109
158
  path: `${appBundleDir(input)}/${BUNDLE_PKGINFO}`,
@@ -137,6 +186,57 @@ export const LAYOUTS = {
137
186
  'cannot assume — Windows ships neither, and there is NO GJS host on Windows at all ' +
138
187
  '(ADR 0024 § 4), so § 4 derives Node here. A self-contained directory needs ' +
139
188
  '`@gjsify/node-runtime-win32-x64` and a `--app node` payload (#1354 M3).',
189
+ // THE ONE LAYOUT WITH NO DECLARATIVE ANSWER AT ALL, and the three obvious
190
+ // candidates are each dead for a different reason — written down because each
191
+ // one looks right until it is read. The first is now dead by MEASUREMENT
192
+ // rather than by reading (ADR 0038 § W1-W3, Windows 11 / GTK 4.22.4).
193
+ //
194
+ // * FONTCONFIG. gvsbuild does build it and pango links it
195
+ // (`-Dfontconfig=enabled`), but `pangocairo-fontmap.c` picks the first
196
+ // backend COMPILED IN, in the order coretext → win32 → fc, and cairo's
197
+ // meson adds `cairo-win32` unconditionally on a Windows host. So the fc
198
+ // font map is built and never selected, and the whole `etc/fonts` +
199
+ // `FONTCONFIG_PATH` arrangement `@gjsify/gtk-runtime-win32-x64` ships is
200
+ // inert. `APPSHAREFONTDIR` even resolves to exactly this layout
201
+ // (`<exe dir>\..\share\fonts`) and feeds the map nothing reads. RUN: with
202
+ // a `FONTCONFIG_FILE` naming the face's directory beside WINDOWSFONTDIR
203
+ // the default map stays at 82 families without it — and stays at 82 when
204
+ // that directory is the ONLY one configured, which is the row that shows
205
+ // the map is not reading a misconfigured fontconfig but no fontconfig.
206
+ // The control that makes that a finding: a `PangoFT2.FontMap` from the
207
+ // SAME config in the SAME process goes 76 → 77 WITH the face, and to 5
208
+ // families exclusively. The bundle also ships no `fc-cache.exe`.
209
+ // * XDG_DATA_DIRS, which carries the faces on Linux. `FcConfigXdgDataDirs()`
210
+ // splits on a HARDCODED colon (its own comment says the spec asks for one),
211
+ // while `FC_SEARCH_PATH_SEPARATOR` is `;` here — so `C:\App\share` splits
212
+ // into `C` and `\App\share`. The Linux measurement does not carry over.
213
+ // * `AddFontResourceEx(..., FR_PRIVATE)`. It registers with GDI, and
214
+ // `pango_win32_font_map_init()` has exactly one population call —
215
+ // `pango_win32_dwrite_font_map_populate()` — with no `EnumFontFamilies` and
216
+ // no `AddFontResource` anywhere in the backend. A GDI-private font cannot
217
+ // reach GTK4 text.
218
+ //
219
+ // What is left is a runtime call, which a packaging command has no business
220
+ // making inside somebody's application — so the launcher hands over the
221
+ // directory (`GJSIFY_FONT_DIR`) and this string names the call. That the call
222
+ // WORKS is measured too, and on GTK's own map rather than a loose Pango
223
+ // object: `label.get_pango_context().get_font_map()` IS the default map
224
+ // there, `add_font_file` on it answers true, families go 82 → 83 and the
225
+ // family then loads. With the discriminator that keeps "resolved" honest — an
226
+ // invented family renders as Tahoma at different metrics with a Pango
227
+ // warning, which is exactly what the probe family did BEFORE the call.
228
+ fontGap: 'the faces are staged in `share/fonts/<appId>` and the launcher exports GJSIFY_FONT_DIR at it, ' +
229
+ 'but WINDOWS HAS NO DECLARATIVE FONT ACTIVATION and nothing here reaches a bundled face on its ' +
230
+ 'own: GTK4 uses pangowin32, whose font map is populated from DirectWrite and never from ' +
231
+ 'fontconfig or from GDI. MEASURED on Windows 11 / GTK 4.22.4 rather than inferred (ADR 0038 ' +
232
+ 'W1-W5): a FONTCONFIG_FILE naming the staged directory moves the default font map by nothing ' +
233
+ 'even when it is the ONLY configuration present — that map does not read fontconfig, and exposes ' +
234
+ 'no config_changed or set_config with which to make it — while add_font_file on the same map a ' +
235
+ 'Gtk.Label renders through does register the family. So register them at startup, with one call ' +
236
+ "from the GTK host layer: `import { initFonts } from '@gjsify/gtk-host/fonts'`, then `initFonts()` " +
237
+ 'before the first window — it reads GJSIFY_FONT_DIR itself, registers every face it holds, and ' +
238
+ 'no-ops on the operating systems that already activated them. Without it the app renders in a ' +
239
+ 'substituted family with no error.',
140
240
  // `.cmd`, not `.bat`: the two differ in whether a failing built-in (`set`,
141
241
  // `path`, `append`) sets ERRORLEVEL, and `.cmd` is the one where it does.
142
242
  launcherExt: '.cmd',
@@ -146,15 +246,41 @@ export const LAYOUTS = {
146
246
  // has no rpath — a DLL is found on `PATH`, in the directory of the image
147
247
  // that loaded it, or not at all.
148
248
  dirs: () => ({ launcher: '', bundle: 'app', native: 'lib', data: 'share', other: '' }),
149
- // Nothing, and this is the row that shows the field is not a macOS detail
150
- // wearing a general name: a Windows installer's metadata lives in the
151
- // `.msi`'s own tables, not in a file inside the program directory. THREE
152
- // format rows wrap this layout now, and the installer — the one that could
153
- // have needed a file here — did not: `msi.ts` puts every such fact in the
154
- // `Product`/`Package` attributes of the document it renders. So the answer
155
- // held rather than the question going unasked, and if a fourth row ever
156
- // needs a file, THIS is where it goes — not a branch in the stager.
157
- metadata: () => [],
249
+ // NO METADATA, and one PROGRAM. A Windows installer's metadata lives in the
250
+ // `.msi`'s own tables, not in a file inside the program directory —
251
+ // `msi.ts` puts every such fact in the `Product`/`Package` attributes of the
252
+ // document it renders — so this row still owns no manifest, and the field is
253
+ // not a macOS detail wearing a general name.
254
+ //
255
+ // What it DOES own is the GUI-subsystem launcher beside the `.cmd`, and it
256
+ // arrives through this seam rather than through a branch in the stager,
257
+ // which is exactly what the field's contract says a fourth row should do.
258
+ //
259
+ // A SECOND LAUNCHER, NOT A REPLACEMENT. The `.cmd` makes every environment
260
+ // decision, is what `assertLauncherMatchesInterpreter` and
261
+ // `.github/ship-oracle/verify-program-dir.py` read back, and is the right
262
+ // entry point in a terminal. The `.exe` is the entry point for a
263
+ // double-click and for the shortcut an `.msi` writes — the two launches
264
+ // Windows gives a console window today, because `cmd.exe` is a
265
+ // console-subsystem image (ADR 0024 § M3). It RUNS the `.cmd`.
266
+ //
267
+ // AND ONLY FOR THE ARCHITECTURE IT IS. The stub is x86-64 machine code, so
268
+ // staging it into a tree labelled `arm64` would have
269
+ // `assertPayloadMatchesArch` refuse the STAGE over a file the author never
270
+ // named — turning "this layout has no arm64 runtime" (a warning, with
271
+ // `Layout.arches.why` to act on) into "your payload is mislabelled" (a
272
+ // refusal, about our own file). `arches.only` already says there is no
273
+ // other Windows architecture; a stage for one stays assemblable and stays
274
+ // unpackable, which is the split `Layout.runtimeGap` draws everywhere else.
275
+ metadata: (input) => input.arch === 'x64'
276
+ ? [
277
+ {
278
+ path: windowsGuiLauncherPath(input),
279
+ mode: 0o755,
280
+ source: { kind: 'bytes', data: buildGuiLauncher({ logLeaf: windowsLaunchLogLeaf(input) }) },
281
+ },
282
+ ]
283
+ : [],
158
284
  // ONE, and the blocker is a project we do not own. `wingtk/gvsbuild`
159
285
  // hardcodes `self.platform = "x64"` in `utils/base_project.py` and its last
160
286
  // five releases publish exactly two assets each, both x64 — so there is no
@@ -185,6 +311,36 @@ const BY_OS = new Map(LAYOUT_NAMES.map((name) => [LAYOUTS[name].os, LAYOUTS[name
185
311
  export function launcherPath(layout, identity) {
186
312
  return posix.join(layout.dirs(identity).launcher, `${identity.binaryName}${layout.launcherExt}`);
187
313
  }
314
+ /**
315
+ * Stage-relative path of the windows layout's GUI launcher.
316
+ *
317
+ * IT MUST BE `<launcherPath minus `.cmd`>.exe` AND THE STUB DEPENDS ON IT:
318
+ * `buildGuiLauncher` finds its `.cmd` by overwriting the last three characters of
319
+ * its own module filename, which is a three-character store instead of a path
320
+ * search only because the two names differ in exactly those bytes. Deriving both
321
+ * from `LAYOUTS.windows.launcherExt` here is what keeps that true — renaming the
322
+ * batch launcher to `.bat` would otherwise leave a stub looking for a file that
323
+ * is not there, at exit 0, with a window that never appears.
324
+ */
325
+ export function windowsGuiLauncherPath(identity) {
326
+ const layout = LAYOUTS.windows;
327
+ if (layout.launcherExt.length !== 4) {
328
+ throw new Error(`gjsify ship: the windows launcher extension is "${layout.launcherExt}" and the GUI stub can only ` +
329
+ 'reach a four-character one — it rewrites the last three characters of its own filename rather ' +
330
+ 'than searching for a path (`utils/ship/pe-launcher.ts`).');
331
+ }
332
+ return posix.join(layout.dirs(identity).launcher, `${identity.binaryName}.exe`);
333
+ }
334
+ /**
335
+ * What the windows GUI launcher calls the file it writes under `%TEMP%`.
336
+ *
337
+ * Named after the binary rather than the display name: `%TEMP%` is shared by
338
+ * every application on the machine, and `binaryName` is the one identifier this
339
+ * project already refuses to let collide.
340
+ */
341
+ export function windowsLaunchLogLeaf(identity) {
342
+ return `${identity.binaryName}.launch.log`;
343
+ }
188
344
  /**
189
345
  * The layout for one `process.platform` token, refusing every alias.
190
346
  *
@@ -339,8 +495,9 @@ export function placeStage(layout, identity, files, carried = []) {
339
495
  // prefix-relative plan, so there is nothing to map. `<layout> metadata` rather
340
496
  // than a path as the origin label, because a collision message naming
341
497
  // `Contents/Info.plist` as both sides would say nothing.
342
- for (const file of layout.metadata(identity))
498
+ for (const file of layout.metadata(identity, files)) {
343
499
  claim(file.path, `the ${layout.name} layout's own metadata`, file);
500
+ }
344
501
  // A THIRD source, and stage-relative like the second: the runtime the bundle
345
502
  // CARRIES (`utils/ship/app-runtime.ts`). It goes through `claim` for the reason
346
503
  // the metadata does — an `extraFiles` destination landing on a staged dylib
@@ -21,7 +21,8 @@
21
21
  // * lays the program directory `windows-dir` produces under
22
22
  // `%ProgramFiles%\<App>`, overridable with `msiexec INSTALLDIR=…`;
23
23
  // * gives the user something to start — one Start-Menu shortcut at the top
24
- // level, aimed at the same `.cmd` launcher the zip's user double-clicks;
24
+ // level, aimed at the GUI-subsystem `.exe` launcher the zip's user
25
+ // double-clicks (see the last paragraph of this header);
25
26
  // * comes off again: Add/Remove Programs gets its entry from the MSI's own
26
27
  // `ProductName`/`ProductVersion`/`Manufacturer`, and `msiexec /x` removes
27
28
  // every file the package installed. The `windows-dir-selfcontained` leg's
@@ -57,15 +58,20 @@
57
58
  // bought for nothing. A directory whose id is a public property is settable
58
59
  // from a command line either way.
59
60
  //
60
- // AND ONE THING IT DOES NOT FIX. `node.exe` is a CONSOLE-subsystem image and the
61
- // Node release ships no `nodew.exe` (`status/open-todos.md` carries the offsets),
62
- // so the shortcut this installer writes pops a console window behind the GUI
63
- // exactly as the `.cmd` does. Nothing here changes that, and no leg can observe
64
- // it — every Windows CI leg starts the app from a shell and already has one.
61
+ // AND ONE THING IT USED NOT TO FIX. `node.exe` is a CONSOLE-subsystem image and
62
+ // the Node release ships no `nodew.exe`, so a Start-Menu shortcut to the `.cmd`
63
+ // popped a console window behind the GUI exactly as a double-click did. The
64
+ // shortcut now points at the GUI-subsystem launcher the windows LAYOUT stages
65
+ // (`utils/ship/pe-launcher.ts`), which runs the same `.cmd` with no window —
66
+ // measured on `win11-gjsify` in session 1, where the `.cmd` produces two new
67
+ // visible console-host windows and the `.exe` produces none. Still no CI leg can
68
+ // observe it: every Windows job starts the app from a shell and already has a
69
+ // console.
65
70
  import { createHash } from 'node:crypto';
66
71
  import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
67
72
  import { join, posix } from 'node:path';
68
73
  import { describeExit, spawnToCompletion } from '../spawn.js';
74
+ import { windowsGuiLauncherPath } from './layout.js';
69
75
  import { xmlEscape } from './xml.js';
70
76
  /**
71
77
  * The UUID namespace every GUID in a generated `.msi` is derived under.
@@ -236,7 +242,18 @@ export function renderWxs(input) {
236
242
  // `Name <email>` is `PackSettings.maintainer`'s shape; ARP shows `Publisher`
237
243
  // to a human, and an address in that field reads as a mistake.
238
244
  const manufacturer = settings.maintainer.replace(/\s*<[^>]*>\s*$/, '').trim() || settings.maintainer;
239
- const launcher = `${settings.binaryName}.cmd`;
245
+ // THE SHORTCUT POINTS AT THE `.exe`, NOT THE `.cmd` — this installer's half of
246
+ // the console-window fix. A Start-Menu shortcut to a batch file starts
247
+ // `cmd.exe`, a console-subsystem image, so Windows allocates a console for it
248
+ // and a black window sits behind the app (ADR 0024 § M3). The GUI-subsystem
249
+ // launcher beside it — `utils/ship/pe-launcher.ts`, staged by the windows
250
+ // LAYOUT — runs the same `.cmd` and has no window of its own.
251
+ //
252
+ // Derived from `windowsGuiLauncherPath` and never spelled again here: the stub
253
+ // finds its `.cmd` by rewriting the last three characters of its own filename,
254
+ // so the two names are ONE decision, and a second literal is where they would
255
+ // come apart.
256
+ const launcher = windowsGuiLauncherPath(settings);
240
257
  const identifiers = new Map();
241
258
  const claim = (id, what) => {
242
259
  const previous = identifiers.get(id);
@@ -440,7 +440,15 @@ export function assertLauncherMatchesInterpreter(payload, layout, identity, inte
440
440
  ' An installed package that depends on one interpreter and runs another installs cleanly and ' +
441
441
  'fails\n' +
442
442
  " at first launch, on the user's machine.\n" +
443
- ` Either set \`gjsify.app\` to "${other}", or fix the launcher — if it comes from ` +
443
+ // THIS TARGET's key, and not `gjsify.app`. Since #1486 the runtime is
444
+ // resolved per target, so sending the reader to the project field to
445
+ // fix ONE layout's launcher moves every other layout's `Depends:` with
446
+ // it — the defect § A22 exists to close, reintroduced by the message
447
+ // that reports it. Named as a project edit because `--from-stage`
448
+ // reaches here too, and that host has no config to change.
449
+ ` Either set \`gjsify.ship.app.${layout.os}\` to "${other}" in the project this was staged ` +
450
+ 'from — it overrides\n' +
451
+ ' `gjsify.app` for that target alone — or fix the launcher: if it comes from ' +
444
452
  '`gjsify.ship.extraFiles`,\n' +
445
453
  ' that override is what decides which interpreter runs and it must match the declaration.');
446
454
  }
@@ -488,19 +496,30 @@ const SHARE_VERDICTS = [
488
496
  /**
489
497
  * Paths that survive the trip, so the rule below can be an INVERSE one.
490
498
  *
491
- * Two entries and they are different SHAPES, which is why the check below tests
499
+ * Three entries and they are different SHAPES, which is why the check below tests
492
500
  * both containment and equality:
493
501
  *
494
502
  * - `share/locale` is a DIRECTORY. A `.mo` is read straight off disk by
495
503
  * `bindtextdomain`, with no install step anywhere, and the launcher hands the
496
504
  * directory over on all three layouts.
505
+ * - `share/fonts` is a directory too, and it is here for a NARROWER reason than
506
+ * the one next to it (ADR 0038). What this list means is "no package install
507
+ * step makes this correct", and that is true of a face on every layout: nothing
508
+ * compiles it, nothing reindexes it, and on Linux fontconfig scans the
509
+ * directory and caches lazily per user. What it deliberately does NOT claim is
510
+ * that a face is FOUND on the other two — macOS reaches it through
511
+ * `Info.plist`'s `ATSApplicationFontsPath` and Windows only through a call the
512
+ * app makes itself. Those are `Layout.fontGap`, printed separately, because
513
+ * they are facts about an OS rather than about a missing scriptlet, and folding
514
+ * them in here would report the answer under a heading that names the wrong
515
+ * question.
497
516
  * - `gschemas.compiled` is one FILE, and it is here because it is not a cost —
498
517
  * it is the thing that removes one. Reported under a warning headed "whose
499
518
  * Linux correctness comes from a package install step" it would name the fix
500
519
  * as if it were the problem, and it would be the sixth line of a five-line
501
520
  * list nobody asked to grow.
502
521
  */
503
- const SHARE_PORTABLE = [SHARE.locale, SCHEMA_CACHE];
522
+ const SHARE_PORTABLE = [SHARE.locale, SHARE.fonts, SCHEMA_CACHE];
504
523
  /**
505
524
  * Every `share/` entry a non-Linux layout carries, classified — including the
506
525
  * ones nothing here has a rule for.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `IMAGE_SUBSYSTEM_WINDOWS_GUI` — the one field this whole file exists to set.
3
+ *
4
+ * The counterpart is 3, `_CUI`, which is what `node.exe` and `cmd.exe` are and
5
+ * what makes Windows allocate a console when there is none to inherit.
6
+ */
7
+ export declare const PE_SUBSYSTEM_GUI = 2;
8
+ /** Where `Subsystem` lands in the emitted image: `e_lfanew` + 4 + 20 + 68. */
9
+ export declare const PE_SUBSYSTEM_OFFSET: number;
10
+ export interface GuiLauncherInput {
11
+ /**
12
+ * The file the stub writes the child's output to under `%TEMP%`, when there
13
+ * is no console to inherit.
14
+ *
15
+ * A LEAF and not a path: `%TEMP%` is the one directory a program can write to
16
+ * on every Windows install, and the program directory is not — `C:\Program
17
+ * Files\<App>` is read-only for the user who runs the app.
18
+ */
19
+ logLeaf: string;
20
+ }
21
+ /**
22
+ * The GUI-subsystem launcher, as a complete PE32+ image.
23
+ *
24
+ * Deterministic: the same input always produces the same bytes, with no
25
+ * timestamp, no checksum and no build-host string anywhere in the file — which
26
+ * is what lets `tests/e2e/ship-windows` compare two runs and what keeps the
27
+ * artifact reproducible for anyone who repacks a stage (ADR 0024 § A2).
28
+ */
29
+ export declare function buildGuiLauncher(input: GuiLauncherInput): Uint8Array;