@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
@@ -0,0 +1,504 @@
1
+ // WHERE a payload's files land, per operating system (ADR 0024 § 2, § A2).
2
+ //
3
+ // The payload is produced ONCE and is identical everywhere; what differs is the
4
+ // layout. Up to here that difference was a single string — `FormatDescriptor.prefix`,
5
+ // `/usr` for deb and rpm, `/app` for a Flatpak — because every Linux format keeps
6
+ // the same `bin/`, `lib/<name>/`, `share/…` shape underneath it. `Contents/MacOS`
7
+ // is the first layout that is NOT a prefix substitution: the launcher, the bundle,
8
+ // the shared libraries and the data tree each move somewhere different, and on
9
+ // macOS the native files leave the bundle directory altogether for
10
+ // `Contents/Frameworks`.
11
+ //
12
+ // So the layout is a MAP from the prefix-relative plan to a stage-relative path,
13
+ // and `planStage` keeps producing the prefix-relative plan for all three. That
14
+ // split is what makes the claim checkable: one payload, three layouts, and the
15
+ // file SET has to be identical modulo this map (`tests/e2e/ship-layout`).
16
+ //
17
+ // It also draws the line a third-party app author needs. Everything structural
18
+ // here belongs to the OS — `Contents/MacOS` is Apple's, `%~dp0` is cmd.exe's.
19
+ // Everything with a NAME in it comes from the consumer's own `gjsify.ship` block:
20
+ // the `<App>.app` directory is `name`, the launcher inside it is `binaryName`, and
21
+ // the data tree is addressed by `appId`. Nothing here is derived from gjsify's own
22
+ // tree, and nothing here has a gjsify-specific default.
23
+ import { posix } from 'node:path';
24
+ import { buildGuiLauncher } from './pe-launcher.js';
25
+ import { BUNDLE_INFO_PLIST, BUNDLE_PKGINFO, renderInfoPlist, renderPkgInfo } from './plist.js';
26
+ import { SHARE } from './share-dirs.js';
27
+ /**
28
+ * Characters a `<App>.app` directory name may not contain.
29
+ *
30
+ * `/` is the POSIX separator and `:` is HFS+'s — the Finder shows a stored `:` as
31
+ * a `/` and vice versa, so a name carrying either produces a bundle whose
32
+ * directory is not the one its own metadata names. Refused here rather than at
33
+ * `hdiutil` time, which is three milestones away and on another host.
34
+ */
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
+ }
57
+ function appBundleDir(identity) {
58
+ if (BUNDLE_NAME_FORBIDDEN.test(identity.name)) {
59
+ throw new Error(`gjsify ship: the macOS layout would put this app in "${identity.name}.app", and a bundle ` +
60
+ 'directory may contain no "/", "\\" or ":" — HFS+ and the Finder swap the first two, so the ' +
61
+ 'bundle would not be the one its own metadata names. Set `gjsify.ship.name` to the display ' +
62
+ 'name you want on disk.');
63
+ }
64
+ return `${identity.name}.app`;
65
+ }
66
+ export const LAYOUTS = {
67
+ linux: {
68
+ name: 'linux',
69
+ os: 'linux',
70
+ shippedRuntime: 'gjs',
71
+ launcherExt: '',
72
+ root: () => '',
73
+ // The app id in the directory name is what makes `/usr/lib/<pkg>/` a
74
+ // directory the PACKAGE owns — the prefix is shared with every other
75
+ // package on the system. Inside a `.app` or a program directory nothing
76
+ // is shared, which is why the other two rows drop it.
77
+ dirs: (identity) => ({
78
+ launcher: 'bin',
79
+ bundle: `lib/${identity.binaryName}`,
80
+ native: `lib/${identity.binaryName}/gi`,
81
+ data: 'share',
82
+ other: '',
83
+ }),
84
+ // A `/usr` prefix has no manifest of its own — everything a Linux package
85
+ // says about itself is in the `.deb`/`.rpm` header or in the freedesktop
86
+ // files the payload already carries.
87
+ metadata: () => [],
88
+ // Every architecture Debian and rpm have a name for, which is what
89
+ // `DEBIAN_ARCH`/`RPM_ARCH` already enumerate — the layout adds nothing.
90
+ arches: null,
91
+ },
92
+ darwin: {
93
+ name: 'darwin',
94
+ os: 'darwin',
95
+ shippedRuntime: 'node',
96
+ // GJS does exist on macOS — Homebrew ships it and the `macos` CI leg runs
97
+ // `test:gjs` on both arches — so a launcher naming it is true of a
98
+ // developer's machine and NOT of a `.app` a stranger downloads:
99
+ // `packages/node-gi/scripts/build-gtk-runtime-darwin.mjs` records that GJS
100
+ // ships no relocation, which is why § 4 derives Node here.
101
+ runtimeGap: 'the staged launcher execs an interpreter off `PATH`, which a downloaded `.app` cannot assume — ' +
102
+ 'macOS ships no Node, and there is no RELOCATABLE GJS (`build-gtk-runtime-darwin.mjs`: "GJS ' +
103
+ 'ships no relocation"), so ADR 0024 § 4 derives Node here. A self-contained bundle needs `@gjsify/node-runtime-darwin-<arch>` and a ' +
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.',
129
+ launcherExt: '',
130
+ root: appBundleDir,
131
+ // Apple's, all four. `Contents/MacOS` holds executables, `Contents/Resources`
132
+ // everything that is not, and `Contents/Frameworks` the dylibs — which is
133
+ // the split that makes this more than a prefix: the `gi/` directory that
134
+ // sits INSIDE `lib/<name>/` on Linux leaves the bundle directory entirely.
135
+ dirs: (identity) => {
136
+ const app = appBundleDir(identity);
137
+ return {
138
+ launcher: `${app}/Contents/MacOS`,
139
+ bundle: `${app}/Contents/Resources/lib`,
140
+ native: `${app}/Contents/Frameworks`,
141
+ data: `${app}/Contents/Resources/share`,
142
+ other: `${app}/Contents/Resources`,
143
+ };
144
+ },
145
+ // The two files that make the directory a BUNDLE. Without `Info.plist`
146
+ // nothing tells LaunchServices which file under `Contents/MacOS` to exec,
147
+ // and a `*.app` with no `Info.plist` is a folder with a suffix — which is
148
+ // exactly what M1 staged. `plist.ts` carries the per-key citations.
149
+ metadata: (input, payload) => [
150
+ {
151
+ path: `${appBundleDir(input)}/${BUNDLE_INFO_PLIST}`,
152
+ mode: 0o644,
153
+ source: { kind: 'text', text: renderInfoPlist(input, darwinFontsPath(input, payload)) },
154
+ },
155
+ {
156
+ path: `${appBundleDir(input)}/${BUNDLE_PKGINFO}`,
157
+ mode: 0o644,
158
+ source: { kind: 'text', text: renderPkgInfo() },
159
+ },
160
+ ],
161
+ // The two architectures macOS runs on, which is also exactly what
162
+ // `@gjsify/gtk-runtime-darwin-*` and `@gjsify/node-runtime-darwin-*` are
163
+ // published for. Nothing upstream blocks a third; there is no third.
164
+ arches: {
165
+ only: ['x64', 'arm64'],
166
+ why: 'macOS runs on x86_64 and Apple silicon and nothing else, so those are the two ' +
167
+ '`@gjsify/gtk-runtime-darwin-*` and `@gjsify/node-runtime-darwin-*` targets that exist',
168
+ },
169
+ },
170
+ windows: {
171
+ name: 'windows',
172
+ os: 'win32',
173
+ shippedRuntime: 'node',
174
+ // The stronger of the two gaps, and not a relocation question: there is
175
+ // no GJS host on Windows AT ALL, so nothing on that OS can run a
176
+ // `--app gjs` payload until M0's bundled Node exists and the payload is
177
+ // built for it. Cited to ADR 0024 § 4, which is where the fact is
178
+ // established — an earlier version of this string cited
179
+ // `docs/ci-selective.md`, which contains no occurrence of it and whose
180
+ // four `gjs` mentions are all about the affected-classifier bundle. The
181
+ // bad citation came in from the ADR's own § 4 table and was promoted into
182
+ // a user-visible string, which is the worst place for one.
183
+ runtimeGap: 'the staged launcher execs an interpreter off `PATH`, which a downloaded program directory ' +
184
+ 'cannot assume — Windows ships neither, and there is NO GJS host on Windows at all ' +
185
+ '(ADR 0024 § 4), so § 4 derives Node here. A self-contained directory needs ' +
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.',
236
+ // `.cmd`, not `.bat`: the two differ in whether a failing built-in (`set`,
237
+ // `path`, `append`) sets ERRORLEVEL, and `.cmd` is the one where it does.
238
+ launcherExt: '.cmd',
239
+ root: () => '',
240
+ // A program directory has no imposed shape, so this one is chosen for the
241
+ // LOADER: `lib/` is what the launcher prepends to `PATH`, because Windows
242
+ // has no rpath — a DLL is found on `PATH`, in the directory of the image
243
+ // that loaded it, or not at all.
244
+ dirs: () => ({ launcher: '', bundle: 'app', native: 'lib', data: 'share', other: '' }),
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
+ : [],
280
+ // ONE, and the blocker is a project we do not own. `wingtk/gvsbuild`
281
+ // hardcodes `self.platform = "x64"` in `utils/base_project.py` and its last
282
+ // five releases publish exactly two assets each, both x64 — so there is no
283
+ // arm64 GTK to build `@gjsify/gtk-runtime-win32-arm64` OUT OF, and on
284
+ // Windows that bundle is the only GTK there is. `@gjsify/node-gi` declares
285
+ // `win32-x64` only for the same reason. Tracked in #1117, which also
286
+ // records why an exploratory arm64 leg must NOT be added: its first step
287
+ // downloads a ZIP that does not exist, so it would be red by construction.
288
+ arches: {
289
+ only: ['x64'],
290
+ why: 'gvsbuild publishes no arm64 GTK (it hardcodes `self.platform = "x64"`), so there is nothing ' +
291
+ 'to build `@gjsify/gtk-runtime-win32-arm64` out of and no GTK for a Windows/ARM artifact to ' +
292
+ 'load — the blocker is upstream and is tracked in gjsify/gjsify#1117',
293
+ },
294
+ },
295
+ };
296
+ /** Every layout name, derived from the table so a new row cannot be missed. */
297
+ export const LAYOUT_NAMES = Object.keys(LAYOUTS);
298
+ /**
299
+ * `HostOs` → the layout for it.
300
+ *
301
+ * Derived rather than a second table: `LAYOUTS` is already total over
302
+ * `LayoutName`, so reading its rows back means a fourth OS cannot be added to one
303
+ * list and forgotten in the other.
304
+ */
305
+ const BY_OS = new Map(LAYOUT_NAMES.map((name) => [LAYOUTS[name].os, LAYOUTS[name]]));
306
+ /** Stage-relative path of the launcher this layout puts in front of the app. */
307
+ export function launcherPath(layout, identity) {
308
+ return posix.join(layout.dirs(identity).launcher, `${identity.binaryName}${layout.launcherExt}`);
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
+ }
340
+ /**
341
+ * The layout for one `process.platform` token, refusing every alias.
342
+ *
343
+ * STRICT, and that is the difference from {@link resolveLayout}, which is for a
344
+ * word a human typed. This one is for the stage manifest's `target.os` — a
345
+ * cross-host WIRE FORMAT with exactly one legal spelling per OS. Accepting
346
+ * `"windows"` there and silently comparing it as `win32` would mean
347
+ * `--expect-target win32-x64` no longer compares against the file's content:
348
+ * two manifests with different bytes in that field would both match, and the
349
+ * flag's whole job is to catch a job that downloaded the wrong artifact.
350
+ */
351
+ export function layoutForOs(os) {
352
+ const layout = BY_OS.get(os);
353
+ if (layout === undefined) {
354
+ throw new Error(`"${os}" is not a \`process.platform\` token this gjsify has a layout for. ` +
355
+ `Known: ${LAYOUT_NAMES.map((name) => LAYOUTS[name].os).join(', ')} — the ` +
356
+ '`${process.platform}-${process.arch}` spelling, so `win32` and never `windows`.');
357
+ }
358
+ return layout;
359
+ }
360
+ /**
361
+ * Resolve the positional to a layout.
362
+ *
363
+ * Accepts BOTH vocabularies (`windows` and `win32`) for the reason
364
+ * {@link LayoutName} records: the ADR writes one and `--expect-target` prints the
365
+ * other, and refusing whichever the user has in front of them is a papercut with
366
+ * no upside. The canonical answer is a single `Layout`, so nothing downstream has
367
+ * two spellings to reconcile. NOT for the stage manifest — see {@link layoutForOs}.
368
+ */
369
+ export function resolveLayout(raw) {
370
+ const name = raw.trim().toLowerCase();
371
+ if (Object.hasOwn(LAYOUTS, name))
372
+ return LAYOUTS[name];
373
+ const byOs = BY_OS.get(name);
374
+ if (byOs !== undefined)
375
+ return byOs;
376
+ throw new Error(`gjsify ship: "${raw}" is not an operating system this command has a layout for. ` +
377
+ `Known: ${LAYOUT_NAMES.join(', ')} (\`win32\` is accepted for \`windows\`, because that is the ` +
378
+ 'spelling `--expect-target <os>-<arch>` uses).');
379
+ }
380
+ /**
381
+ * The layout for the host this process is running on.
382
+ *
383
+ * What `gjsify ship` picks when the positional is absent, so a bare `gjsify ship`
384
+ * on Linux keeps assembling the Linux layout and packing `.deb` + `.rpm` exactly
385
+ * as before. A host whose platform has no layout is refused BY NAME rather than
386
+ * silently treated as Linux — a payload laid out for `/usr` and labelled for a
387
+ * FreeBSD would install and not run.
388
+ */
389
+ export function hostLayout(platform = process.platform) {
390
+ const layout = BY_OS.get(platform);
391
+ if (layout === undefined) {
392
+ throw new Error(`gjsify ship: this host is ${platform}, which has no layout. Name the one you want — ` +
393
+ `\`gjsify ship <${LAYOUT_NAMES.join('|')}> --stage\` — assembly is not host-bound ` +
394
+ '(ADR 0024 § A1).');
395
+ }
396
+ return layout;
397
+ }
398
+ /**
399
+ * Refuse an architecture this layout cannot be assembled for, naming the blocker.
400
+ *
401
+ * AT STAGE TIME, which is the whole point — see {@link Layout.arches}. The
402
+ * message carries the row's own `why` rather than a generic "unsupported", because
403
+ * the two refusals a reader can act on are different jobs: `x64` is what a Windows
404
+ * artifact is built for TODAY, and `win32-arm64` is blocked in a repository we do
405
+ * not own. Telling an author "unknown architecture" for the second one would send
406
+ * them to look for a flag.
407
+ *
408
+ * `process.arch` spelling on both sides, so the value quoted back is the one the
409
+ * user typed after `--arch`.
410
+ */
411
+ export function assertLayoutSupportsArch(layout, arch) {
412
+ const arches = layout.arches;
413
+ if (arches === null || arches.only.includes(arch))
414
+ return;
415
+ throw new Error(`gjsify ship: the ${layout.name} layout is not assemblable for \`--arch ${arch}\` — ` +
416
+ `${arches.only.length === 1 ? 'the only architecture it has' : 'the architectures it has'} ` +
417
+ `${arches.only.length === 1 ? 'is' : 'are'} ${arches.only.join(', ')}.\n` +
418
+ ` ${arches.why}.\n` +
419
+ ' `--arch` names the architecture the PAYLOAD was built for and cross-compiles nothing, so ' +
420
+ 'there is no\n' +
421
+ ' flag that makes this work — the artifact would carry a runtime that does not exist.');
422
+ }
423
+ /**
424
+ * Map one PREFIX-RELATIVE planned path to where this layout keeps it.
425
+ *
426
+ * The plan is written once, in the Linux/XDG shape, and this is the only place
427
+ * that knows any other. Order matters: `lib/<name>/gi/` is tested before
428
+ * `lib/<name>/`, because on macOS those two go to different directories.
429
+ */
430
+ export function place(layout, identity, rel) {
431
+ const dirs = layout.dirs(identity);
432
+ if (rel === `bin/${identity.binaryName}`)
433
+ return launcherPath(layout, identity);
434
+ const libPrefix = `lib/${identity.binaryName}/`;
435
+ const giPrefix = `${libPrefix}gi/`;
436
+ if (rel.startsWith(giPrefix))
437
+ return posix.join(dirs.native, rel.slice(giPrefix.length));
438
+ if (rel.startsWith(libPrefix))
439
+ return posix.join(dirs.bundle, rel.slice(libPrefix.length));
440
+ if (rel.startsWith('bin/'))
441
+ return posix.join(dirs.launcher, rel.slice('bin/'.length));
442
+ if (rel.startsWith('share/'))
443
+ return posix.join(dirs.data, rel.slice('share/'.length));
444
+ return posix.join(dirs.other, rel);
445
+ }
446
+ /**
447
+ * Apply a layout to a whole planned payload, and add what the layout itself owns.
448
+ *
449
+ * THREE SOURCES, one destination namespace. The mapped files come from
450
+ * `planStage`'s prefix-relative plan; {@link Layout.metadata} adds the files that
451
+ * have no prefix-relative counterpart at all (`Contents/Info.plist`); `carried`
452
+ * adds the runtime the artifact ships inside itself (#1354 M2b — the interpreter,
453
+ * the relocated GTK closure, the node-gi addon). All three go through the same
454
+ * uniqueness check, which is what makes the other two safe: an `extraFiles`
455
+ * destination that maps onto a metadata path is refused by name instead of
456
+ * silently replacing the file that makes the bundle a bundle.
457
+ *
458
+ * `carried` is already stage-relative and deliberately NOT mapped. A relocated
459
+ * closure's every internal relation is relative — `@loader_path/<leaf>` install
460
+ * names, `@loader_path/../../..` in `loaders.cache`, `@loader_path/gtk/lib` on the
461
+ * addon — so the tree survives the copy only if it is copied as a tree. Routing it
462
+ * through the prefix-relative plan would put it through
463
+ * `gjsify.ship.bundledTypelibs`'s flattening (`plan.ts`: `posix.join(libDir, 'gi',
464
+ * basename(file))`), which destroys all three at once.
465
+ *
466
+ * The uniqueness check is not decoration for the mapped half either. `planStage`
467
+ * deduplicates on the PREFIX-RELATIVE path — that is how `gjsify.ship.extraFiles`
468
+ * is documented to override a default — but the MAP can bring two distinct
469
+ * prefix-relative paths together: on Windows the launcher becomes
470
+ * `<binaryName>.cmd` at the program root, which an `extraFiles` destination can
471
+ * name directly. Without this the second one silently wins and which one that is
472
+ * depends on plan order — the "installs cleanly, does nothing" class this command
473
+ * is built against.
474
+ */
475
+ export function placeStage(layout, identity, files, carried = []) {
476
+ const byPlaced = new Map();
477
+ const out = [];
478
+ const claim = (path, from, file) => {
479
+ const previous = byPlaced.get(path);
480
+ if (previous !== undefined) {
481
+ throw new Error(`gjsify ship: in the ${layout.name} layout both ${previous} and ${from} install as ` +
482
+ `${path}. Two payload files cannot share one destination — one would silently replace the ` +
483
+ 'other. Rename the `gjsify.ship.extraFiles` destination that collides.');
484
+ }
485
+ byPlaced.set(path, from);
486
+ out.push({ ...file, path });
487
+ };
488
+ for (const file of files)
489
+ claim(place(layout, identity, file.path), file.path, file);
490
+ // The layout's own files are ALREADY stage-relative — they were never in the
491
+ // prefix-relative plan, so there is nothing to map. `<layout> metadata` rather
492
+ // than a path as the origin label, because a collision message naming
493
+ // `Contents/Info.plist` as both sides would say nothing.
494
+ for (const file of layout.metadata(identity, files)) {
495
+ claim(file.path, `the ${layout.name} layout's own metadata`, file);
496
+ }
497
+ // A THIRD source, and stage-relative like the second: the runtime the bundle
498
+ // CARRIES (`utils/ship/app-runtime.ts`). It goes through `claim` for the reason
499
+ // the metadata does — an `extraFiles` destination landing on a staged dylib
500
+ // would otherwise replace it silently, and the bundle would look complete.
501
+ for (const file of carried)
502
+ claim(file.path, `the runtime staged into the ${layout.name} artifact`, file);
503
+ return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
504
+ }
@@ -0,0 +1,22 @@
1
+ /** The generated metadata, before or after translation. */
2
+ export interface LocalizableMetadata {
3
+ /** The rendered AppStream MetaInfo XML. */
4
+ metainfo: string;
5
+ /** The rendered desktop entry — `undefined` for `kind: 'cli'`. */
6
+ desktopEntry?: string;
7
+ }
8
+ /** A staged catalogue, in the `<lang>/LC_MESSAGES/<domain>.mo` shape `discoverLocales` guarantees. */
9
+ export interface StagedCatalogue {
10
+ rel: string;
11
+ abs: string;
12
+ }
13
+ /**
14
+ * Translate the generated metadata with the catalogues the package ships.
15
+ *
16
+ * Returns the input unchanged when there is nothing to fold in, so a project
17
+ * without a `localeDir` never needs the gettext tools. A project WITH catalogues
18
+ * that lacks them is REFUSED rather than shipped in English: an untranslated
19
+ * `.desktop` is indistinguishable from a correct one at install time, and the
20
+ * package would have promised translations it does not deliver.
21
+ */
22
+ export declare function localizeMetadata(metadata: LocalizableMetadata, localeFiles: readonly StagedCatalogue[]): LocalizableMetadata;
@@ -0,0 +1,126 @@
1
+ // Folding the staged gettext catalogues into the generated freedesktop metadata.
2
+ //
3
+ // `gjsify ship` already discovered, validated and staged the compiled `.mo`
4
+ // catalogues, and `app-metadata.ts` already rendered a `.desktop` entry and an
5
+ // AppStream component. The two never met: no `Name[xx]=` and no `xml:lang=` was
6
+ // emitted anywhere, so a fully translated app still showed an English name in
7
+ // the GNOME app menu and in Software. Nobody reported it because both files
8
+ // stay VALID without the translations — `desktop-file-validate` and
9
+ // `appstreamcli validate` pass an untranslated file at exit 0, which is why the
10
+ // tests for this assert the localised keys are PRESENT rather than trusting a
11
+ // validator to notice they are gone.
12
+ //
13
+ // The msgfmt constraints this rests on live in `../msgfmt-merge.ts`, which runs
14
+ // the chain for `gjsify gettext` too. Two more are local to here, both from what
15
+ // a STAGED locale tree may hold that a `po/` directory cannot:
16
+ //
17
+ // * The catalogues reach us as `.mo` and msgfmt reads `.po` — handing it a
18
+ // `.mo` gives `de.mo:1:2: syntax error`, exit 1. `msgunfmt` converts back,
19
+ // and the round trip is exact for the msgid/msgstr pairs these two formats
20
+ // consume.
21
+ // * One language may arrive as SEVERAL catalogues, because the tree is keyed by
22
+ // `<lang>/LC_MESSAGES/<domain>.mo` and a package may ship more than one text
23
+ // domain. `msgcat --use-first` folds those into one before the chain sees
24
+ // them — the alternative is `msgfmt-merge.ts` constraint 5, an invalid file
25
+ // out of nothing but exit-0 calls.
26
+ //
27
+ // Both tools are in the same `gettext` package as `msgfmt`, so neither adds a
28
+ // dependency the fold did not already have.
29
+ import { execFileSync } from 'node:child_process';
30
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
31
+ import { tmpdir } from 'node:os';
32
+ import { join } from 'node:path';
33
+ import { mergeCatalogues } from '../msgfmt-merge.js';
34
+ /**
35
+ * The locale a staged catalogue path declares.
36
+ *
37
+ * Read rather than re-validated: `discoverLocales` has already refused anything
38
+ * that is not `<lang>/LC_MESSAGES/<domain>.mo`, and a second validator here would
39
+ * be a second definition of the layout.
40
+ */
41
+ function localeOf(rel) {
42
+ return rel.split('/')[0] ?? '';
43
+ }
44
+ /** Chain one metadata file through every catalogue, and read the result back. */
45
+ function fold(mode, source, extension, catalogues, workDir) {
46
+ // The source is a STRING, so it has to become a file before msgfmt can be its
47
+ // template — and that file needs `extension` for the same reason the
48
+ // intermediates do (`msgfmt-merge.ts`, constraint 4).
49
+ const template = join(workDir, `source${extension}`);
50
+ writeFileSync(template, source);
51
+ return readFileSync(mergeCatalogues({ mode, template, extension, catalogues, workDir }), 'utf-8');
52
+ }
53
+ /**
54
+ * Translate the generated metadata with the catalogues the package ships.
55
+ *
56
+ * Returns the input unchanged when there is nothing to fold in, so a project
57
+ * without a `localeDir` never needs the gettext tools. A project WITH catalogues
58
+ * that lacks them is REFUSED rather than shipped in English: an untranslated
59
+ * `.desktop` is indistinguishable from a correct one at install time, and the
60
+ * package would have promised translations it does not deliver.
61
+ */
62
+ export function localizeMetadata(metadata, localeFiles) {
63
+ if (localeFiles.length === 0)
64
+ return metadata;
65
+ const workDir = mkdtempSync(join(tmpdir(), 'gjsify-ship-l10n-'));
66
+ try {
67
+ // Sorted so the emitted `Name[xx]=` order — and therefore the artifact's
68
+ // bytes — does not depend on directory-read order. `listFilesRecursive`
69
+ // already sorts; relying on that from here would put the determinism of this
70
+ // file's output in another module's keeping. The sort ALSO fixes which
71
+ // domain wins below, so `--use-first` is a choice and not a coin toss.
72
+ const sorted = [...localeFiles].sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
73
+ // Grouped by LOCALE, not one entry per file. `discoverLocales` accepts every
74
+ // `<lang>/LC_MESSAGES/<domain>.mo`, and a package that ships two text domains
75
+ // (its own plus a bundled library's) therefore hands us two catalogues for one
76
+ // language. Chained as-is that is `msgfmt-merge.ts` constraint 5: two exit-0
77
+ // calls and a file both validators reject — `multiple keys named "Name[de]"`
78
+ // (exit 1) and `tag-duplicated name (lang=de)` (exit 3). Measured.
79
+ const byLocale = new Map();
80
+ for (const [index, file] of sorted.entries()) {
81
+ const po = join(workDir, `${index}.po`);
82
+ execFileSync('msgunfmt', [file.abs, '--output-file', po], { stdio: 'pipe' });
83
+ const locale = localeOf(file.rel);
84
+ byLocale.set(locale, [...(byLocale.get(locale) ?? []), po]);
85
+ }
86
+ const catalogues = [...byLocale].map(([locale, files]) => ({
87
+ locale,
88
+ // `--use-first` rather than a plain concatenation: two domains translating
89
+ // the same source string is not an error a packager can act on, and the
90
+ // alternatives are both worse. msgfmt given both files at once refuses
91
+ // outright (`duplicate message definition`, 2 fatal errors, exit 1), and a
92
+ // plain `msgcat` writes `#-#-#-#-#` conflict markers straight into the
93
+ // `Name=` a user reads. First in the sort above wins, deterministically.
94
+ po: files.length === 1 ? files[0] : msgcat(files, join(workDir, `${locale}.merged.po`)),
95
+ }));
96
+ return {
97
+ metainfo: fold('--xml', metadata.metainfo, '.metainfo.xml', catalogues, workDir),
98
+ desktopEntry: metadata.desktopEntry === undefined
99
+ ? undefined
100
+ : fold('--desktop', metadata.desktopEntry, '.desktop', catalogues, workDir),
101
+ };
102
+ }
103
+ catch (error) {
104
+ throw new Error(describeFailure(error, localeFiles.length));
105
+ }
106
+ finally {
107
+ rmSync(workDir, { recursive: true, force: true });
108
+ }
109
+ }
110
+ /** Fold several `.po` for ONE language into one, first translation wins. Returns its path. */
111
+ function msgcat(files, out) {
112
+ execFileSync('msgcat', ['--use-first', '--output-file', out, ...files], { stdio: 'pipe' });
113
+ return out;
114
+ }
115
+ /** One actionable line for a failed merge, naming the tool the host is missing. */
116
+ function describeFailure(error, count) {
117
+ const e = error;
118
+ if (e?.code === 'ENOENT') {
119
+ return ('gjsify ship: `msgfmt`/`msgunfmt`/`msgcat` are not on PATH, but the package stages ' +
120
+ `${count} gettext catalogue(s). Install them (package: gettext) — shipping the metadata ` +
121
+ 'untranslated would install an English app menu entry for a translated app, which nothing ' +
122
+ 'downstream can detect.');
123
+ }
124
+ const stderr = e?.stderr === undefined ? '' : `\n${String(e.stderr).trim()}`;
125
+ return `gjsify ship: could not fold the gettext catalogues into the freedesktop metadata.${stderr}`;
126
+ }