@gjsify/cli 0.64.5 → 0.64.7

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.
@@ -44,7 +44,7 @@ import { buildFlatpakBundle } from '../utils/ship/flatpak.js';
44
44
  import { iconSizes, resolveAppIcon } from '../utils/ship/icons.js';
45
45
  import { localizeMetadata } from '../utils/ship/localize-metadata.js';
46
46
  import { assertHostCanFinish, assertKindCanPack, assertToolsInstalled, canCarryTicket, configuredFormats, defaultFormatIds, formatIdsFor, resolveFormats, windowsProgramDirName, FORMAT_IDS, FORMATS, } from '../utils/ship/formats.js';
47
- import { assertLayoutSupportsArch, hostLayout, layoutForOs, place, placeStage, resolveLayout, LAYOUT_NAMES, } from '../utils/ship/layout.js';
47
+ import { assertLayoutSupportsArch, hostLayout, layoutForOs, place, placeStage, resolveLayout, windowsGuiLauncherPath, LAYOUT_NAMES, LAYOUTS, } from '../utils/ship/layout.js';
48
48
  import { isNodeRuntimeTarget, resolveNodeRuntime } from '../utils/ship/node-runtime.js';
49
49
  import { isUnder, SHARE } from '../utils/ship/share-dirs.js';
50
50
  import { compileSchemasForPayload, compileSchemasForStage } from '../utils/ship/schemas.js';
@@ -501,7 +501,17 @@ async function assemble(args) {
501
501
  // `packOne` reads it again. That is the price of checking the tree that is
502
502
  // shipped rather than the bytes that happen to be in memory, and it is the
503
503
  // same reason `readStage` exists at all.
504
- assertPayloadMatchesArch(readStage([stageDir], staged), settings.arch);
504
+ //
505
+ // ONE NAMED EXEMPTION: the windows GUI-launcher stub is x86-64 machine code
506
+ // on every windows arch by design (`pe-launcher.ts`'s `MACHINE_AMD64`
507
+ // comment) — it only `exec`s the sibling `.cmd`, which hands off to the
508
+ // arch-correct `node.exe` the stage carries, and Windows-on-ARM runs x64
509
+ // code through Prism for exactly this path. `layout.windows` now emits that
510
+ // stub unconditionally (it used to skip arm64 entirely), so an arm64 stage
511
+ // legitimately carries one x64 PE beside its arm64 payload.
512
+ assertPayloadMatchesArch(readStage([stageDir], staged), settings.arch, {
513
+ exempt: layout === LAYOUTS.windows ? new Set([windowsGuiLauncherPath(settings)]) : undefined,
514
+ });
505
515
  // What the map carried into a layout that has no install step for it.
506
516
  //
507
517
  // NOT a warning about this command's output being wrong — the files are the
@@ -268,7 +268,7 @@ function resolveLocalShowcaseDir(showcase) {
268
268
  */
269
269
  function gtkRuntimeSpecs(cliVersion) {
270
270
  const target = `${process.platform}-${process.arch}`;
271
- const shipped = ['win32-x64', 'darwin-arm64', 'darwin-x64'];
271
+ const shipped = ['win32-x64', 'win32-arm64', 'darwin-arm64', 'darwin-x64'];
272
272
  if (!shipped.includes(target))
273
273
  return [];
274
274
  const name = `@gjsify/gtk-runtime-${target}`;
@@ -173,12 +173,23 @@ export declare function buildNativeEnvPreamble(scanRoot: string, bakedDirs?: rea
173
173
  * cannot go stale the way baking `systemGiLibraryDirs()` in would (a launcher is
174
174
  * routinely written before `brew install gtk4`), and it re-derives nothing —
175
175
  * four literals from `dyld(1)`, shared with {@link dyldDefaultFallbackDirs}.
176
- * An inherited value stays AHEAD of it; the export only ADDS BACK what dyld
177
- * would have searched on its own.
176
+ * An inherited value stays AHEAD of it.
178
177
  *
179
- * NOT the host's GI libdirs: `/opt/homebrew/lib` was never in dyld's default
180
- * either, so an ARM Mac has that gap with or without gjsify —
181
- * `status/open-todos/README.md`.
178
+ * NOT SUFFICIENT ON ITS OWN, so the `lib/` beside the `gjs` this shim execs is
179
+ * appended: "set to the documented default" is NOT "unset". Measured on macOS 27 / arm64 with Homebrew
180
+ * gjs 1.88.1 and a fresh glib keg: `gjs -c "imports.gi.Gio"` loads with the
181
+ * variable unset and dies with the documented default exported —
182
+ * `Failed to load shared library 'libgjs.0.dylib' referenced by the typelib`,
183
+ * then `Unsupported type void` from Gio's override `_init`. `GjsPrivate-1.0`
184
+ * names GJS's own library by bare leaf, and once the variable is SET dyld only
185
+ * finds it in a listed directory; `gjs`'s rpath names GLib's keg, not its own.
186
+ * That broke every `gjsify` command on an Apple-silicon Mac, `--version`
187
+ * included, until a symlink was planted in GLib's keg by hand (the CI step
188
+ * `Make libgjs resolvable without a DYLD_ variable`) — and a `brew upgrade glib`
189
+ * silently removes it again. `<dir of gjs>/../lib` is where every prefix
190
+ * (Homebrew on either arch, MacPorts) links `libgjs`, resolved at LAUNCH from
191
+ * the same `PATH` lookup the `exec` uses, so it cannot go stale either. It goes
192
+ * LAST: it only has to catch what the defaults miss.
182
193
  */
183
194
  export declare function dyldFallbackPreamble(platform: string): string;
184
195
  /** POSIX `sh` single-quoting for a launcher's target path. */
@@ -444,12 +444,23 @@ export function buildNativeEnvPreamble(scanRoot, bakedDirs = [], target = {}) {
444
444
  * cannot go stale the way baking `systemGiLibraryDirs()` in would (a launcher is
445
445
  * routinely written before `brew install gtk4`), and it re-derives nothing —
446
446
  * four literals from `dyld(1)`, shared with {@link dyldDefaultFallbackDirs}.
447
- * An inherited value stays AHEAD of it; the export only ADDS BACK what dyld
448
- * would have searched on its own.
447
+ * An inherited value stays AHEAD of it.
449
448
  *
450
- * NOT the host's GI libdirs: `/opt/homebrew/lib` was never in dyld's default
451
- * either, so an ARM Mac has that gap with or without gjsify —
452
- * `status/open-todos/README.md`.
449
+ * NOT SUFFICIENT ON ITS OWN, so the `lib/` beside the `gjs` this shim execs is
450
+ * appended: "set to the documented default" is NOT "unset". Measured on macOS 27 / arm64 with Homebrew
451
+ * gjs 1.88.1 and a fresh glib keg: `gjs -c "imports.gi.Gio"` loads with the
452
+ * variable unset and dies with the documented default exported —
453
+ * `Failed to load shared library 'libgjs.0.dylib' referenced by the typelib`,
454
+ * then `Unsupported type void` from Gio's override `_init`. `GjsPrivate-1.0`
455
+ * names GJS's own library by bare leaf, and once the variable is SET dyld only
456
+ * finds it in a listed directory; `gjs`'s rpath names GLib's keg, not its own.
457
+ * That broke every `gjsify` command on an Apple-silicon Mac, `--version`
458
+ * included, until a symlink was planted in GLib's keg by hand (the CI step
459
+ * `Make libgjs resolvable without a DYLD_ variable`) — and a `brew upgrade glib`
460
+ * silently removes it again. `<dir of gjs>/../lib` is where every prefix
461
+ * (Homebrew on either arch, MacPorts) links `libgjs`, resolved at LAUNCH from
462
+ * the same `PATH` lookup the `exec` uses, so it cannot go stale either. It goes
463
+ * LAST: it only has to catch what the defaults miss.
453
464
  */
454
465
  export function dyldFallbackPreamble(platform) {
455
466
  if (platform !== 'darwin')
@@ -457,7 +468,10 @@ export function dyldFallbackPreamble(platform) {
457
468
  // `HOME: '$HOME'` on purpose — the shim must expand it at LAUNCH, not bake
458
469
  // the writing machine's home directory into every launcher.
459
470
  const dirs = dyldDefaultFallbackDirs({ HOME: '$HOME' }).join(':');
460
- return (`DYLD_FALLBACK_LIBRARY_PATH="\${DYLD_FALLBACK_LIBRARY_PATH:+$DYLD_FALLBACK_LIBRARY_PATH:}${dirs}"\n` +
471
+ return (`gjsify_gjs=$(command -v gjs 2>/dev/null)\n` +
472
+ `DYLD_FALLBACK_LIBRARY_PATH="\${DYLD_FALLBACK_LIBRARY_PATH:+$DYLD_FALLBACK_LIBRARY_PATH:}${dirs}` +
473
+ `\${gjsify_gjs:+:\${gjsify_gjs%/*}/../lib}"\n` +
474
+ `unset gjsify_gjs\n` +
461
475
  `export DYLD_FALLBACK_LIBRARY_PATH\n`);
462
476
  }
463
477
  /** POSIX `sh` single-quoting for a launcher's target path. */
@@ -72,7 +72,7 @@ import { getCachedPackument, putCachedPackument } from './install-packument-cach
72
72
  import { assertNativeBackendNodeVersion } from './node-version.js';
73
73
  import { buildCmdShim, buildLauncherShims, buildNativeEnvPreamble, buildShLauncher, isGjsBundlePath, parseShebang, pickBinMap, } from './bin-shim.js';
74
74
  import { detectNativePackages } from './detect-native-packages.js';
75
- import { findExtraneous, formatExtraneousError } from './install-extraneous.js';
75
+ import { droppedInstallPaths, findExtraneous, formatExtraneousError } from './install-extraneous.js';
76
76
  import { devLinkPath, devLinkRoot } from './dev-link.js';
77
77
  import { scanPrefix } from './prune-prefix.js';
78
78
  import { badPlatformError, checkPlatform, declaresPlatform, describePlatformTarget, readPlatformForce, resolveHostPlatform, } from './platform-check.js';
@@ -293,6 +293,12 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
293
293
  await downloadAndExtractAll(installable, opts.prefix, npmrc, log, opts.signal, progress);
294
294
  await linkBins(installable, opts.prefix, log);
295
295
  warnMissingNativeBuilds(installable, opts.prefix, log);
296
+ // Only after a resolve that rewrote the lockfile: that is when a placement can
297
+ // be dropped, and the previous lockfile is the record of what this installer
298
+ // put there. After the extract, so a failed download leaves the tree as it was.
299
+ if (resolved && opts.lockfile && existingLock) {
300
+ removeDroppedPackages(opts.prefix, droppedInstallPaths(Object.keys(existingLock.packages), nodes), new Set([...(opts.workspaceNames ?? []), ...(opts.linkedNames ?? [])]), log);
301
+ }
296
302
  log('install: done');
297
303
  // Top-level requested packages only, so callers can write the resolved version
298
304
  // back into package.json (`npm install --save`). From `nodes`, NEVER from
@@ -300,6 +306,38 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
300
306
  // downloaded", and a provided package is installed.
301
307
  return topLevelResolutions(opts.specs, nodes);
302
308
  }
309
+ /**
310
+ * Remove what {@link droppedInstallPaths} planned, and say so in one line.
311
+ *
312
+ * A symlink is never removed (a workspace or `gjsify link` checkout, i.e. the user's
313
+ * sources), and neither is a name a workspace or a dev link provides, whatever its
314
+ * path: the same two guards `extractOne` applies before it overwrites anything.
315
+ */
316
+ function removeDroppedPackages(prefix, installPaths, providedNames, log) {
317
+ const removed = [];
318
+ for (const installPath of installPaths) {
319
+ if (providedNames.has(nameFromInstallPath(installPath)))
320
+ continue;
321
+ const dir = path.join(prefix, installPath);
322
+ let stat;
323
+ try {
324
+ stat = fs.lstatSync(dir);
325
+ }
326
+ catch {
327
+ continue;
328
+ }
329
+ if (!stat.isDirectory())
330
+ continue;
331
+ fs.rmSync(dir, { recursive: true, force: true });
332
+ removed.push(installPath);
333
+ }
334
+ if (removed.length === 0)
335
+ return;
336
+ log('install: removed %d dropped placement(s): %s', removed.length, removed.join(', '));
337
+ const names = removed.map(nameFromInstallPath);
338
+ const shown = names.slice(0, 5).join(', ') + (names.length > 5 ? `, … ${names.length - 5} more` : '');
339
+ console.log(`gjsify: removed ${removed.length} package(s) the lockfile no longer names (${shown})`);
340
+ }
303
341
  /**
304
342
  * `--immutable` only: refuse a `node_modules` holding packages the lockfile does not
305
343
  * describe, naming every one of them.
@@ -91,3 +91,28 @@ export declare const EXTRANEOUS_REPORT_LIMIT = 20;
91
91
  * hand them a command that changes nothing and a refusal that repeats verbatim.
92
92
  */
93
93
  export declare function formatExtraneousError(entries: readonly ExtraneousPackage[], prefix: string): string;
94
+ /**
95
+ * Install paths the PREVIOUS lockfile placed and a fresh resolve no longer does —
96
+ * what a plain install DROPPED, and the only thing it removes.
97
+ *
98
+ * THE INCIDENT (2026-10-22). A project removed `@gjsify/gtk-runtime-darwin-*` from its
99
+ * `optionalDependencies`; `gjsify install` rewrote the lockfile without them and left
100
+ * the 0.64.2 copies in `node_modules`. On a Mac every darwin prebuild's rpath prefers
101
+ * that bundle's GLib over Homebrew's, so every build kept dying with `No signal
102
+ * 'context_response' on object 'GVolumeMonitor'` — after the fix, from a dependency
103
+ * the project no longer declared. npm removes such a package; this installer only
104
+ * ever added.
105
+ *
106
+ * WHY THE LOCKFILE DIFF, and not "everything {@link findExtraneous} reports". The tree
107
+ * legitimately holds packages no lockfile names: `ensureProjectGjsEngine` lays the GJS
108
+ * bundler engine down after the install and on purpose keeps it out of the lockfile.
109
+ * Sweeping the undescribed set would delete that engine on every install and fetch it
110
+ * again. A path some earlier lockfile of THIS project placed is this installer's own
111
+ * output, so removing it cannot take anything another writer put there.
112
+ *
113
+ * Nested placements under a dropped root collapse into the root, like
114
+ * {@link findExtraneous}, so the caller removes each directory once.
115
+ */
116
+ export declare function droppedInstallPaths(previous: Iterable<string>, next: readonly {
117
+ installPath: string;
118
+ }[]): string[];
@@ -129,3 +129,44 @@ export function formatExtraneousError(entries, prefix) {
129
129
  'In CI this usually means a cache entry was restored from a DIFFERENT lockfile.',
130
130
  ].join('\n');
131
131
  }
132
+ /**
133
+ * Install paths the PREVIOUS lockfile placed and a fresh resolve no longer does —
134
+ * what a plain install DROPPED, and the only thing it removes.
135
+ *
136
+ * THE INCIDENT (2026-10-22). A project removed `@gjsify/gtk-runtime-darwin-*` from its
137
+ * `optionalDependencies`; `gjsify install` rewrote the lockfile without them and left
138
+ * the 0.64.2 copies in `node_modules`. On a Mac every darwin prebuild's rpath prefers
139
+ * that bundle's GLib over Homebrew's, so every build kept dying with `No signal
140
+ * 'context_response' on object 'GVolumeMonitor'` — after the fix, from a dependency
141
+ * the project no longer declared. npm removes such a package; this installer only
142
+ * ever added.
143
+ *
144
+ * WHY THE LOCKFILE DIFF, and not "everything {@link findExtraneous} reports". The tree
145
+ * legitimately holds packages no lockfile names: `ensureProjectGjsEngine` lays the GJS
146
+ * bundler engine down after the install and on purpose keeps it out of the lockfile.
147
+ * Sweeping the undescribed set would delete that engine on every install and fetch it
148
+ * again. A path some earlier lockfile of THIS project placed is this installer's own
149
+ * output, so removing it cannot take anything another writer put there.
150
+ *
151
+ * Nested placements under a dropped root collapse into the root, like
152
+ * {@link findExtraneous}, so the caller removes each directory once.
153
+ */
154
+ export function droppedInstallPaths(previous, next) {
155
+ const kept = new Set(next.map((n) => normalizePath(n.installPath)));
156
+ const dropped = [...new Set([...previous].map(normalizePath))]
157
+ .filter((p) => p.startsWith('node_modules/') && !kept.has(p))
158
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
159
+ const keptList = [...kept];
160
+ const roots = [];
161
+ for (const p of dropped) {
162
+ const parent = roots[roots.length - 1];
163
+ if (parent && p.startsWith(`${parent}/`))
164
+ continue;
165
+ // A directory still holding a kept placement is not removed whole; its own
166
+ // dropped descendants are still listed, one by one.
167
+ if (keptList.some((k) => k.startsWith(`${p}/`)))
168
+ continue;
169
+ roots.push(p);
170
+ }
171
+ return roots;
172
+ }
@@ -1,4 +1,4 @@
1
- import type { Layout, LayoutIdentity } from './layout.js';
1
+ import { type Layout, type LayoutIdentity } from './layout.js';
2
2
  import { type ResolveInstalledPackageOptions } from '../resolve-npm-package.js';
3
3
  import type { StagedFile } from './types.js';
4
4
  /**
@@ -10,7 +10,7 @@ import type { StagedFile } from './types.js';
10
10
  * deriving one from the other would make a future Linux GTK bundle silently imply
11
11
  * a bundled Linux interpreter (which `node-runtime.ts` refuses on purpose).
12
12
  */
13
- export declare const GTK_RUNTIME_TARGETS: readonly ["darwin-arm64", "darwin-x64", "win32-x64"];
13
+ export declare const GTK_RUNTIME_TARGETS: readonly ["darwin-arm64", "darwin-x64", "win32-x64", "win32-arm64"];
14
14
  export type GtkRuntimeTarget = (typeof GTK_RUNTIME_TARGETS)[number];
15
15
  /** Is this `<platform>-<arch>` one of the published GTK-closure targets? */
16
16
  export declare function isGtkRuntimeTarget(target: string): target is GtkRuntimeTarget;
@@ -161,8 +161,11 @@ export interface AppRuntimePaths {
161
161
  /** `<prebuildDir>/node_gi.node` — what `NODE_GI_NATIVE` pins. */
162
162
  addonPath: string;
163
163
  /**
164
- * `<App>.app/Contents/MacOS/node`, or `<program dir>/node.exe` — beside the
165
- * launcher that execs it, under the name the target's own release uses.
164
+ * `<App>.app/Contents/Helpers/<display name>`, or `<program dir>/node.exe`
165
+ * beside the launcher — darwin names it after the app (`layout.ts`'s
166
+ * `dirs.helpers`) so Activity Monitor and System Events show the app
167
+ * instead of `node`; Windows keeps the interpreter's own release name
168
+ * beside the `.cmd`, where nothing reads a process list by file name.
166
169
  */
167
170
  interpreterPath: string;
168
171
  /** Node's own LICENSE, under `Contents/Resources` (macOS) or `share/` (Windows). */
@@ -80,6 +80,7 @@
80
80
  import { existsSync, readdirSync, statSync } from 'node:fs';
81
81
  import { dirname, join, posix, sep } from 'node:path';
82
82
  import { listFilesRecursive } from './discover.js';
83
+ import { assertUsableAsHelperName } from './layout.js';
83
84
  import { nodeRuntimeBinaryName } from './node-runtime.js';
84
85
  import { isExecutableAsset } from './plan.js';
85
86
  import { resolveInstalledPackage } from '../resolve-npm-package.js';
@@ -92,7 +93,7 @@ import { resolveInstalledPackage } from '../resolve-npm-package.js';
92
93
  * deriving one from the other would make a future Linux GTK bundle silently imply
93
94
  * a bundled Linux interpreter (which `node-runtime.ts` refuses on purpose).
94
95
  */
95
- export const GTK_RUNTIME_TARGETS = ['darwin-arm64', 'darwin-x64', 'win32-x64'];
96
+ export const GTK_RUNTIME_TARGETS = ['darwin-arm64', 'darwin-x64', 'win32-x64', 'win32-arm64'];
96
97
  /** Is this `<platform>-<arch>` one of the published GTK-closure targets? */
97
98
  export function isGtkRuntimeTarget(target) {
98
99
  return GTK_RUNTIME_TARGETS.includes(target);
@@ -264,20 +265,34 @@ export function appRuntimePaths(layout, identity, target) {
264
265
  prebuildDir,
265
266
  gtkDir: posix.join(prebuildDir, 'gtk'),
266
267
  addonPath: posix.join(prebuildDir, NODE_GI_ADDON_FILENAME),
267
- // `Contents/MacOS`, because that is where a `.app` keeps executables and
268
- // because `$here` — which the launcher already computes — is then the whole
269
- // path expression. `dirs.native` would work for dyld and would make
270
- // the launcher walk back up for the one file it execs. On Windows
271
- // `dirs.launcher` is the program directory itself, so the interpreter lands
272
- // beside the `.cmd` for the same reason.
268
+ // ON DARWIN, `Contents/Helpers/<display name>` — NEVER `Contents/MacOS`,
269
+ // because that directory already holds the launcher at `binaryName`, and
270
+ // APFS is case-insensitive by default: `stamped` and `Stamped` are the
271
+ // same file there, so no capitalisation of `binaryName` can share the
272
+ // directory with it. `Contents/Helpers` is one of Apple's own
273
+ // nested-code locations (`codesign(1)`'s bundle format, beside
274
+ // `Frameworks`, `PlugIns`, `XPCServices`), and naming the leaf after
275
+ // `identity.name` rather than `binaryName` is the whole point: Activity
276
+ // Monitor and System Events read a Mach-O process's name off its own
277
+ // FILE NAME, not off `exec -a` or any argv — measured by hand-editing a
278
+ // released `.app`, moving its interpreter to `Contents/Helpers/<App>`
279
+ // and rewriting the launcher's `exec` line, after which `ps -o comm` and
280
+ // System Events both read the app's name instead of `node`.
273
281
  //
274
- // THE LEAF COMES FROM `node-runtime.ts`, which is the module that already
275
- // decided it for the SOURCE file — `node.exe` on win32, `node` elsewhere,
276
- // read off the TARGET and never `process.platform` (assembling a Windows
277
- // program directory on Linux is the supported path). Restating the rule here
278
- // would let the two drift into a stage that copies `node.exe` to `node` and a
279
- // launcher that execs neither.
280
- interpreterPath: posix.join(dirs.launcher, nodeRuntimeBinaryName(target)),
282
+ // Derived from the LAYOUT (`dirs.helpers !== ''`) rather than from
283
+ // `layout.name === 'darwin'`: only the darwin row sets `helpers` to a
284
+ // real directory (`layout.ts`), so this branch is "does this layout have
285
+ // a helpers seam", not a second darwin-specific fact typed out here.
286
+ //
287
+ // ON WINDOWS AND LINUX, unchanged: `dirs.launcher` and the leaf
288
+ // `node-runtime.ts` already decided for the SOURCE file — `node.exe` on
289
+ // win32, read off the TARGET and never `process.platform` (assembling a
290
+ // Windows program directory on Linux is the supported path). A Windows
291
+ // program directory has no launcher-collision to dodge in the first
292
+ // place, so its interpreter stays beside the `.cmd`.
293
+ interpreterPath: dirs.helpers === ''
294
+ ? posix.join(dirs.launcher, nodeRuntimeBinaryName(target))
295
+ : posix.join(dirs.helpers, assertUsableAsHelperName(identity.name)),
281
296
  // rpm's `share/licenses/<name>/LICENSE` shape, which the `macos-app` row
282
297
  // already uses for the app's own licence — one layout for a reader to learn,
283
298
  // and `node` is a name no `binaryName` can collide with silently: `placeStage`
@@ -132,23 +132,19 @@ function macosArch(arch, _archIndependent) {
132
132
  /**
133
133
  * `process.arch` → the name Windows and everything that targets it use.
134
134
  *
135
- * ONE ROW, and it is not caution: there is no arm64 GTK CLOSURE to put on it yet.
136
- * `wingtk/gvsbuild` publishes no arm64 assets, which is why ADR 0089 builds that
137
- * prefix from our `gjsify/gvsbuild` fork in CI instead — but what a Windows
138
- * artifact stages is `@gjsify/gtk-runtime-*`, and the arm64 one is not built, so
139
- * there is nothing for a Windows/ARM artifact to load (#1117). `Layout.arches`
140
- * refuses the same value one phase earlier, at stage time, with that reason spelled
141
- * out; this table is the second half — the one that stops a stage assembled by an
142
- * older gjsify from acquiring a label here.
135
+ * TWO ROWS now. `wingtk/gvsbuild` still publishes x64 assets only, but ADR 0089
136
+ * builds the arm64 GTK prefix from our `gjsify/gvsbuild` fork in CI, and
137
+ * `@gjsify/gtk-runtime-win32-arm64` + `@gjsify/node-runtime-win32-arm64` exist —
138
+ * the two packages a Windows/ARM artifact stages (#1117, closed by this row).
143
139
  *
144
- * `x64` maps to itself, and unlike the macOS table that is not a coincidence to be
145
- * caught later: `x64` is the spelling Node's own release archives use
146
- * (`node-v24.20.0-win-x64.zip`), the spelling gvsbuild's assets use
147
- * (`GTK4_Gvsbuild_<v>_x64.zip`), and the spelling a `windows-latest x64` runner
148
- * label uses. `%PROCESSOR_ARCHITECTURE%` says `AMD64` and nothing a user downloads
149
- * is named after it.
140
+ * `x64` and `arm64` map to themselves, and unlike the macOS table that is not a
141
+ * coincidence to be caught later: both are the spelling Node's own release
142
+ * archives use (`node-v24.20.0-win-{x64,arm64}.zip`), the spelling a
143
+ * `windows-11-arm` runner's architecture is, and the spelling `--arch` already
144
+ * accepts everywhere else in this command. `%PROCESSOR_ARCHITECTURE%` says
145
+ * `AMD64`/`ARM64` and nothing a user downloads is named after that spelling.
150
146
  */
151
- const WINDOWS_ARCH = { x64: 'x64' };
147
+ const WINDOWS_ARCH = { x64: 'x64', arm64: 'arm64' };
152
148
  function windowsArch(arch, _archIndependent) {
153
149
  return lookupArch(WINDOWS_ARCH, arch, 'Windows');
154
150
  }
@@ -181,7 +181,16 @@ function renderPrefixLauncher(settings, bundleRelPath, layout) {
181
181
  * IT EXECS WHAT THE BUNDLE CARRIES. `runtime` comes from
182
182
  * `utils/ship/app-runtime.ts` and names the interpreter, the relocated GTK
183
183
  * closure and the node-gi addon staged inside `Contents/` — so the exec line is
184
- * `"$here/node"` and the two locators below point into `dirs.native`.
184
+ * `"$here/../Helpers/<display name>"` and the two locators below point into
185
+ * `dirs.native`. The interpreter is staged under `Contents/Helpers`, not
186
+ * `Contents/MacOS` beside this very script, and named after `identity.name`
187
+ * rather than kept as `node`: Activity Monitor and System Events read a
188
+ * process's name off the FILE NAME of the Mach-O that is running, not off argv
189
+ * or any `exec -a`, so a bundle whose carried interpreter is still called `node`
190
+ * shows up as `node` everywhere a user looks for it (`layout.ts`'s `dirs.helpers`,
191
+ * `app-runtime.ts`'s `appRuntimePaths`). `beside()` computes the relative hop
192
+ * from the actual two directories rather than assuming a sibling, so this
193
+ * function needs no special case for the rename.
185
194
  * When the stage carries none of them every line here is byte-identical to what
186
195
  * M2a wrote, which is what keeps a bundle assembled without the runtime packages
187
196
  * exactly as (un)usable as it was rather than differently broken.
@@ -308,10 +317,14 @@ function renderAppBundleLauncher(settings, bundleRelPath, layout, runtime) {
308
317
  if (settings.fontFiles.length > 0) {
309
318
  lines.push(`GJSIFY_FONT_DIR="$contents/${stagedFontDir(under(dirs.data), settings.appId)}"`, 'export GJSIFY_FONT_DIR');
310
319
  }
311
- // `"$here/node"`, not `node`. The bare name is true of a developer's machine
312
- // and false of a `.app` a stranger downloads, and the difference is not a
313
- // degraded experience: macOS ships no Node at all, so the launcher's first act
314
- // would be `exec: node: not found`.
320
+ // `"$here/../Helpers/<display name>"`, not a bare `node`. The bare name is
321
+ // true of a developer's machine and false of a `.app` a stranger downloads,
322
+ // and the difference is not a degraded experience: macOS ships no Node at
323
+ // all, so the launcher's first act would be `exec: node: not found`. The path
324
+ // is relative rather than the literal `../Helpers/…` written out, because
325
+ // `beside()` already carries the relation between `dirs.launcher` and
326
+ // wherever `appRuntimePaths` staged the interpreter — this function does not
327
+ // need to know that the leaf changed from `node` to `identity.name`.
315
328
  const interpreter = runtime.interpreter === undefined ? undefined : `"$here/${beside(runtime.interpreter)}"`;
316
329
  lines.push(`exec ${execLine(layout, `"$contents/${under(dirs.bundle)}/${bundleRelPath}"`, settings, interpreter)} "$@"`, '');
317
330
  return lines.join('\n');
@@ -462,8 +475,8 @@ function windowsPath(rel) {
462
475
  * `@gjsify/node-runtime-<target>` exists for; this comment used to say "the day a
463
476
  * stage carries one, THIS is the line that names it by a layout-relative path
464
477
  * instead", and #1354 M2b is that day — `utils/ship/app-runtime.ts` stages the
465
- * interpreter into `Contents/MacOS/node` and the `.app` launcher execs
466
- * `"$here/node"`.
478
+ * interpreter into `Contents/Helpers/<display name>` and the `.app` launcher
479
+ * execs `"$here/../Helpers/<display name>"`.
467
480
  *
468
481
  * #1354 M3 is the same day one OS over: the Windows layout stages `node.exe` into
469
482
  * the program directory and its `.cmd` runs `"%HERE%node.exe"`.
@@ -128,6 +128,15 @@ export interface LayoutDirs {
128
128
  * bundle can address it and `codesign` later refuses the bundle.
129
129
  */
130
130
  other: string;
131
+ /**
132
+ * macOS only: where a carried interpreter lives when it must NOT be named
133
+ * `binaryName` inside {@link LayoutDirs.launcher} — see
134
+ * `utils/ship/app-runtime.ts`'s `appRuntimePaths`. `''` on linux and windows,
135
+ * where nothing reads it: Linux takes its interpreter from the distribution,
136
+ * and a Windows program directory has no launcher-collision to dodge in the
137
+ * first place, so its interpreter stays beside the `.cmd`.
138
+ */
139
+ helpers: string;
131
140
  }
132
141
  /**
133
142
  * One operating system's layout.
@@ -290,6 +299,20 @@ export interface LayoutArches {
290
299
  * {@link LAYOUTS.linux.icon} for what asked for each of the three.
291
300
  */
292
301
  export declare const HICOLOR_RASTER_SIZES: readonly [64, 128, 256];
302
+ /**
303
+ * Refuse an `identity.name` that cannot be the carried interpreter's own file
304
+ * name under `Contents/Helpers` (`dirs.helpers`) — `appRuntimePaths` in
305
+ * `utils/ship/app-runtime.ts` is the only caller.
306
+ *
307
+ * A SUPERSET of {@link BUNDLE_NAME_FORBIDDEN}, not a restatement: the bundle
308
+ * directory wraps the same string in `.app`, so a name of "." or ".." becomes
309
+ * "..app" or "....app" — both ordinary directory names — while this leaf is
310
+ * `identity.name` taken LITERALLY, where "." and ".." name the directory
311
+ * itself and its parent. An empty name and a NUL byte are refused for the same
312
+ * reason `windowsProgramDirName` refuses them one OS over: neither is a name a
313
+ * filesystem can hold.
314
+ */
315
+ export declare function assertUsableAsHelperName(name: string): string;
293
316
  export declare const LAYOUTS: Record<LayoutName, Layout>;
294
317
  /** Every layout name, derived from the table so a new row cannot be missed. */
295
318
  export declare const LAYOUT_NAMES: LayoutName[];
@@ -75,6 +75,28 @@ function appBundleDir(identity) {
75
75
  }
76
76
  return `${identity.name}.app`;
77
77
  }
78
+ /**
79
+ * Refuse an `identity.name` that cannot be the carried interpreter's own file
80
+ * name under `Contents/Helpers` (`dirs.helpers`) — `appRuntimePaths` in
81
+ * `utils/ship/app-runtime.ts` is the only caller.
82
+ *
83
+ * A SUPERSET of {@link BUNDLE_NAME_FORBIDDEN}, not a restatement: the bundle
84
+ * directory wraps the same string in `.app`, so a name of "." or ".." becomes
85
+ * "..app" or "....app" — both ordinary directory names — while this leaf is
86
+ * `identity.name` taken LITERALLY, where "." and ".." name the directory
87
+ * itself and its parent. An empty name and a NUL byte are refused for the same
88
+ * reason `windowsProgramDirName` refuses them one OS over: neither is a name a
89
+ * filesystem can hold.
90
+ */
91
+ export function assertUsableAsHelperName(name) {
92
+ if (name === '' || name === '.' || name === '..' || BUNDLE_NAME_FORBIDDEN.test(name) || name.includes('\u0000')) {
93
+ throw new Error(`gjsify ship: the macOS layout would name the carried interpreter "${name}" under ` +
94
+ '`Contents/Helpers`, and that is not usable as a file name — empty, ".", ".." and anything ' +
95
+ 'containing "/", ":", "\\" or a NUL byte are refused. Set `gjsify.ship.name` to the display ' +
96
+ 'name you want.');
97
+ }
98
+ return name;
99
+ }
78
100
  /**
79
101
  * The rasters a row writes its icon from, or nothing for a CLI — and a refusal
80
102
  * for an application that arrives without them.
@@ -117,6 +139,7 @@ export const LAYOUTS = {
117
139
  native: `lib/${identity.binaryName}/gi`,
118
140
  data: 'share',
119
141
  other: '',
142
+ helpers: '',
120
143
  }),
121
144
  // THE RASTERS THE BUILD-TIME READERS OF THE THEME NEED, and the one row
122
145
  // whose icon is written as ordinary payload files rather than into a
@@ -280,6 +303,14 @@ export const LAYOUTS = {
280
303
  native: `${app}/Contents/Resources/native`,
281
304
  data: `${app}/Contents/Resources/share`,
282
305
  other: `${app}/Contents/Resources`,
306
+ // Apple's own nested-code location (alongside `Frameworks`,
307
+ // `PlugIns`, `XPCServices` — `codesign(1)`'s bundle format), and the
308
+ // one place `appRuntimePaths` can put the interpreter under a name
309
+ // that is not `binaryName`: `Contents/MacOS` already holds the
310
+ // launcher, and APFS is case-insensitive by default, so a second
311
+ // file there named `<BinaryName>` collides with it bytewise even
312
+ // when the casing differs.
313
+ helpers: `${app}/Contents/Helpers`,
283
314
  };
284
315
  },
285
316
  // The two files that make the directory a BUNDLE. Without `Info.plist`
@@ -411,7 +442,7 @@ export const LAYOUTS = {
411
442
  // LOADER: `lib/` is what the launcher prepends to `PATH`, because Windows
412
443
  // has no rpath — a DLL is found on `PATH`, in the directory of the image
413
444
  // that loaded it, or not at all.
414
- dirs: () => ({ launcher: '', bundle: 'app', native: 'lib', data: 'share', other: '' }),
445
+ dirs: () => ({ launcher: '', bundle: 'app', native: 'lib', data: 'share', other: '', helpers: '' }),
415
446
  // NO METADATA, and one PROGRAM. A Windows installer's metadata lives in the
416
447
  // `.msi`'s own tables, not in a file inside the program directory —
417
448
  // `msi.ts` puts every such fact in the `Product`/`Package` attributes of the
@@ -450,39 +481,40 @@ export const LAYOUTS = {
450
481
  required: true,
451
482
  },
452
483
  metadata: (input) => {
484
+ // Unconditional on `input.arch` — see `pe-launcher.ts`'s `MACHINE_AMD64`
485
+ // comment for why: the stub is x86-64 machine code on EVERY windows arch,
486
+ // because all it does is `exec` the sibling `.cmd`, which hands off to the
487
+ // native `node.exe` the layout staged for that arch. This used to read
488
+ // `input.arch === 'x64'`, which silently emitted NO launcher stub — and
489
+ // therefore no icon and no `.exe` for the MSI shortcut to point at — on
490
+ // arm64, the one arch `arches.only` now admits besides x64.
453
491
  const icon = iconFor(input, 'windows');
454
- return input.arch === 'x64'
455
- ? [
456
- {
457
- path: windowsGuiLauncherPath(input),
458
- mode: 0o755,
459
- source: {
460
- kind: 'bytes',
461
- data: buildGuiLauncher({
462
- logLeaf: windowsLaunchLogLeaf(input),
463
- ...(icon === undefined ? {} : { icon: iconResources(icon) }),
464
- }),
465
- },
492
+ return [
493
+ {
494
+ path: windowsGuiLauncherPath(input),
495
+ mode: 0o755,
496
+ source: {
497
+ kind: 'bytes',
498
+ data: buildGuiLauncher({
499
+ logLeaf: windowsLaunchLogLeaf(input),
500
+ ...(icon === undefined ? {} : { icon: iconResources(icon) }),
501
+ }),
466
502
  },
467
- ]
468
- : [];
503
+ },
504
+ ];
469
505
  },
470
- // ONE, and the blocker is OURS now. `wingtk/gvsbuild` hardcodes
471
- // `self.platform = "x64"` and publishes x64 assets only — but we stopped
472
- // waiting for it: ADR 0089 builds the arm64 GTK prefix from
473
- // `gjsify/gvsbuild` in our own CI and `@gjsify/node-gi` declares
474
- // `win32-arm64` against it (#2003). What is missing is
475
- // `@gjsify/gtk-runtime-win32-arm64`, the closure this layout stages and on
476
- // Windows the only GTK there is, so a Windows/ARM artifact still has
477
- // nothing to load. Adding arm64 to `only` is the PR that builds that
478
- // package, not this one.
506
+ // TWO now. `wingtk/gvsbuild` still hardcodes `self.platform = "x64"` and
507
+ // publishes x64 assets only, but ADR 0089 builds the arm64 GTK prefix from
508
+ // our `gjsify/gvsbuild` fork in CI, `@gjsify/node-gi` ships a win32-arm64
509
+ // prebuild against it (#2003), and `@gjsify/gtk-runtime-win32-arm64` +
510
+ // `@gjsify/node-runtime-win32-arm64` now exist — the two packages this
511
+ // layout stages. Tracked in gjsify/gjsify#1117; remaining gaps (gstsoup,
512
+ // webgl, gamepad, WebView2 on arm64) are `status/open-todos/windows.md`
513
+ // § win32-arm64, not a reason to withhold the architecture.
479
514
  arches: {
480
- only: ['x64'],
481
- why: 'the arm64 GTK closure `@gjsify/gtk-runtime-win32-arm64` does not exist yet — the prefix it ' +
482
- 'would be built from is no longer missing, because ADR 0089 builds it from our `gjsify/gvsbuild` ' +
483
- 'fork in CI and `@gjsify/node-gi` ships a win32-arm64 prebuild against it — so what is left is ' +
484
- 'the runtime package this layout would stage, ordered in `status/open-todos/windows.md` and ' +
485
- 'tracked in gjsify/gjsify#1117',
515
+ only: ['x64', 'arm64'],
516
+ why: 'win32 runs on x64 natively and on arm64 via the MSVC closure ADR 0089 builds — both have a ' +
517
+ '`@gjsify/gtk-runtime-win32-*` and `@gjsify/node-runtime-win32-*` to stage',
486
518
  },
487
519
  },
488
520
  };