@gjsify/cli 0.45.0 → 0.46.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.
@@ -8,6 +8,30 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
8
8
  import { dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
9
9
  import { LICENSE_FILE_NAMES } from './settings.js';
10
10
  const ICON_EXTENSIONS = ['.svg', '.png'];
11
+ /**
12
+ * The face formats every FreeType opens, with no build option deciding it.
13
+ *
14
+ * TrueType, OpenType and their collections. Deliberately short: the point of the
15
+ * set is that a file in it resolves inside the app's OWN runtime closure as well
16
+ * as it does on the machine that packaged it.
17
+ */
18
+ const FONT_EXTENSIONS = ['.ttf', '.otf', '.ttc', '.otc'];
19
+ /**
20
+ * Web-font wrappers, refused BY NAME rather than filtered away.
21
+ *
22
+ * They are the mistake worth a diagnostic. An `OFL.txt` beside the faces is
23
+ * ignored the way a stray file in an icon directory is; a `.woff2` is a font
24
+ * somebody put there on purpose, and dropping it silently costs the same as
25
+ * staging it — the app ships without the family and Pango substitutes, at exit 0.
26
+ *
27
+ * Measured in BOTH directions, which is why this is a refusal rather than a
28
+ * warning: `fc-query` reads a `.woff2`'s family fine on Fedora 44 (fontconfig
29
+ * 2.17.0, FreeType built with brotli), and that support is
30
+ * `FT_CONFIG_OPTION_USE_BROTLI` — a property of whichever FreeType the shipped
31
+ * artifact loads, which for a `.app` or a Windows program directory is the one
32
+ * inside `@gjsify/gtk-runtime-<target>` and not this host's.
33
+ */
34
+ const WEB_FONT_EXTENSIONS = ['.woff', '.woff2', '.eot'];
11
35
  export function discoverPayload(input) {
12
36
  const { projectDir, ship } = input;
13
37
  const bundlePath = resolveBundle(input);
@@ -29,6 +53,7 @@ export function discoverPayload(input) {
29
53
  schemaFiles: discoverSchemas(projectDir, ship.schemas),
30
54
  typelibFiles: discoverTypelibs(projectDir, ship.bundledTypelibs),
31
55
  localeFiles,
56
+ fontFiles: discoverFonts(projectDir, ship.fonts),
32
57
  licenseFile: discoverLicense(projectDir, ship.licenseFile),
33
58
  };
34
59
  }
@@ -186,6 +211,66 @@ function listIcons(dir) {
186
211
  .map((rel) => join(dir, rel.split('/').join(sep)))
187
212
  .sort();
188
213
  }
214
+ /**
215
+ * Font faces the application SHIPS (`gjsify.ship.fonts`), or the GNOME-shaped
216
+ * default `data/fonts` when it exists.
217
+ *
218
+ * A FILE or a DIRECTORY, like `icon`, and a directory is walked rather than
219
+ * globbed because a family arrives as several faces and often with its licence
220
+ * beside them.
221
+ *
222
+ * The refusals are the interesting half, and both cover the same failure: a font
223
+ * that installs and is never found. Pango does not report a missing family — it
224
+ * substitutes, so the app renders in the wrong typeface with no error, no exit
225
+ * code and nothing for CI to see. That is why a configured path holding no usable
226
+ * face fails here rather than staging an empty directory, and why a web-font
227
+ * wrapper is named rather than filtered (see {@link WEB_FONT_EXTENSIONS}).
228
+ */
229
+ function discoverFonts(projectDir, configured) {
230
+ const declared = configured ?? 'data/fonts';
231
+ const root = configured === undefined ? join(projectDir, 'data', 'fonts') : resolve(projectDir, configured);
232
+ if (!existsSync(root)) {
233
+ if (configured === undefined)
234
+ return [];
235
+ throw new Error(`gjsify ship: the configured font path ${configured} does not exist.`);
236
+ }
237
+ if (!statSync(root).isDirectory()) {
238
+ assertNotWebFont(root, declared);
239
+ if (!FONT_EXTENSIONS.some((ext) => root.toLowerCase().endsWith(ext))) {
240
+ throw new Error(`gjsify ship: \`gjsify.ship.fonts\` names ${configured}, which is not a font face ` +
241
+ `fontconfig will open (${FONT_EXTENSIONS.join(', ')}). ` +
242
+ 'A file it cannot read installs and is never found, and Pango substitutes a fallback ' +
243
+ 'family rather than reporting anything.');
244
+ }
245
+ return [root];
246
+ }
247
+ const out = [];
248
+ for (const rel of listFilesRecursive(root)) {
249
+ assertNotWebFont(rel, declared);
250
+ if (FONT_EXTENSIONS.some((ext) => rel.toLowerCase().endsWith(ext))) {
251
+ out.push(join(root, rel.split('/').join(sep)));
252
+ }
253
+ }
254
+ if (out.length === 0 && configured !== undefined) {
255
+ throw new Error(`gjsify ship: \`gjsify.ship.fonts\` (${configured}) holds no font face ` +
256
+ `(${FONT_EXTENSIONS.join(', ')}). Declaring fonts that ship nothing is a promise the ` +
257
+ 'package does not keep — and its symptom is a substituted family, not an error.');
258
+ }
259
+ return out.sort();
260
+ }
261
+ /** A `.woff`/`.woff2`/`.eot` is named, never dropped — {@link WEB_FONT_EXTENSIONS} carries the reason. */
262
+ function assertNotWebFont(path, where) {
263
+ const lower = path.toLowerCase();
264
+ const wrapper = WEB_FONT_EXTENSIONS.find((ext) => lower.endsWith(ext));
265
+ if (wrapper === undefined)
266
+ return;
267
+ throw new Error(`gjsify ship: \`gjsify.ship.fonts\` (${where}) carries ${path}, and \`${wrapper}\` is a web-font ` +
268
+ 'wrapper whose support is a FreeType BUILD option — brotli for woff2, zlib for woff. Whether it ' +
269
+ 'opens is therefore a property of the FreeType the SHIPPED artifact loads (inside ' +
270
+ '`@gjsify/gtk-runtime-<target>` for a `.app` or a Windows program directory), not of the machine ' +
271
+ `that packaged it. Ship the desktop face instead (${FONT_EXTENSIONS.join(', ')}); a font that ` +
272
+ 'fails to open is not an error, it is Pango quietly substituting a different typeface.');
273
+ }
189
274
  function discoverSchemas(projectDir, configured) {
190
275
  const roots = configured !== undefined ? [resolve(projectDir, configured)] : [join(projectDir, 'data')];
191
276
  for (const root of roots) {
@@ -116,3 +116,26 @@ export declare function configuredFormats(raw: readonly string[], layout: Layout
116
116
  formats: FormatDescriptor[];
117
117
  dropped: FormatId[];
118
118
  };
119
+ /**
120
+ * Can this format's container hold a notarisation ticket?
121
+ *
122
+ * A SWITCH WITH A `never` GUARD and not a field, deliberately — the difference
123
+ * from `FormatDescriptor.host` is who owns the fact. Where a format can be PACKED
124
+ * is a property of this project's toolchain and belongs on the descriptor; whether
125
+ * `stapler` will write a ticket into it is Apple's list, unchanged by anything
126
+ * here, and six of the nine rows are only "no" because notarisation is a macOS
127
+ * concept they have nothing to do with. Putting it in a `switch` keeps the answer
128
+ * beside its citation and still makes a tenth format a compile error.
129
+ *
130
+ * `stapler(1)`: *"stapler works only with UDIF disk images, signed \"flat\"
131
+ * installer packages, and certain code-signed executable bundles such as
132
+ * \".app\"."* And Apple's notarisation guide for the exception that decides the
133
+ * zip: *"While you can notarize a ZIP archive, you can't staple to it directly.
134
+ * Instead, run stapler against each item that you added to the archive. Then
135
+ * create a new ZIP file containing the stapled items for distribution."*
136
+ *
137
+ * `macos-app` is `true` here even though `notarytool` cannot SUBMIT a directory:
138
+ * the two questions are separate, and a `.app` is precisely the item Apple's
139
+ * remedy above tells you to staple after submitting the archive around it.
140
+ */
141
+ export declare function canCarryTicket(id: FormatId): boolean;
@@ -947,3 +947,47 @@ export function configuredFormats(raw, layout) {
947
947
  dropped: named.filter((name) => FORMATS[name].layoutOs !== layout.os),
948
948
  };
949
949
  }
950
+ /**
951
+ * Can this format's container hold a notarisation ticket?
952
+ *
953
+ * A SWITCH WITH A `never` GUARD and not a field, deliberately — the difference
954
+ * from `FormatDescriptor.host` is who owns the fact. Where a format can be PACKED
955
+ * is a property of this project's toolchain and belongs on the descriptor; whether
956
+ * `stapler` will write a ticket into it is Apple's list, unchanged by anything
957
+ * here, and six of the nine rows are only "no" because notarisation is a macOS
958
+ * concept they have nothing to do with. Putting it in a `switch` keeps the answer
959
+ * beside its citation and still makes a tenth format a compile error.
960
+ *
961
+ * `stapler(1)`: *"stapler works only with UDIF disk images, signed \"flat\"
962
+ * installer packages, and certain code-signed executable bundles such as
963
+ * \".app\"."* And Apple's notarisation guide for the exception that decides the
964
+ * zip: *"While you can notarize a ZIP archive, you can't staple to it directly.
965
+ * Instead, run stapler against each item that you added to the archive. Then
966
+ * create a new ZIP file containing the stapled items for distribution."*
967
+ *
968
+ * `macos-app` is `true` here even though `notarytool` cannot SUBMIT a directory:
969
+ * the two questions are separate, and a `.app` is precisely the item Apple's
970
+ * remedy above tells you to staple after submitting the archive around it.
971
+ */
972
+ export function canCarryTicket(id) {
973
+ switch (id) {
974
+ case 'macos-app':
975
+ case 'macos-app-dmg':
976
+ return true;
977
+ case 'macos-app-zip':
978
+ // Nothing outside darwin has a ticket to carry: `stapler` is Apple's tool
979
+ // and no other OS pre-clears an artifact at all (§ A5 — Windows SmartScreen
980
+ // accrues reputation per file hash instead).
981
+ case 'deb':
982
+ case 'rpm':
983
+ case 'flatpak':
984
+ case 'windows-dir':
985
+ case 'windows-dir-zip':
986
+ case 'msi':
987
+ return false;
988
+ default: {
989
+ const unhandled = id;
990
+ throw new Error(`gjsify ship: no ticket answer for format "${String(unhandled)}".`);
991
+ }
992
+ }
993
+ }
@@ -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,30 @@ 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.',
82
129
  launcherExt: '',
83
130
  root: appBundleDir,
84
131
  // Apple's, all four. `Contents/MacOS` holds executables, `Contents/Resources`
@@ -99,11 +146,11 @@ export const LAYOUTS = {
99
146
  // nothing tells LaunchServices which file under `Contents/MacOS` to exec,
100
147
  // and a `*.app` with no `Info.plist` is a folder with a suffix — which is
101
148
  // exactly what M1 staged. `plist.ts` carries the per-key citations.
102
- metadata: (input) => [
149
+ metadata: (input, payload) => [
103
150
  {
104
151
  path: `${appBundleDir(input)}/${BUNDLE_INFO_PLIST}`,
105
152
  mode: 0o644,
106
- source: { kind: 'text', text: renderInfoPlist(input) },
153
+ source: { kind: 'text', text: renderInfoPlist(input, darwinFontsPath(input, payload)) },
107
154
  },
108
155
  {
109
156
  path: `${appBundleDir(input)}/${BUNDLE_PKGINFO}`,
@@ -137,6 +184,55 @@ export const LAYOUTS = {
137
184
  'cannot assume — Windows ships neither, and there is NO GJS host on Windows at all ' +
138
185
  '(ADR 0024 § 4), so § 4 derives Node here. A self-contained directory needs ' +
139
186
  '`@gjsify/node-runtime-win32-x64` and a `--app node` payload (#1354 M3).',
187
+ // THE ONE LAYOUT WITH NO DECLARATIVE ANSWER AT ALL, and the three obvious
188
+ // candidates are each dead for a different reason — written down because each
189
+ // one looks right until it is read. The first is now dead by MEASUREMENT
190
+ // rather than by reading (ADR 0038 § W1-W3, Windows 11 / GTK 4.22.4).
191
+ //
192
+ // * FONTCONFIG. gvsbuild does build it and pango links it
193
+ // (`-Dfontconfig=enabled`), but `pangocairo-fontmap.c` picks the first
194
+ // backend COMPILED IN, in the order coretext → win32 → fc, and cairo's
195
+ // meson adds `cairo-win32` unconditionally on a Windows host. So the fc
196
+ // font map is built and never selected, and the whole `etc/fonts` +
197
+ // `FONTCONFIG_PATH` arrangement `@gjsify/gtk-runtime-win32-x64` ships is
198
+ // inert. `APPSHAREFONTDIR` even resolves to exactly this layout
199
+ // (`<exe dir>\..\share\fonts`) and feeds the map nothing reads. RUN: with
200
+ // a `FONTCONFIG_FILE` naming the face's directory beside WINDOWSFONTDIR
201
+ // the default map stays at 82 families without it — and stays at 82 when
202
+ // that directory is the ONLY one configured, which is the row that shows
203
+ // the map is not reading a misconfigured fontconfig but no fontconfig.
204
+ // The control that makes that a finding: a `PangoFT2.FontMap` from the
205
+ // SAME config in the SAME process goes 76 → 77 WITH the face, and to 5
206
+ // families exclusively. The bundle also ships no `fc-cache.exe`.
207
+ // * XDG_DATA_DIRS, which carries the faces on Linux. `FcConfigXdgDataDirs()`
208
+ // splits on a HARDCODED colon (its own comment says the spec asks for one),
209
+ // while `FC_SEARCH_PATH_SEPARATOR` is `;` here — so `C:\App\share` splits
210
+ // into `C` and `\App\share`. The Linux measurement does not carry over.
211
+ // * `AddFontResourceEx(..., FR_PRIVATE)`. It registers with GDI, and
212
+ // `pango_win32_font_map_init()` has exactly one population call —
213
+ // `pango_win32_dwrite_font_map_populate()` — with no `EnumFontFamilies` and
214
+ // no `AddFontResource` anywhere in the backend. A GDI-private font cannot
215
+ // reach GTK4 text.
216
+ //
217
+ // What is left is a runtime call, which a packaging command has no business
218
+ // making inside somebody's application — so the launcher hands over the
219
+ // directory (`GJSIFY_FONT_DIR`) and this string names the call. That the call
220
+ // WORKS is measured too, and on GTK's own map rather than a loose Pango
221
+ // object: `label.get_pango_context().get_font_map()` IS the default map
222
+ // there, `add_font_file` on it answers true, families go 82 → 83 and the
223
+ // family then loads. With the discriminator that keeps "resolved" honest — an
224
+ // invented family renders as Tahoma at different metrics with a Pango
225
+ // warning, which is exactly what the probe family did BEFORE the call.
226
+ fontGap: 'the faces are staged in `share/fonts/<appId>` and the launcher exports GJSIFY_FONT_DIR at it, ' +
227
+ 'but WINDOWS HAS NO DECLARATIVE FONT ACTIVATION and nothing here reaches a bundled face on its ' +
228
+ 'own: GTK4 uses pangowin32, whose font map is populated from DirectWrite and never from ' +
229
+ 'fontconfig or from GDI. MEASURED on Windows 11 / GTK 4.22.4 rather than inferred (ADR 0038 ' +
230
+ 'W1-W5): a FONTCONFIG_FILE naming the staged directory moves the default font map by nothing ' +
231
+ 'even when it is the ONLY configuration present — that map does not read fontconfig, and exposes ' +
232
+ 'no config_changed or set_config with which to make it — while add_font_file on the same map a ' +
233
+ 'Gtk.Label renders through does register the family. So register them at startup: ' +
234
+ '`PangoCairo.FontMap.get_default().add_font_file(path)` (Pango 1.56+, in the typelib), or the ' +
235
+ 'app renders in a substituted family with no error.',
140
236
  // `.cmd`, not `.bat`: the two differ in whether a failing built-in (`set`,
141
237
  // `path`, `append`) sets ERRORLEVEL, and `.cmd` is the one where it does.
142
238
  launcherExt: '.cmd',
@@ -146,15 +242,41 @@ export const LAYOUTS = {
146
242
  // has no rpath — a DLL is found on `PATH`, in the directory of the image
147
243
  // that loaded it, or not at all.
148
244
  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: () => [],
245
+ // NO METADATA, and one PROGRAM. A Windows installer's metadata lives in the
246
+ // `.msi`'s own tables, not in a file inside the program directory —
247
+ // `msi.ts` puts every such fact in the `Product`/`Package` attributes of the
248
+ // document it renders — so this row still owns no manifest, and the field is
249
+ // not a macOS detail wearing a general name.
250
+ //
251
+ // What it DOES own is the GUI-subsystem launcher beside the `.cmd`, and it
252
+ // arrives through this seam rather than through a branch in the stager,
253
+ // which is exactly what the field's contract says a fourth row should do.
254
+ //
255
+ // A SECOND LAUNCHER, NOT A REPLACEMENT. The `.cmd` makes every environment
256
+ // decision, is what `assertLauncherMatchesInterpreter` and
257
+ // `.github/ship-oracle/verify-program-dir.py` read back, and is the right
258
+ // entry point in a terminal. The `.exe` is the entry point for a
259
+ // double-click and for the shortcut an `.msi` writes — the two launches
260
+ // Windows gives a console window today, because `cmd.exe` is a
261
+ // console-subsystem image (ADR 0024 § M3). It RUNS the `.cmd`.
262
+ //
263
+ // AND ONLY FOR THE ARCHITECTURE IT IS. The stub is x86-64 machine code, so
264
+ // staging it into a tree labelled `arm64` would have
265
+ // `assertPayloadMatchesArch` refuse the STAGE over a file the author never
266
+ // named — turning "this layout has no arm64 runtime" (a warning, with
267
+ // `Layout.arches.why` to act on) into "your payload is mislabelled" (a
268
+ // refusal, about our own file). `arches.only` already says there is no
269
+ // other Windows architecture; a stage for one stays assemblable and stays
270
+ // unpackable, which is the split `Layout.runtimeGap` draws everywhere else.
271
+ metadata: (input) => input.arch === 'x64'
272
+ ? [
273
+ {
274
+ path: windowsGuiLauncherPath(input),
275
+ mode: 0o755,
276
+ source: { kind: 'bytes', data: buildGuiLauncher({ logLeaf: windowsLaunchLogLeaf(input) }) },
277
+ },
278
+ ]
279
+ : [],
158
280
  // ONE, and the blocker is a project we do not own. `wingtk/gvsbuild`
159
281
  // hardcodes `self.platform = "x64"` in `utils/base_project.py` and its last
160
282
  // five releases publish exactly two assets each, both x64 — so there is no
@@ -185,6 +307,36 @@ const BY_OS = new Map(LAYOUT_NAMES.map((name) => [LAYOUTS[name].os, LAYOUTS[name
185
307
  export function launcherPath(layout, identity) {
186
308
  return posix.join(layout.dirs(identity).launcher, `${identity.binaryName}${layout.launcherExt}`);
187
309
  }
310
+ /**
311
+ * Stage-relative path of the windows layout's GUI launcher.
312
+ *
313
+ * IT MUST BE `<launcherPath minus `.cmd`>.exe` AND THE STUB DEPENDS ON IT:
314
+ * `buildGuiLauncher` finds its `.cmd` by overwriting the last three characters of
315
+ * its own module filename, which is a three-character store instead of a path
316
+ * search only because the two names differ in exactly those bytes. Deriving both
317
+ * from `LAYOUTS.windows.launcherExt` here is what keeps that true — renaming the
318
+ * batch launcher to `.bat` would otherwise leave a stub looking for a file that
319
+ * is not there, at exit 0, with a window that never appears.
320
+ */
321
+ export function windowsGuiLauncherPath(identity) {
322
+ const layout = LAYOUTS.windows;
323
+ if (layout.launcherExt.length !== 4) {
324
+ throw new Error(`gjsify ship: the windows launcher extension is "${layout.launcherExt}" and the GUI stub can only ` +
325
+ 'reach a four-character one — it rewrites the last three characters of its own filename rather ' +
326
+ 'than searching for a path (`utils/ship/pe-launcher.ts`).');
327
+ }
328
+ return posix.join(layout.dirs(identity).launcher, `${identity.binaryName}.exe`);
329
+ }
330
+ /**
331
+ * What the windows GUI launcher calls the file it writes under `%TEMP%`.
332
+ *
333
+ * Named after the binary rather than the display name: `%TEMP%` is shared by
334
+ * every application on the machine, and `binaryName` is the one identifier this
335
+ * project already refuses to let collide.
336
+ */
337
+ export function windowsLaunchLogLeaf(identity) {
338
+ return `${identity.binaryName}.launch.log`;
339
+ }
188
340
  /**
189
341
  * The layout for one `process.platform` token, refusing every alias.
190
342
  *
@@ -339,8 +491,9 @@ export function placeStage(layout, identity, files, carried = []) {
339
491
  // prefix-relative plan, so there is nothing to map. `<layout> metadata` rather
340
492
  // than a path as the origin label, because a collision message naming
341
493
  // `Contents/Info.plist` as both sides would say nothing.
342
- for (const file of layout.metadata(identity))
494
+ for (const file of layout.metadata(identity, files)) {
343
495
  claim(file.path, `the ${layout.name} layout's own metadata`, file);
496
+ }
344
497
  // A THIRD source, and stage-relative like the second: the runtime the bundle
345
498
  // CARRIES (`utils/ship/app-runtime.ts`). It goes through `claim` for the reason
346
499
  // the metadata does — an `extraFiles` destination landing on a staged dylib