@gjsify/cli 0.44.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.
Files changed (81) hide show
  1. package/dist/cli.gjs.mjs +202 -167
  2. package/lib/commands/gettext.d.ts +2 -1
  3. package/lib/commands/gettext.js +105 -47
  4. package/lib/commands/onboard.d.ts +7 -0
  5. package/lib/commands/onboard.js +349 -96
  6. package/lib/commands/ship.d.ts +3 -0
  7. package/lib/commands/ship.js +850 -58
  8. package/lib/commands/trust.d.ts +6 -0
  9. package/lib/commands/trust.js +1 -1
  10. package/lib/commands/upgrade.d.ts +23 -0
  11. package/lib/commands/upgrade.js +95 -12
  12. package/lib/templates/install.mjs.tmpl +2 -2
  13. package/lib/types/config-data.d.ts +127 -1
  14. package/lib/utils/gi-search-path.js +11 -3
  15. package/lib/utils/msgfmt-merge.d.ts +35 -0
  16. package/lib/utils/msgfmt-merge.js +102 -0
  17. package/lib/utils/npm-otp.d.ts +27 -2
  18. package/lib/utils/npm-otp.js +82 -7
  19. package/lib/utils/onboard-discovery.d.ts +68 -0
  20. package/lib/utils/onboard-discovery.js +178 -0
  21. package/lib/utils/onboard-probe.d.ts +118 -1
  22. package/lib/utils/onboard-probe.js +153 -4
  23. package/lib/utils/prompt-output.d.ts +12 -0
  24. package/lib/utils/prompt-output.js +54 -0
  25. package/lib/utils/prompt.js +22 -2
  26. package/lib/utils/publishable-packages.d.ts +1 -1
  27. package/lib/utils/publishable-packages.js +1 -0
  28. package/lib/utils/resolve-npm-package.d.ts +56 -0
  29. package/lib/utils/resolve-npm-package.js +69 -0
  30. package/lib/utils/ship/app-runtime.d.ts +214 -0
  31. package/lib/utils/ship/app-runtime.js +429 -0
  32. package/lib/utils/ship/depends.d.ts +50 -0
  33. package/lib/utils/ship/depends.js +117 -3
  34. package/lib/utils/ship/discover.js +103 -1
  35. package/lib/utils/ship/dmg.d.ts +71 -0
  36. package/lib/utils/ship/dmg.js +181 -0
  37. package/lib/utils/ship/flatpak.js +1 -1
  38. package/lib/utils/ship/formats.d.ts +108 -7
  39. package/lib/utils/ship/formats.js +778 -15
  40. package/lib/utils/ship/launcher.d.ts +13 -3
  41. package/lib/utils/ship/launcher.js +419 -17
  42. package/lib/utils/ship/layout.d.ts +330 -0
  43. package/lib/utils/ship/layout.js +504 -0
  44. package/lib/utils/ship/localize-metadata.d.ts +22 -0
  45. package/lib/utils/ship/localize-metadata.js +126 -0
  46. package/lib/utils/ship/mime.js +1 -9
  47. package/lib/utils/ship/msi.d.ts +126 -0
  48. package/lib/utils/ship/msi.js +434 -0
  49. package/lib/utils/ship/node-runtime.d.ts +71 -0
  50. package/lib/utils/ship/node-runtime.js +122 -0
  51. package/lib/utils/ship/payload.d.ts +156 -5
  52. package/lib/utils/ship/payload.js +599 -14
  53. package/lib/utils/ship/pe-launcher.d.ts +29 -0
  54. package/lib/utils/ship/pe-launcher.js +663 -0
  55. package/lib/utils/ship/plan.d.ts +21 -0
  56. package/lib/utils/ship/plan.js +55 -7
  57. package/lib/utils/ship/plist.d.ts +98 -0
  58. package/lib/utils/ship/plist.js +256 -0
  59. package/lib/utils/ship/rpm.js +16 -3
  60. package/lib/utils/ship/schemas.d.ts +61 -0
  61. package/lib/utils/ship/schemas.js +118 -0
  62. package/lib/utils/ship/scripts.js +7 -3
  63. package/lib/utils/ship/settings.d.ts +91 -2
  64. package/lib/utils/ship/settings.js +93 -1
  65. package/lib/utils/ship/share-dirs.d.ts +53 -0
  66. package/lib/utils/ship/share-dirs.js +76 -0
  67. package/lib/utils/ship/signing.d.ts +311 -0
  68. package/lib/utils/ship/signing.js +506 -0
  69. package/lib/utils/ship/stage-manifest.d.ts +40 -12
  70. package/lib/utils/ship/stage-manifest.js +99 -13
  71. package/lib/utils/ship/stage-writer.d.ts +48 -0
  72. package/lib/utils/ship/stage-writer.js +91 -2
  73. package/lib/utils/ship/types.d.ts +191 -14
  74. package/lib/utils/ship/xml.d.ts +7 -0
  75. package/lib/utils/ship/xml.js +25 -0
  76. package/lib/utils/ship/zip.d.ts +69 -0
  77. package/lib/utils/ship/zip.js +245 -0
  78. package/lib/utils/system-gi.d.ts +20 -5
  79. package/lib/utils/system-gi.js +36 -13
  80. package/lib/utils/trust-registry.js +5 -0
  81. package/package.json +19 -19
@@ -1,7 +1,17 @@
1
+ import type { LauncherRuntime } from './app-runtime.js';
2
+ import type { Layout } from './layout.js';
1
3
  import type { ShipSettings } from './types.js';
2
4
  /**
3
- * Render the launcher for a GJS application.
5
+ * Render the launcher for one layout.
4
6
  *
5
- * `bundleRelPath` is the bundle's path inside `lib/<binaryName>/`.
7
+ * `bundleRelPath` is the bundle's path inside the layout's BUNDLE directory —
8
+ * `lib/<binaryName>/` on Linux, `Contents/Resources/lib/` in a `.app`, `app\` in
9
+ * a Windows program directory.
10
+ *
11
+ * `runtime` names what the stage CARRIES (`utils/ship/app-runtime.ts`), as
12
+ * stage-relative paths, and is empty when it carries nothing. The macOS and
13
+ * Windows forms read it; the Linux one does not and never will, because its
14
+ * interpreter is a package dependency. The default is what keeps a stage that
15
+ * carries nothing byte-identical to what M1 wrote.
6
16
  */
7
- export declare function renderLauncher(settings: ShipSettings, bundleRelPath: string): string;
17
+ export declare function renderLauncher(settings: ShipSettings, bundleRelPath: string, layout: Layout, runtime?: LauncherRuntime): string;
@@ -1,35 +1,132 @@
1
- // The `bin/<name>` entry a package installs.
1
+ // The launcher a package installs in front of the app — one form per OS layout.
2
2
  //
3
- // It derives its own prefix at runtime instead of having one baked in. That is
3
+ // Every form execs ONE interpreter, named by `settings.app`, and `deriveDepends`
4
+ // seeds the dependency from that same field. That coupling is the point rather
5
+ // than a convenience: the first cut of the Node half derived "this payload needs
6
+ // Node" from a FILENAME (`*.node.mjs` anywhere in the staged tree) while this
7
+ // function still execed gjs unconditionally, and the two disagreed in both
8
+ // directions — a `--app gjs` project that merely built a Node bundle beside its
9
+ // GJS one got `Depends: gjs (>= 1.86), nodejs (>= 24)` and a launcher running the
10
+ // GJS bundle, and `>= 24` is unsatisfiable on every current DEB stable, so a
11
+ // working package became uninstallable for a reason its author never opted into.
12
+ // `assertLauncherMatchesInterpreter` in `utils/ship/payload.ts` re-reads this
13
+ // line off the STAGED file and is the gate that keeps the two from drifting
14
+ // apart again. It reads the LAYOUT's launcher, so the three forms below share one
15
+ // `execLine` rather than each answering the question themselves.
16
+ //
17
+ // It derives its own location at runtime instead of having one baked in. That is
4
18
  // what lets ONE staged payload become a `.deb` under `/usr`, an `.rpm` under
5
19
  // `/usr` and a Flatpak under `/app` (ADR 0024 § 3) — a baked path would force a
6
20
  // payload per format and collapse the whole design back into N packagers.
7
21
  //
8
- // `XDG_DATA_DIRS` is prepended for the same reason. Under `/usr` it is already
9
- // on the list and the line is a no-op; installed anywhere else (`/opt`, a
10
- // `--prefix=$HOME/.local` tree, a relocated Flatpak) it is what makes the
11
- // staged icons, the desktop entry and the compiled GSettings schemas findable.
12
- // Getting that from the launcher rather than from the app keeps the app free of
13
- // install-layout knowledge.
22
+ // `XDG_DATA_DIRS` is prepended for the same reason, and on all three OSes: GTK
23
+ // and GLib read it on macOS and Windows too, and it is what makes the staged
24
+ // icons, the desktop entry and the compiled GSettings schemas findable wherever
25
+ // the tree was installed. Getting that from the launcher rather than from the app
26
+ // keeps the app free of install-layout knowledge.
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
+ //
38
+ // THREE FORMS, because neither other OS runs this file. macOS runs a `/bin/sh`
39
+ // script too but cannot use `readlink -f` and must not be handed a `DYLD_*`;
40
+ // Windows runs `cmd.exe`, which shares no syntax with either. What they DO share
41
+ // is the set of decisions — where the prefix comes from, which variables are
42
+ // exported, what is exec'd — so the three renderers sit here side by side and the
43
+ // dispatch is a `switch` with a `never` guard, the same closed-vocabulary
44
+ // discipline `packOne` uses for the packers.
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
+ }
14
60
  /** POSIX-shell single-quote: the only escape inside `'…'` is `'\''`. */
15
61
  function shellQuote(value) {
16
62
  return `'${value.replace(/'/g, `'\\''`)}'`;
17
63
  }
18
64
  /**
19
- * Render the launcher for a GJS application.
65
+ * Quote one argument for `cmd.exe`, or refuse it.
66
+ *
67
+ * `cmd.exe` has no escape that survives its own parser for `"` or `%` inside a
68
+ * quoted string: `%` starts an expansion at PARSE time (so `%foo%` silently
69
+ * becomes empty rather than staying literal), and `"` ends the quoted run with
70
+ * nothing that reliably re-opens it. `!` is only special under delayed expansion,
71
+ * which this launcher does not enable, but a consumer copying the line into one
72
+ * that does gets the same silent substitution — so all three are refused with the
73
+ * config key named, rather than emitted into a launcher that runs with the wrong
74
+ * argv.
75
+ */
76
+ function cmdQuote(value) {
77
+ const bad = /["%!\r\n]/.exec(value);
78
+ if (bad !== null) {
79
+ throw new Error(`gjsify ship: \`gjsify.ship.execArgs\` contains ${JSON.stringify(value)}, and cmd.exe has no way ` +
80
+ `to pass ${JSON.stringify(bad[0])} through a Windows launcher literally — \`%\` expands at ` +
81
+ 'parse time and `"` ends the quoted run. Move the value into the app (it can read it from its ' +
82
+ 'own config) or drop the character.');
83
+ }
84
+ return `"${value}"`;
85
+ }
86
+ /**
87
+ * Render the launcher for one layout.
88
+ *
89
+ * `bundleRelPath` is the bundle's path inside the layout's BUNDLE directory —
90
+ * `lib/<binaryName>/` on Linux, `Contents/Resources/lib/` in a `.app`, `app\` in
91
+ * a Windows program directory.
92
+ *
93
+ * `runtime` names what the stage CARRIES (`utils/ship/app-runtime.ts`), as
94
+ * stage-relative paths, and is empty when it carries nothing. The macOS and
95
+ * Windows forms read it; the Linux one does not and never will, because its
96
+ * interpreter is a package dependency. The default is what keeps a stage that
97
+ * carries nothing byte-identical to what M1 wrote.
98
+ */
99
+ export function renderLauncher(settings, bundleRelPath, layout, runtime = {}) {
100
+ switch (layout.name) {
101
+ case 'linux':
102
+ return renderPrefixLauncher(settings, bundleRelPath, layout);
103
+ case 'darwin':
104
+ return renderAppBundleLauncher(settings, bundleRelPath, layout, runtime);
105
+ case 'windows':
106
+ return renderWindowsLauncher(settings, bundleRelPath, layout, runtime);
107
+ default: {
108
+ const unhandled = layout.name;
109
+ throw new Error(`gjsify ship: no launcher is wired for the "${String(unhandled)}" layout.`);
110
+ }
111
+ }
112
+ }
113
+ /**
114
+ * Linux: a `/bin/sh` script that walks up from its own RESOLVED path to the prefix.
20
115
  *
21
- * `bundleRelPath` is the bundle's path inside `lib/<binaryName>/`.
116
+ * `readlink -f` is what makes this correct when the package's `bin/` entry has
117
+ * been symlinked — `/usr/local/bin/<name>` → `/usr/lib/<pkg>/…` is a layout
118
+ * distributions produce — and it is GNU coreutils', which every Linux this
119
+ * packages for has.
22
120
  */
23
- export function renderLauncher(settings, bundleRelPath) {
24
- const lib = `"$prefix"/lib/${settings.binaryName}`;
25
- const args = settings.execArgs.map(shellQuote).join(' ');
121
+ function renderPrefixLauncher(settings, bundleRelPath, layout) {
122
+ const dirs = layout.dirs(settings);
26
123
  const lines = [
27
124
  '#!/bin/sh',
28
125
  '# Generated by `gjsify ship` — do not edit.',
29
126
  'set -e',
30
127
  'self=$(readlink -f "$0")',
31
128
  'prefix=$(dirname "$(dirname "$self")")',
32
- 'XDG_DATA_DIRS="$prefix/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"',
129
+ `XDG_DATA_DIRS="$prefix/${dirs.data}:\${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"`,
33
130
  'export XDG_DATA_DIRS',
34
131
  ];
35
132
  // Typelibs the package carries itself (see `gjsify.ship.bundledTypelibs`). PREPENDED, not
@@ -38,7 +135,7 @@ export function renderLauncher(settings, bundleRelPath) {
38
135
  // actually staged — an empty `GI_TYPELIB_PATH` entry makes GI search a directory that is not
39
136
  // there, on every import, forever.
40
137
  if (settings.typelibFiles.length > 0) {
41
- const giDir = '"$prefix"/lib/' + settings.binaryName + '/gi';
138
+ const giDir = `"$prefix"/${dirs.native}`;
42
139
  for (const variable of ['GI_TYPELIB_PATH', 'LD_LIBRARY_PATH']) {
43
140
  // `${VAR:+:$VAR}` appends the existing value with a separator ONLY when it is set —
44
141
  // a plain `:$VAR` leaves a trailing colon, which both loaders read as "and the current
@@ -53,8 +150,313 @@ export function renderLauncher(settings, bundleRelPath) {
53
150
  // above does for icons and schemas — a baked `/usr/share/locale` would be wrong in a Flatpak
54
151
  // and in any `--prefix` tree.
55
152
  if (settings.localeFiles.length > 0) {
56
- lines.push('GJSIFY_LOCALE_DIR="$prefix"/share/locale', 'export GJSIFY_LOCALE_DIR');
153
+ lines.push(`GJSIFY_LOCALE_DIR="$prefix"/${dirs.data}/locale`, 'export GJSIFY_LOCALE_DIR');
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');
57
163
  }
58
- lines.push(`exec gjs -m ${lib}/${bundleRelPath}${args ? ` ${args}` : ''} "$@"`, '');
164
+ lines.push(`exec ${execLine(layout, `"$prefix"/${dirs.bundle}/${bundleRelPath}`, settings)} "$@"`, '');
59
165
  return lines.join('\n');
60
166
  }
167
+ /**
168
+ * macOS: `Contents/MacOS/<name>` — what a self-contained bundle's launcher says,
169
+ * and two things it must still NOT do.
170
+ *
171
+ * IT EXECS WHAT THE BUNDLE CARRIES. `runtime` comes from
172
+ * `utils/ship/app-runtime.ts` and names the interpreter, the relocated GTK
173
+ * closure and the node-gi addon staged inside `Contents/` — so the exec line is
174
+ * `"$here/node"` and the two locators below point into `Contents/Frameworks`.
175
+ * When the stage carries none of them every line here is byte-identical to what
176
+ * M2a wrote, which is what keeps a bundle assembled without the runtime packages
177
+ * exactly as (un)usable as it was rather than differently broken.
178
+ *
179
+ * 1. **No `readlink -f`.** That flag is GNU coreutils'; the BSD `readlink` macOS
180
+ * ships does not have it, and a launcher whose first command fails under
181
+ * `set -e` exits before it does anything. It needs none either: LaunchServices,
182
+ * `open(1)` and Finder all exec `Contents/MacOS/<name>` at its real path
183
+ * inside the bundle, and the staged tree holds no symlink to resolve.
184
+ * 2. **No `DYLD_*`.** THE RULE STANDS AND ITS OLD REASON DID NOT, so the reason
185
+ * is replaced rather than the rule. This comment used to say a wrapper
186
+ * "structurally cannot hand the loader a library path" because SIP strips an
187
+ * inherited `DYLD_*` at the `/bin/sh` exec — which is stronger than what was
188
+ * measured, and this repository depends on the difference in two places that
189
+ * are green on the darwin legs today (`utils/bin-shim.ts`'s
190
+ * `dyldFallbackPreamble`, `packages/node-gi`'s `maybeReexecForGtkRuntime`).
191
+ * The narrow fact is that an INHERITED `DYLD_*` is stripped when a PROTECTED
192
+ * binary is exec'd; what a shim exports ITSELF survives into an unprotected
193
+ * child.
194
+ *
195
+ * The reason that holds is one milestone away and points the same way: a
196
+ * hardened-runtime, Developer-ID-signed main executable IS restricted, so the
197
+ * variable is stripped there. A launcher depending on `DYLD_*` therefore
198
+ * works unsigned and breaks on the day the bundle is signed (#1354 M6,
199
+ * ADR 0024 § A4) — the worst possible day to find out. `GI_TYPELIB_PATH` is
200
+ * not a `DYLD_` variable and is stripped under neither rule, so the half that
201
+ * CAN be handed over is; the other half is
202
+ * `GIRepository.Repository.prepend_library_path` from inside the process,
203
+ * which is ADR 0021's decision for prebuilds applied to the app's own runtime.
204
+ *
205
+ * Corrected rather than deleted, because a rule whose stated reason is wrong
206
+ * gets "simplified" back into the bug the first time somebody checks it.
207
+ *
208
+ * What this function exports INSTEAD is the point of the distinction, not an
209
+ * exception to it: `GJSIFY_GTK_RUNTIME`, `NODE_GI_NATIVE`,
210
+ * `GJSIFY_GI_LIBRARY_PATH`, `GI_TYPELIB_PATH`. Every one is read by node-gi
211
+ * or by GLib in JS or in the process, none by dyld, so none is stripped from
212
+ * a restricted process and the bundle behaves the same signed and unsigned.
213
+ * The dylibs themselves need no variable at all: `install_name_tool` rewrote
214
+ * every install name to `@loader_path/<leaf>` when the closure was built, and
215
+ * the addon's `@rpath` is `@loader_path/gtk/lib` — which is exactly why the
216
+ * closure has to be staged as a TREE and not flattened.
217
+ */
218
+ function renderAppBundleLauncher(settings, bundleRelPath, layout, runtime) {
219
+ const root = layout.root(settings);
220
+ const contents = `${root}/Contents`;
221
+ const dirs = layout.dirs(settings);
222
+ // Every path is expressed relative to `Contents/`, which the launcher finds by
223
+ // going one directory up from `Contents/MacOS`. Deriving them from the
224
+ // layout's own directories is what keeps the two from drifting: they are the
225
+ // same strings the planner placed the files with.
226
+ const under = (dir) => posix.relative(contents, dir);
227
+ // …and the interpreter relative to `$here`, which is `Contents/MacOS` itself.
228
+ // Two anchors rather than one because the launcher already computes both, and
229
+ // `"$here/node"` is the shortest true expression for a file beside it.
230
+ const beside = (path) => posix.relative(dirs.launcher, path);
231
+ const lines = [
232
+ '#!/bin/sh',
233
+ '# Generated by `gjsify ship` — do not edit.',
234
+ 'set -e',
235
+ 'here=$(cd -- "$(dirname -- "$0")" && pwd)',
236
+ 'contents=$(dirname -- "$here")',
237
+ `XDG_DATA_DIRS="$contents/${under(dirs.data)}:\${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"`,
238
+ 'export XDG_DATA_DIRS',
239
+ ];
240
+ // The two locators a carried runtime needs, and both are read by node-gi ITSELF
241
+ // rather than by a loader — which is the whole reason they are allowed here at
242
+ // all (see this function's second numbered note).
243
+ //
244
+ // `GJSIFY_GTK_RUNTIME` is candidate 1 of `resolveGtkRuntimeBundle()`'s four.
245
+ // Candidates 2–4 walk from `@gjsify/node-gi`'s own package directory or through
246
+ // `node_modules`, and a shipped `.app` has neither: its JavaScript is one
247
+ // bundled file under `Contents/Resources/lib`, so `import.meta.url` there
248
+ // resolves node-gi's "package root" to the bundle's directory and every probed
249
+ // path lands beside the bundle instead of in `Contents/Frameworks`.
250
+ //
251
+ // `NODE_GI_NATIVE` is the same problem for the addon: `prebuildAddonPath()`
252
+ // joins `prebuilds/<target>/node_gi.node` onto that same wrong root
253
+ // (`packages/node-gi/node-gi/native-paths.js`). An absolute path is one of the
254
+ // three values that variable takes (`'build'`, `'prebuild'`, or a path), and
255
+ // `nativeCandidates()` resolves it and returns it ALONE — so the bundle's addon
256
+ // is not merely preferred, nothing else is tried.
257
+ if (runtime.gtkRuntimeDir !== undefined) {
258
+ lines.push(`GJSIFY_GTK_RUNTIME="$contents/${under(runtime.gtkRuntimeDir)}"`, 'export GJSIFY_GTK_RUNTIME');
259
+ }
260
+ if (runtime.nodeGiAddon !== undefined) {
261
+ lines.push(`NODE_GI_NATIVE="$contents/${under(runtime.nodeGiAddon)}"`, 'export NODE_GI_NATIVE');
262
+ }
263
+ if (settings.typelibFiles.length > 0) {
264
+ lines.push(`GI_TYPELIB_PATH="$contents/${under(dirs.native)}"\${GI_TYPELIB_PATH:+:$GI_TYPELIB_PATH}`, 'export GI_TYPELIB_PATH');
265
+ // The other half of the same pair, and the half no loader variable can
266
+ // carry here. GI resolves the typelib and then `g_module_open`s the bare
267
+ // leaf it records; `LD_LIBRARY_PATH` answers that on Linux and `DYLD_*`
268
+ // cannot answer it in a signed bundle. `GJSIFY_GI_LIBRARY_PATH` is read by
269
+ // node-gi in JS and handed to `gi_repository_prepend_library_path()`, so
270
+ // dyld never sees it (#1410). Written here for the first time: that fix
271
+ // shipped the READER with no writer anywhere in this pipeline.
272
+ lines.push(`GJSIFY_GI_LIBRARY_PATH="$contents/${under(dirs.native)}"\${GJSIFY_GI_LIBRARY_PATH:+:$GJSIFY_GI_LIBRARY_PATH}`, 'export GJSIFY_GI_LIBRARY_PATH');
273
+ }
274
+ if (settings.localeFiles.length > 0) {
275
+ lines.push(`GJSIFY_LOCALE_DIR="$contents/${under(dirs.data)}/locale"`, 'export GJSIFY_LOCALE_DIR');
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
+ }
286
+ // `"$here/node"`, not `node`. The bare name is true of a developer's machine
287
+ // and false of a `.app` a stranger downloads, and the difference is not a
288
+ // degraded experience: macOS ships no Node at all, so the launcher's first act
289
+ // would be `exec: node: not found`.
290
+ const interpreter = runtime.interpreter === undefined ? undefined : `"$here/${beside(runtime.interpreter)}"`;
291
+ lines.push(`exec ${execLine(layout, `"$contents/${under(dirs.bundle)}/${bundleRelPath}"`, settings, interpreter)} "$@"`, '');
292
+ return lines.join('\n');
293
+ }
294
+ /**
295
+ * Windows: a `.cmd` that derives the program directory from `%~dp0` — and execs
296
+ * the interpreter that directory carries.
297
+ *
298
+ * CRLF, and that is not cosmetic — `cmd.exe` reads a batch file in chunks and
299
+ * re-seeks by byte OFFSET while it runs, which is where the documented `goto` and
300
+ * block-parsing failures on LF-only files come from. It costs nothing to be right
301
+ * about, and the file is ASCII for the same reason: a batch file is read in the
302
+ * console's active code page, not as UTF-8.
303
+ *
304
+ * `PATH` carries the native directory because Windows has no rpath: a DLL is
305
+ * found on `PATH`, in the directory of the image that loaded it, or not at all.
306
+ * That makes `PATH` the load-bearing variable here — where `LD_LIBRARY_PATH` is
307
+ * on Linux, and where nothing can be on macOS. It is also the answer to the
308
+ * question `GJSIFY_GI_LIBRARY_PATH` answers in the `.app` form: GI resolves a
309
+ * typelib and then `g_module_open`s the bare leaf it records, and on Windows
310
+ * `PATH` reaches that leaf, so no second variable is needed.
311
+ *
312
+ * WHAT THIS DELIBERATELY DOES NOT PUT ON `PATH` is the carried GTK closure's own
313
+ * `bin/`, and the omission is the Windows counterpart of the `.app` form's "no
314
+ * `DYLD_*`" rule — with the opposite reason, so it is written down rather than
315
+ * inferred. node-gi does it ITSELF, in-process, before it `require`s the addon:
316
+ * `maybePrependGtkRuntimeDllPath()` runs at `packages/node-gi/node-gi/index.js`
317
+ * top level, above `loadNative()`, because "Windows re-reads the DLL search path
318
+ * at every LoadLibrary (unlike dyld's launch-time capture), so mutating
319
+ * process.env.PATH in-process before the require() below covers both the static
320
+ * DLL imports and the runtime g_module_open of typelib backers". What that
321
+ * function needs from the launcher is the LOCATOR — `GJSIFY_GTK_RUNTIME`,
322
+ * candidate 1 of `resolveGtkRuntimeBundle()`'s four — and a launcher-set `PATH`
323
+ * would be a second, silently diverging copy of a directory node-gi already
324
+ * derives from it.
325
+ *
326
+ * `runtime` comes from `utils/ship/app-runtime.ts` and names what the program
327
+ * directory CARRIES, as stage-relative paths. When it carries nothing every line
328
+ * here is byte-identical to what M1 wrote (#1354 M3).
329
+ */
330
+ function renderWindowsLauncher(settings, bundleRelPath, layout, runtime) {
331
+ const dirs = layout.dirs(settings);
332
+ // `%~dp0` already ends in a backslash, so every path below concatenates
333
+ // directly — which is the whole dialect difference from the `.app` form's
334
+ // `"$here/node"`, where the separator is written out. `utils/ship/payload.ts`
335
+ // reads this token back and has to know it (`readLauncherInterpreters`).
336
+ const at = (rel) => `%HERE%${windowsPath(rel)}`;
337
+ const lines = [
338
+ '@echo off',
339
+ 'rem Generated by "gjsify ship" - do not edit.',
340
+ 'setlocal',
341
+ 'set "HERE=%~dp0"',
342
+ // `if defined` rather than a bare `;%VAR%`, for the reason the sh form
343
+ // gives: an unset variable leaves a trailing separator, and an empty entry
344
+ // in a Windows search path is read as the current directory.
345
+ prependVar('XDG_DATA_DIRS', at(dirs.data)),
346
+ ];
347
+ // The two locators a carried runtime needs, identical in role to the `.app`
348
+ // form's and read by node-gi ITSELF rather than by a loader. `GJSIFY_GTK_RUNTIME`
349
+ // is candidate 1 of `resolveGtkRuntimeBundle()`'s four, and candidates 2-4 walk
350
+ // from `@gjsify/node-gi`'s own package directory — which, in a shipped program
351
+ // directory, resolves to wherever the staged JavaScript sits and not to `lib/`.
352
+ // `NODE_GI_NATIVE` pins the addon the same way, absolutely, so
353
+ // `nativeCandidates()` returns it ALONE.
354
+ //
355
+ // `set "NAME=value"`, quoted, because an unquoted `set` keeps trailing spaces
356
+ // and a program directory under `C:\Program Files\…` has spaces in it.
357
+ if (runtime.gtkRuntimeDir !== undefined) {
358
+ lines.push(`set "GJSIFY_GTK_RUNTIME=${at(runtime.gtkRuntimeDir)}"`);
359
+ }
360
+ if (runtime.nodeGiAddon !== undefined) {
361
+ lines.push(`set "NODE_GI_NATIVE=${at(runtime.nodeGiAddon)}"`);
362
+ }
363
+ if (settings.typelibFiles.length > 0) {
364
+ lines.push(prependVar('GI_TYPELIB_PATH', at(dirs.native)), prependVar('PATH', at(dirs.native)));
365
+ }
366
+ if (settings.localeFiles.length > 0) {
367
+ lines.push(`set "GJSIFY_LOCALE_DIR=${at(dirs.data)}\\locale"`);
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
+ }
379
+ // `"%HERE%node.exe"`, not `node`. The bare name is true of a developer's
380
+ // machine and false of a program directory a stranger unzips: Windows ships no
381
+ // Node at all, so the launcher's first act would be `'node' is not recognized
382
+ // as an internal or external command`.
383
+ const interpreter = runtime.interpreter === undefined ? undefined : `"${at(runtime.interpreter)}"`;
384
+ // No `exec` in batch. `cmd.exe` reports the last command's exit code when the
385
+ // script ends, so the interpreter's status is the launcher's status.
386
+ lines.push(`${execLine(layout, `"${at(dirs.bundle)}\\${windowsPath(bundleRelPath)}"`, settings, interpreter)} %*`, '');
387
+ return lines.join('\r\n');
388
+ }
389
+ /** `set "VAR=<value>;%VAR%"`, and just `<value>` when VAR is not set. */
390
+ function prependVar(variable, value) {
391
+ return `if defined ${variable} (set "${variable}=${value};%${variable}%") else (set "${variable}=${value}")`;
392
+ }
393
+ /** POSIX-separated → Windows-separated. An empty segment would produce `\\`, which is a UNC root. */
394
+ function windowsPath(rel) {
395
+ return rel
396
+ .split('/')
397
+ .filter((part) => part.length > 0)
398
+ .join('\\');
399
+ }
400
+ /**
401
+ * The interpreter, the bundle and the project's own `execArgs` — for every layout.
402
+ *
403
+ * ONE function, called by all three forms, because the answer is the same
404
+ * question everywhere and the launcher is where it is checkable:
405
+ * `assertLauncherMatchesInterpreter` reads this line back off the STAGED file
406
+ * and compares it with the dependency about to be declared.
407
+ *
408
+ * `settings.app`, NOT the layout. ADR 0024 § 4's runtime-per-OS table says what
409
+ * a shipped artifact CARRIES — that is `Layout.shippedRuntime` — and reading it
410
+ * as a per-layout launcher decision breaks both directions at once: it would
411
+ * refuse `gjsify.app: "gjs"` for the macOS layout, and it would put `exec node`
412
+ * in front of a bundle whose first line is
413
+ * `import Gtk from 'gi://Gtk?version=4.0'`. Measured before it was fixed: a
414
+ * project with no `gjsify.app` key staged
415
+ * `exec node "$contents/Resources/lib/gjs.js"` for macOS at exit 0. The only
416
+ * interpreter that can read the payload is the one the payload was BUILT for,
417
+ * and that is `settings.app` on all three.
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
+ *
424
+ * `Layout.runtimeGap` is where the honest half of that lives: it says, per OS,
425
+ * why the staged launcher does not yet name what § 4 derives, and `gjsify ship`
426
+ * prints it.
427
+ *
428
+ * `gjs` needs `-m` to treat the bundle as an ES module; `node` decides from the
429
+ * extension and rejects the flag.
430
+ *
431
+ * THE NAME OR A PATH, and which one is a fact about the STAGE. On Linux it stays
432
+ * the bare name, because the emitted dependency is what guarantees the
433
+ * interpreter is on `PATH` — and on Fedora `/usr/bin/node` is an alternatives
434
+ * symlink whose target is whichever stream package won, so hardcoding a path
435
+ * there would pin the launcher to a layout the dependency does not promise. macOS
436
+ * and Windows have no system Node and no dependency to declare, which is what
437
+ * `@gjsify/node-runtime-<target>` exists for; this comment used to say "the day a
438
+ * stage carries one, THIS is the line that names it by a layout-relative path
439
+ * instead", and #1354 M2b is that day — `utils/ship/app-runtime.ts` stages the
440
+ * interpreter into `Contents/MacOS/node` and the `.app` launcher execs
441
+ * `"$here/node"`.
442
+ *
443
+ * #1354 M3 is the same day one OS over: the Windows layout stages `node.exe` into
444
+ * the program directory and its `.cmd` runs `"%HERE%node.exe"`.
445
+ *
446
+ * `utils/ship/payload.ts`'s `readLauncherInterpreters` reads those quoted,
447
+ * variable-bearing tokens back to the bare name `node`, which is what keeps
448
+ * `assertLauncherMatchesInterpreter` a check rather than a vacuous pass. The two
449
+ * dialects differ in a way that reader has to know: `$here/` writes its separator
450
+ * and `%~dp0` already ends in one, so the macOS token splits on a `/` that is
451
+ * there and the Windows token has no separator to split on at all.
452
+ */
453
+ function execLine(layout, bundle, settings, interpreter) {
454
+ const quote = layout.name === 'windows' ? cmdQuote : shellQuote;
455
+ const args = settings.execArgs.map(quote).join(' ');
456
+ // The NAME when the stage carries no interpreter, the layout-relative PATH
457
+ // when it does. `-m` stays attached to `gjs` in both branches: it tells the
458
+ // interpreter to read the bundle as a module and has nothing to do with how
459
+ // the interpreter was found.
460
+ const program = interpreter ?? (settings.app === 'node' ? 'node' : 'gjs');
461
+ return `${settings.app === 'node' ? program : `${program} -m`} ${bundle}${args ? ` ${args}` : ''}`;
462
+ }