@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.
- package/dist/cli.gjs.mjs +202 -167
- package/lib/commands/gettext.d.ts +2 -1
- package/lib/commands/gettext.js +105 -47
- package/lib/commands/onboard.d.ts +7 -0
- package/lib/commands/onboard.js +349 -96
- package/lib/commands/ship.d.ts +3 -0
- package/lib/commands/ship.js +850 -58
- package/lib/commands/trust.d.ts +6 -0
- package/lib/commands/trust.js +1 -1
- package/lib/commands/upgrade.d.ts +23 -0
- package/lib/commands/upgrade.js +95 -12
- package/lib/templates/install.mjs.tmpl +2 -2
- package/lib/types/config-data.d.ts +127 -1
- package/lib/utils/gi-search-path.js +11 -3
- package/lib/utils/msgfmt-merge.d.ts +35 -0
- package/lib/utils/msgfmt-merge.js +102 -0
- package/lib/utils/npm-otp.d.ts +27 -2
- package/lib/utils/npm-otp.js +82 -7
- package/lib/utils/onboard-discovery.d.ts +68 -0
- package/lib/utils/onboard-discovery.js +178 -0
- package/lib/utils/onboard-probe.d.ts +118 -1
- package/lib/utils/onboard-probe.js +153 -4
- package/lib/utils/prompt-output.d.ts +12 -0
- package/lib/utils/prompt-output.js +54 -0
- package/lib/utils/prompt.js +22 -2
- package/lib/utils/publishable-packages.d.ts +1 -1
- package/lib/utils/publishable-packages.js +1 -0
- package/lib/utils/resolve-npm-package.d.ts +56 -0
- package/lib/utils/resolve-npm-package.js +69 -0
- package/lib/utils/ship/app-runtime.d.ts +214 -0
- package/lib/utils/ship/app-runtime.js +429 -0
- package/lib/utils/ship/depends.d.ts +50 -0
- package/lib/utils/ship/depends.js +117 -3
- package/lib/utils/ship/discover.js +103 -1
- package/lib/utils/ship/dmg.d.ts +71 -0
- package/lib/utils/ship/dmg.js +181 -0
- package/lib/utils/ship/flatpak.js +1 -1
- package/lib/utils/ship/formats.d.ts +108 -7
- package/lib/utils/ship/formats.js +778 -15
- package/lib/utils/ship/launcher.d.ts +13 -3
- package/lib/utils/ship/launcher.js +419 -17
- package/lib/utils/ship/layout.d.ts +330 -0
- package/lib/utils/ship/layout.js +504 -0
- package/lib/utils/ship/localize-metadata.d.ts +22 -0
- package/lib/utils/ship/localize-metadata.js +126 -0
- package/lib/utils/ship/mime.js +1 -9
- package/lib/utils/ship/msi.d.ts +126 -0
- package/lib/utils/ship/msi.js +434 -0
- package/lib/utils/ship/node-runtime.d.ts +71 -0
- package/lib/utils/ship/node-runtime.js +122 -0
- package/lib/utils/ship/payload.d.ts +156 -5
- package/lib/utils/ship/payload.js +599 -14
- package/lib/utils/ship/pe-launcher.d.ts +29 -0
- package/lib/utils/ship/pe-launcher.js +663 -0
- package/lib/utils/ship/plan.d.ts +21 -0
- package/lib/utils/ship/plan.js +55 -7
- package/lib/utils/ship/plist.d.ts +98 -0
- package/lib/utils/ship/plist.js +256 -0
- package/lib/utils/ship/rpm.js +16 -3
- package/lib/utils/ship/schemas.d.ts +61 -0
- package/lib/utils/ship/schemas.js +118 -0
- package/lib/utils/ship/scripts.js +7 -3
- package/lib/utils/ship/settings.d.ts +91 -2
- package/lib/utils/ship/settings.js +93 -1
- package/lib/utils/ship/share-dirs.d.ts +53 -0
- package/lib/utils/ship/share-dirs.js +76 -0
- package/lib/utils/ship/signing.d.ts +311 -0
- package/lib/utils/ship/signing.js +506 -0
- package/lib/utils/ship/stage-manifest.d.ts +40 -12
- package/lib/utils/ship/stage-manifest.js +99 -13
- package/lib/utils/ship/stage-writer.d.ts +48 -0
- package/lib/utils/ship/stage-writer.js +91 -2
- package/lib/utils/ship/types.d.ts +191 -14
- package/lib/utils/ship/xml.d.ts +7 -0
- package/lib/utils/ship/xml.js +25 -0
- package/lib/utils/ship/zip.d.ts +69 -0
- package/lib/utils/ship/zip.js +245 -0
- package/lib/utils/system-gi.d.ts +20 -5
- package/lib/utils/system-gi.js +36 -13
- package/lib/utils/trust-registry.js +5 -0
- 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
|
+
}
|