@gjsify/cli 0.53.0 → 0.55.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 (43) hide show
  1. package/dist/cli.gjs.mjs +266 -249
  2. package/lib/bundler-pick.d.ts +35 -0
  3. package/lib/bundler-pick.js +74 -7
  4. package/lib/cli-app.js +3 -1
  5. package/lib/commands/blueprint/index.d.ts +4 -0
  6. package/lib/commands/blueprint/index.js +15 -0
  7. package/lib/commands/blueprint/types.d.ts +8 -0
  8. package/lib/commands/blueprint/types.js +148 -0
  9. package/lib/commands/devtools.d.ts +21 -0
  10. package/lib/commands/devtools.js +146 -0
  11. package/lib/commands/index.d.ts +2 -0
  12. package/lib/commands/index.js +2 -0
  13. package/lib/commands/ship.js +56 -8
  14. package/lib/commands/webext/index.js +1 -1
  15. package/lib/types/config-data.d.ts +5 -0
  16. package/lib/utils/oxc-resolve.js +9 -7
  17. package/lib/utils/ship/app-runtime.d.ts +7 -5
  18. package/lib/utils/ship/app-runtime.js +11 -8
  19. package/lib/utils/ship/appimage.d.ts +9 -0
  20. package/lib/utils/ship/appimage.js +2 -1
  21. package/lib/utils/ship/deb.d.ts +10 -0
  22. package/lib/utils/ship/deb.js +7 -0
  23. package/lib/utils/ship/depends.d.ts +37 -3
  24. package/lib/utils/ship/depends.js +21 -9
  25. package/lib/utils/ship/flatpak-config.d.ts +16 -0
  26. package/lib/utils/ship/flatpak-config.js +26 -0
  27. package/lib/utils/ship/formats.js +10 -7
  28. package/lib/utils/ship/gi-namespaces.d.ts +16 -0
  29. package/lib/utils/ship/gi-namespaces.js +64 -1
  30. package/lib/utils/ship/launcher.js +2 -2
  31. package/lib/utils/ship/layout.d.ts +7 -6
  32. package/lib/utils/ship/layout.js +42 -16
  33. package/lib/utils/ship/rpm.d.ts +5 -0
  34. package/lib/utils/ship/rpm.js +42 -0
  35. package/lib/utils/ship/settings.js +6 -0
  36. package/lib/utils/ship/signing.d.ts +7 -2
  37. package/lib/utils/ship/signing.js +59 -32
  38. package/lib/utils/ship/stage-manifest.d.ts +14 -0
  39. package/lib/utils/ship/stage-manifest.js +7 -0
  40. package/lib/utils/ship/types.d.ts +2 -0
  41. package/lib/utils/webext/targets.d.ts +1 -1
  42. package/lib/utils/webext/targets.js +20 -8
  43. package/package.json +20 -19
@@ -34,6 +34,18 @@
34
34
  // `walkModuleAst` answers both questions in ONE pass, and it is the same acorn
35
35
  // pass the CLI already uses to compute its own runtime closure — so there is one
36
36
  // definition of "what does this file import" rather than two.
37
+ //
38
+ // HARD OR OPTIONAL, since ADR 0087: a `gi://Ns?version=X&optional` import still
39
+ // appears in the emitted file, so this scanner still reports its namespace — and
40
+ // `depends.ts` has to know it may be absent. The discriminator is the marker
41
+ // statement the bundler's own optional shim emits (`GI_OPTIONAL_MARKER`, imported
42
+ // from the plugin that writes it rather than restated here). NOT the `import()`
43
+ // SHAPE, which was the tempting reading of the same file: an app that writes its own
44
+ // `await import('gi://Goa')` without the flag must stay a HARD dependency, and
45
+ // classifying it as optional would move a package the host must have out of its
46
+ // `Depends:` — the failure this whole chain exists to prevent, reached from the other
47
+ // direction.
48
+ import { GI_OPTIONAL_MARKER } from '@gjsify/rolldown-plugin-gjsify';
37
49
  import { importedSpecifier, staticStringValue, walkModuleAst, } from '../cli-runtime-closure.js';
38
50
  /** The module a `--app node` bundle reaches GI through. */
39
51
  const NODE_GI_MODULE = '@gjsify/node-gi/gi';
@@ -44,7 +56,12 @@ const NAMESPACE = /^[A-Za-z][A-Za-z\d_]*$/;
44
56
  * specifier pins one and bare `Ns` when it does not.
45
57
  */
46
58
  export function scanGiNamespaces(source) {
59
+ return scanGiRequirements(source).namespaces;
60
+ }
61
+ /** {@link scanGiNamespaces}, plus which of them the artifact marks optional (ADR 0087). */
62
+ export function scanGiRequirements(source) {
47
63
  const found = new Set();
64
+ const optional = new Set();
48
65
  // TWO SETS, because the two kinds of binding are not interchangeable and
49
66
  // treating them as one over-approximated on a real minified bundle. A
50
67
  // namespace object (`import * as gi`) and a required module object are only
@@ -89,11 +106,57 @@ export function scanGiNamespaces(source) {
89
106
  calls.push(node);
90
107
  });
91
108
  for (const call of calls) {
109
+ const marker = optionalMarkerNamespace(call);
110
+ if (marker !== null) {
111
+ optional.add(marker);
112
+ continue;
113
+ }
92
114
  const key = requireGiNamespace(call, callable, objects);
93
115
  if (key !== null)
94
116
  found.add(key);
95
117
  }
96
- return [...found].sort();
118
+ return { namespaces: [...found].sort(), optional: [...optional].filter((ns) => found.has(ns)).sort() };
119
+ }
120
+ /**
121
+ * The namespace an optional-marker call names, or `null` when this is not one.
122
+ *
123
+ * The shape is the bundler's: `globalThis[Symbol.for(GI_OPTIONAL_MARKER)]?.("Ns", "X")`,
124
+ * matched on BOTH halves rather than on the whole statement — the minifier rewrites
125
+ * the string literals to backticks and the optional call to a `ChainExpression`
126
+ * around the `CallExpression` this walk still reaches, and a plain call (a host that
127
+ * has a handler the minifier could prove is non-null) reads the same way.
128
+ */
129
+ function optionalMarkerNamespace(call) {
130
+ const callee = call.callee;
131
+ if (callee?.type !== 'MemberExpression' || callee.computed !== true)
132
+ return null;
133
+ // `globalThis[Symbol.for(KEY)]` — a computed member whose PROPERTY is the
134
+ // `Symbol.for` call, so the key is an argument of that inner call rather than
135
+ // of the outer one. Verified against acorn's own shape: `optional` chaining
136
+ // wraps the whole thing in a `ChainExpression`, which the walk descends into,
137
+ // so the `CallExpression` reaches this function either way.
138
+ const keyCall = callee.property;
139
+ if (keyCall?.type !== 'CallExpression')
140
+ return null;
141
+ const keyCallee = keyCall.callee;
142
+ const keyObject = keyCallee?.type === 'MemberExpression' ? keyCallee.object : undefined;
143
+ const keyProperty = keyCallee?.type === 'MemberExpression' ? keyCallee.property : undefined;
144
+ if (keyObject?.type !== 'Identifier' || keyObject.name !== 'Symbol')
145
+ return null;
146
+ if (keyProperty?.type !== 'Identifier' || keyProperty.name !== 'for')
147
+ return null;
148
+ const keyArgs = keyCall.arguments ?? [];
149
+ if (staticStringValue(keyArgs[0]) !== GI_OPTIONAL_MARKER)
150
+ return null;
151
+ const host = callee.object;
152
+ if (host?.type !== 'Identifier' || host.name !== 'globalThis')
153
+ return null;
154
+ const args = call.arguments ?? [];
155
+ const namespace = staticStringValue(args[0]);
156
+ if (namespace === null || !NAMESPACE.test(namespace))
157
+ return null;
158
+ const version = staticStringValue(args[1]);
159
+ return version === null || version === '' ? namespace : `${namespace}-${version}`;
97
160
  }
98
161
  /** `gi://Gtk?version=4.0` → `Gtk-4.0`; `gi://Gtk` → `Gtk`; anything else → null. */
99
162
  export function parseGiSpecifier(specifier) {
@@ -171,7 +171,7 @@ function renderPrefixLauncher(settings, bundleRelPath, layout) {
171
171
  * IT EXECS WHAT THE BUNDLE CARRIES. `runtime` comes from
172
172
  * `utils/ship/app-runtime.ts` and names the interpreter, the relocated GTK
173
173
  * closure and the node-gi addon staged inside `Contents/` — so the exec line is
174
- * `"$here/node"` and the two locators below point into `Contents/Frameworks`.
174
+ * `"$here/node"` and the two locators below point into `dirs.native`.
175
175
  * When the stage carries none of them every line here is byte-identical to what
176
176
  * M2a wrote, which is what keeps a bundle assembled without the runtime packages
177
177
  * exactly as (un)usable as it was rather than differently broken.
@@ -261,7 +261,7 @@ function renderAppBundleLauncher(settings, bundleRelPath, layout, runtime) {
261
261
  // `node_modules`, and a shipped `.app` has neither: its JavaScript is one
262
262
  // bundled file under `Contents/Resources/lib`, so `import.meta.url` there
263
263
  // resolves node-gi's "package root" to the bundle's directory and every probed
264
- // path lands beside the bundle instead of in `Contents/Frameworks`.
264
+ // path lands beside the bundle instead of in `dirs.native`.
265
265
  //
266
266
  // `NODE_GI_NATIVE` is the same problem for the addon: `prebuildAddonPath()`
267
267
  // joins `prebuilds/<target>/node_gi.node` onto that same wrong root
@@ -230,9 +230,10 @@ export interface Layout {
230
230
  *
231
231
  * * a REASON. `archName` answers "no Windows architecture is known for
232
232
  * `process.arch` \"arm64\"", which reads as a gap in our table. The truth is
233
- * that gvsbuild publishes no arm64 GTK, the blocker is upstream, and #1117
234
- * is where it is tracked — and the difference between "unsupported" and
235
- * "here is what would have to change" is the whole value of the message.
233
+ * a package this project has not built yet — ordered in
234
+ * `status/open-todos/windows.md` and tracked in #1117 — and the difference
235
+ * between "unsupported" and "here is what would have to change" is the whole
236
+ * value of the message.
236
237
  * * a STAGE-TIME warning at the flag that caused it (`commands/ship.ts`), for
237
238
  * a phase that deliberately does not refuse — `tests/e2e/ship-layout`
238
239
  * assembles all three layouts from ONE payload on purpose, and that
@@ -314,9 +315,9 @@ export declare function hostLayout(platform?: string): Layout;
314
315
  * AT STAGE TIME, which is the whole point — see {@link Layout.arches}. The
315
316
  * message carries the row's own `why` rather than a generic "unsupported", because
316
317
  * the two refusals a reader can act on are different jobs: `x64` is what a Windows
317
- * artifact is built for TODAY, and `win32-arm64` is blocked in a repository we do
318
- * not own. Telling an author "unknown architecture" for the second one would send
319
- * them to look for a flag.
318
+ * artifact is built for TODAY, and `win32-arm64` waits on a package of ours that
319
+ * does not exist yet. Telling an author "unknown architecture" for the second one
320
+ * would send them to look for a flag.
320
321
  *
321
322
  * `process.arch` spelling on both sides, so the value quoted back is the one the
322
323
  * user typed after `--arch`.
@@ -7,7 +7,8 @@
7
7
  // is the first layout that is NOT a prefix substitution: the launcher, the bundle,
8
8
  // the shared libraries and the data tree each move somewhere different, and on
9
9
  // macOS the native files leave the bundle directory altogether for
10
- // `Contents/Frameworks`.
10
+ // `Contents/Resources/native` — a path Apple's own layout does not have, for the
11
+ // measured reason {@link layoutForOs}'s darwin row carries.
11
12
  //
12
13
  // So the layout is a MAP from the prefix-relative plan to a stage-relative path,
13
14
  // and `planStage` keeps producing the prefix-relative plan for all three. That
@@ -168,12 +169,34 @@ export const LAYOUTS = {
168
169
  // everything that is not, and `Contents/Frameworks` the dylibs — which is
169
170
  // the split that makes this more than a prefix: the `gi/` directory that
170
171
  // sits INSIDE `lib/<name>/` on Linux leaves the bundle directory entirely.
172
+ //
173
+ // …AND THE NATIVE DIRECTORY IS NOT APPLE'S, because `Contents/Frameworks`
174
+ // is RESERVED FOR NESTED CODE and one directory of it is loadable code
175
+ // that cannot be there. Measured on darwin-arm64 / macOS 27 with the
176
+ // published `@gjsify/gtk-runtime-darwin-arm64` staged by M2b: `codesign`
177
+ // refuses the BUNDLE SEAL with *"bundle format unrecognized, invalid, or
178
+ // unsuitable / In subcomponent: …/gtk/lib/gstreamer-1.0"*, so
179
+ // `gjsify ship darwin --sign` cannot sign a real GTK application at all —
180
+ // and it does so at exit 1 with the payload half-signed. The trigger is not
181
+ // gstreamer: it is any directory under `Contents/Frameworks` whose last
182
+ // dot-separated component parses as a version, because `codesign` reads
183
+ // `foo-1.0`, `foo1.0` and `Versions/1.0` as a VERSIONED NESTED BUNDLE
184
+ // (`<name>-<version>` is a framework's shape) and then finds no
185
+ // `Contents/Info.plist` inside. The relocated closure carries three:
186
+ // `girepository-1.0`, `gdk-pixbuf-2.0/2.10.0` and `lib/gstreamer-1.0`.
187
+ //
188
+ // The same three under `Contents/Resources` seal and verify at exit 0, which
189
+ // is the whole measurement. So `native` is the ONE seam that moves: the
190
+ // relocated closure, the addon, the app's own `gi/` dylibs and both launchers'
191
+ // `GI_TYPELIB_PATH`/`GJSIFY_GI_LIBRARY_PATH` all read it, so the tree and
192
+ // every path that names it move together. Library validation is unaffected —
193
+ // it follows the SIGNATURE (§ `signing.ts`'s darwin row), not the directory.
171
194
  dirs: (identity) => {
172
195
  const app = appBundleDir(identity);
173
196
  return {
174
197
  launcher: `${app}/Contents/MacOS`,
175
198
  bundle: `${app}/Contents/Resources/lib`,
176
- native: `${app}/Contents/Frameworks`,
199
+ native: `${app}/Contents/Resources/native`,
177
200
  data: `${app}/Contents/Resources/share`,
178
201
  other: `${app}/Contents/Resources`,
179
202
  };
@@ -355,19 +378,22 @@ export const LAYOUTS = {
355
378
  ]
356
379
  : [];
357
380
  },
358
- // ONE, and the blocker is a project we do not own. `wingtk/gvsbuild`
359
- // hardcodes `self.platform = "x64"` in `utils/base_project.py` and its last
360
- // five releases publish exactly two assets each, both x64 — so there is no
361
- // arm64 GTK to build `@gjsify/gtk-runtime-win32-arm64` OUT OF, and on
362
- // Windows that bundle is the only GTK there is. `@gjsify/node-gi` declares
363
- // `win32-x64` only for the same reason. Tracked in #1117, which also
364
- // records why an exploratory arm64 leg must NOT be added: its first step
365
- // downloads a ZIP that does not exist, so it would be red by construction.
381
+ // ONE, and the blocker is OURS now. `wingtk/gvsbuild` hardcodes
382
+ // `self.platform = "x64"` and publishes x64 assets only — but we stopped
383
+ // waiting for it: ADR 0089 builds the arm64 GTK prefix from
384
+ // `gjsify/gvsbuild` in our own CI and `@gjsify/node-gi` declares
385
+ // `win32-arm64` against it (#2003). What is missing is
386
+ // `@gjsify/gtk-runtime-win32-arm64`, the closure this layout stages and on
387
+ // Windows the only GTK there is, so a Windows/ARM artifact still has
388
+ // nothing to load. Adding arm64 to `only` is the PR that builds that
389
+ // package, not this one.
366
390
  arches: {
367
391
  only: ['x64'],
368
- why: 'gvsbuild publishes no arm64 GTK (it hardcodes `self.platform = "x64"`), so there is nothing ' +
369
- 'to build `@gjsify/gtk-runtime-win32-arm64` out of and no GTK for a Windows/ARM artifact to ' +
370
- 'load — the blocker is upstream and is tracked in gjsify/gjsify#1117',
392
+ why: 'the arm64 GTK closure `@gjsify/gtk-runtime-win32-arm64` does not exist yet — the prefix it ' +
393
+ 'would be built from is no longer missing, because ADR 0089 builds it from our `gjsify/gvsbuild` ' +
394
+ 'fork in CI and `@gjsify/node-gi` ships a win32-arm64 prebuild against it — so what is left is ' +
395
+ 'the runtime package this layout would stage, ordered in `status/open-todos/windows.md` and ' +
396
+ 'tracked in gjsify/gjsify#1117',
371
397
  },
372
398
  },
373
399
  };
@@ -479,9 +505,9 @@ export function hostLayout(platform = process.platform) {
479
505
  * AT STAGE TIME, which is the whole point — see {@link Layout.arches}. The
480
506
  * message carries the row's own `why` rather than a generic "unsupported", because
481
507
  * the two refusals a reader can act on are different jobs: `x64` is what a Windows
482
- * artifact is built for TODAY, and `win32-arm64` is blocked in a repository we do
483
- * not own. Telling an author "unknown architecture" for the second one would send
484
- * them to look for a flag.
508
+ * artifact is built for TODAY, and `win32-arm64` waits on a package of ours that
509
+ * does not exist yet. Telling an author "unknown architecture" for the second one
510
+ * would send them to look for a flag.
485
511
  *
486
512
  * `process.arch` spelling on both sides, so the value quoted back is the one the
487
513
  * user typed after `--arch`.
@@ -5,6 +5,11 @@ export interface RpmInputs {
5
5
  payload: readonly PayloadEntry[];
6
6
  prefix: string;
7
7
  depends: readonly string[];
8
+ /**
9
+ * Soft dependencies, written as the `RECOMMEND*` tags — an `&optional` typelib
10
+ * (ADR 0087), which the package installs with and runs without.
11
+ */
12
+ recommends?: readonly string[];
8
13
  /** RPM architecture (`x86_64`, `noarch`, …). */
9
14
  archLabel: string;
10
15
  mtime: number;
@@ -54,6 +54,17 @@ const TAG = {
54
54
  REQUIREFLAGS: 1048,
55
55
  REQUIRENAME: 1049,
56
56
  REQUIREVERSION: 1050,
57
+ // The recommend trio. NOT the 1099/1046/1047 of rpm ≤ 4.18, which shared ONE
58
+ // index space with `REQUIRENAME`; since 4.19 each dependency tag carries its
59
+ // own arrays. MEASURED against a package `rpmbuild` produced and read back with
60
+ // `rpm` 6.0.2: RECOMMENDNAME is a STRING_ARRAY of its own right after
61
+ // PROVIDEVERSION, not an extension of the four REQUIRE entries. The numbers are
62
+ // in the 5000s for that reason and a wrong one is invisible here — rpm either
63
+ // ignores the tag or refuses the header, and `rpm -qp --recommends` is what
64
+ // says which (e2e `ship`).
65
+ RECOMMENDNAME: 5046,
66
+ RECOMMENDVERSION: 5047,
67
+ RECOMMENDFLAGS: 5048,
57
68
  RPMVERSION: 1064,
58
69
  POSTINPROG: 1086,
59
70
  POSTUNPROG: 1088,
@@ -225,6 +236,10 @@ function mainHeaderEntries(inputs, files, cpio, compressed) {
225
236
  // From the PAYLOAD, not the settings — see the same call in `deb.ts`.
226
237
  const scripts = renderRpmScriptlets(readPayloadFacts(inputs.payload), inputs.prefix);
227
238
  const requires = buildRequires(inputs.depends, scripts, readShebangInterpreters(inputs.payload));
239
+ // OMITTED, not written empty: rpm rejects a zero-count array (the same rule
240
+ // `encodeValue` enforces), so a package with no `&optional` namespace carries
241
+ // no RECOMMEND tag at all.
242
+ const recommends = buildRecommends(inputs.recommends ?? []);
228
243
  const entries = [
229
244
  { tag: TAG.HEADERI18NTABLE, type: RpmType.STRING_ARRAY, value: ['C'] },
230
245
  { tag: TAG.NAME, type: RpmType.STRING, value: settings.binaryName },
@@ -277,6 +292,13 @@ function mainHeaderEntries(inputs, files, cpio, compressed) {
277
292
  { tag: TAG.REQUIRENAME, type: RpmType.STRING_ARRAY, value: requires.names },
278
293
  { tag: TAG.REQUIREFLAGS, type: RpmType.INT32, value: requires.flags },
279
294
  { tag: TAG.REQUIREVERSION, type: RpmType.STRING_ARRAY, value: requires.versions },
295
+ ...(recommends.names.length > 0
296
+ ? [
297
+ { tag: TAG.RECOMMENDNAME, type: RpmType.STRING_ARRAY, value: recommends.names },
298
+ { tag: TAG.RECOMMENDFLAGS, type: RpmType.INT32, value: recommends.flags },
299
+ { tag: TAG.RECOMMENDVERSION, type: RpmType.STRING_ARRAY, value: recommends.versions },
300
+ ]
301
+ : []),
280
302
  { tag: TAG.PAYLOADFORMAT, type: RpmType.STRING, value: 'cpio' },
281
303
  { tag: TAG.PAYLOADCOMPRESSOR, type: RpmType.STRING, value: 'gzip' },
282
304
  // Write-side metadata only — no reader acts on it (rpm-format.md § 6.5).
@@ -383,6 +405,26 @@ function buildRequires(depends, scripts, interpreters) {
383
405
  }
384
406
  return { names, flags, versions };
385
407
  }
408
+ /**
409
+ * The three parallel `Recommends` arrays — the soft half of
410
+ * {@link buildRequires}, same relation spelling, no `RPMLIB` and no sense bits.
411
+ *
412
+ * `0` is the right flags value and not a missing one: rpm reads an unversioned
413
+ * recommendation as "any version", which is what an `&optional` typelib package
414
+ * means (the app asks for the NAMESPACE, never for a version of the package).
415
+ */
416
+ function buildRecommends(recommends) {
417
+ const names = [];
418
+ const flags = [];
419
+ const versions = [];
420
+ for (const depend of recommends) {
421
+ const parsed = parseDepend(depend);
422
+ names.push(parsed.name);
423
+ flags.push(senseFor(parsed.relation));
424
+ versions.push(parsed.version ?? '');
425
+ }
426
+ return { names, flags, versions };
427
+ }
386
428
  function senseFor(relation) {
387
429
  switch (relation) {
388
430
  case '>=':
@@ -166,6 +166,12 @@ export function resolveShipSettings(input) {
166
166
  throw new Error('gjsify ship: no licence — set `license` in package.json (an SPDX id, e.g. "MIT") ' +
167
167
  'or `gjsify.ship.license.project`. Both deb and rpm carry it as a required field.');
168
168
  }
169
+ // Written BACK, not read twice: the packers read `settings.license` and the AppStream
170
+ // renderers read `metadata.license.project`, so resolving the `package.json#license`
171
+ // fallback into the first alone left `<project_license>` empty in the staged metainfo
172
+ // and warned about a field the project had answered. The returned metadata is what every
173
+ // renderer reads, so this is where the one answer has to land.
174
+ metadata.license = { ...metadata.license, project: license };
169
175
  const maintainer = ship.maintainer ?? formatAuthor(pkg.author) ?? formatDeveloper(metadata);
170
176
  if (!maintainer) {
171
177
  throw new Error('gjsify ship: no maintainer — set `author` in package.json ("Name <you@example.com>"), ' +
@@ -30,14 +30,19 @@ export interface SignerDescriptor {
30
30
  /** argv after {@link tool}, for ONE file. */
31
31
  args: (input: SignArgs) => readonly string[];
32
32
  /**
33
- * The entitlements this signer asks for, or `[]` where the OS has none.
33
+ * The entitlements this signer asks for, given the identity in hand.
34
+ *
35
+ * A FUNCTION, not a list, and the darwin row's second argument is what makes
36
+ * that necessary: the set depends on whether the identity is a real one, and
37
+ * one entitlement is exactly wrong in one of the two directions. It is a
38
+ * measured difference, not a preference — see {@link SIGNERS}.darwin.
34
39
  *
35
40
  * `[]` is an ANSWER and not an omission — the same rule `Layout.metadata`
36
41
  * states. Windows has no equivalent concept at all, and a signer that granted
37
42
  * nothing on macOS would be a different decision from one that has nothing to
38
43
  * grant.
39
44
  */
40
- entitlements: readonly string[];
45
+ entitlements: (adhoc: boolean) => readonly string[];
41
46
  /**
42
47
  * Does this signer seal the ARTIFACT ROOT after signing the images inside it?
43
48
  *
@@ -18,7 +18,10 @@
18
18
  // an ad-hoc `LC_CODE_SIGNATURE` already (they must — `install_name_tool`
19
19
  // invalidates the original during relocation). So the darwin leg re-signs
20
20
  // every image, and the result is new BYTES for the packer rather than a
21
- // container around the old ones.
21
+ // container around the old ones. § A16's corollary of this — "the same
22
+ // identity everywhere, so library validation has nothing to object to" — is
23
+ // measured FALSE for the ad-hoc identity, and the darwin row's
24
+ // `entitlements` is where that correction lives.
22
25
  // 4. THE ORDER IS STRUCTURAL, not conventional (§ A17). `readStage` compares
23
26
  // each file's SIZE against `.gjsify-ship-stage.json`, and a size is no more
24
27
  // re-sign-proof than a digest. BOTH halves are measured and they agree:
@@ -66,10 +69,10 @@
66
69
  // `.dmg` is stapled and the `.zip` is told what a user has to do instead.
67
70
  //
68
71
  // WHAT IS STILL NOT DONE, deliberately: `signtool` is never given a timestamp URL
69
- // (see the win32 row), and the entitlement § A16 is about —
70
- // `com.apple.security.cs.disable-library-validation` — is NOT granted, because
71
- // § A4's re-sign of every image in the closure is the design of record and
72
- // granting the entitlement would quietly make it look unnecessary.
72
+ // (see the win32 row), and the named-identity half of § A16 is untested because it
73
+ // needs a certificate — the GRANT of `disable-library-validation` is the measured
74
+ // ad-hoc answer, and § A4's re-sign of every image stays the design of record for
75
+ // the Developer ID that notarisation will actually require.
73
76
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
74
77
  import { dirname, join, sep } from 'node:path';
75
78
  import { isOnPath } from '../check-system-deps.js';
@@ -123,32 +126,52 @@ export const SIGNERS = {
123
126
  ...(adhoc ? [] : ['--timestamp']),
124
127
  file,
125
128
  ],
126
- // FOUR OF THE REFERENCE'S SIX, and both omissions are decisions:
129
+ // FIVE OF THE REFERENCE'S SIX for an ad-hoc identity, FOUR for a real one,
130
+ // and the difference is the one § A16 left open — now MEASURED, on
131
+ // darwin-arm64 with the published `@gjsify/gtk-runtime-darwin-arm64` staged
132
+ // into the bundle (`utils/ship/layout.ts` carries the rest of that run).
127
133
  //
128
- // * `com.apple.security.cs.get-task-allow` is granted by
129
- // `refs/node/tools/osx-entitlements.plist:15` and is a DEBUGGING
130
- // entitlement — it lets another process attach to this one. Apple's
131
- // notarisation rules refuse a Developer-ID artifact carrying it, so
132
- // shipping it would trade a working debug build for an artifact that
133
- // cannot be distributed. The reference signs its own build; we sign what
134
- // a stranger downloads.
135
- // * `com.apple.security.cs.disable-library-validation` is § A16's open
136
- // question and stays open. § A4's design of record is that every Mach-O
137
- // in the closure is re-signed with the SAME identity as the launcher, so
138
- // library validation has nothing to object to — and granting the
139
- // entitlement anyway would make that re-sign look optional the first
140
- // time somebody reads this list.
134
+ // § A16's premise was that library validation has nothing to object to
135
+ // because every Mach-O carries the SAME identity as the launcher. That is
136
+ // true for a Developer ID and FALSE for `--sign -`: an ad-hoc signature has
137
+ // no team at all, so the running process and the dylib it dlopens report
138
+ // different team IDs and the load is refused —
141
139
  //
142
- // The four that remain are what a shipped V8 needs under a hardened
143
- // runtime, plus the one our own loader story needs:
144
- // `@gjsify/node-gi`'s `maybeReexecForGtkRuntime` sets `DYLD_*` for its
145
- // child on darwin, and a hardened process may not do that without
146
- // `allow-dyld-environment-variables`.
147
- entitlements: [
140
+ // dlopen(…/prebuilds/darwin-arm64/node_gi.node): code signature in
141
+ // <378B2FDA-…> '…node_gi.node' not valid for use in process: mapping
142
+ // process and mapped file (non-platform) have different Team IDs
143
+ //
144
+ // — at `require('@gjsify/node-gi')`, the FIRST thing a shipped `.app` does,
145
+ // with every image correctly signed. Same bundle, same signer, same argv,
146
+ // one more entitlement: `interpreter:`/`gtk-runtime:`/`chrome: ok`/
147
+ // `render: 480x320`/`done: 0`.
148
+ //
149
+ // So it is granted WHERE IT IS NEEDED, which is ad-hoc only. A Developer ID
150
+ // signs the whole closure with one team and needs nothing, and Apple's
151
+ // notarisation rules read a `disable-library-validation` entitlement on a
152
+ // Developer-ID artifact as shipping unsigned code — so granting it there
153
+ // would trade a distributable artifact for a locally runnable one. The
154
+ // GRANT therefore stays what § A16 asked to know: § A4's re-sign of every
155
+ // image is the design of record, and the entitlement is what makes the
156
+ // ad-hoc proof of it RUNNABLE rather than a signature `codesign` accepts and
157
+ // dyld refuses.
158
+ //
159
+ // The four both share are what a shipped V8 needs under a hardened runtime,
160
+ // plus the one our own loader story needs: `@gjsify/node-gi`'s
161
+ // `maybeReexecForGtkRuntime` sets `DYLD_*` for its child on darwin, and a
162
+ // hardened process may not do that without `allow-dyld-environment-variables`.
163
+ //
164
+ // `get-task-allow` is absent from both, and that is a decision:
165
+ // `refs/node/tools/osx-entitlements.plist:15` grants it, it is a DEBUGGING
166
+ // entitlement — it lets another process attach to this one — and Apple's
167
+ // notarisation rules refuse a Developer-ID artifact carrying it. The
168
+ // reference signs its own build; we sign what a stranger downloads.
169
+ entitlements: (adhoc) => [
148
170
  'com.apple.security.cs.allow-jit',
149
171
  'com.apple.security.cs.allow-unsigned-executable-memory',
150
172
  'com.apple.security.cs.disable-executable-page-protection',
151
173
  'com.apple.security.cs.allow-dyld-environment-variables',
174
+ ...(adhoc ? ['com.apple.security.cs.disable-library-validation'] : []),
152
175
  ],
153
176
  // The `.app` is a bundle, and a bundle's signature is the seal over its
154
177
  // whole tree — the images alone leave `codesign --verify` with nothing to
@@ -156,9 +179,10 @@ export const SIGNERS = {
156
179
  sealsBundle: true,
157
180
  sealAddPrefix: (bundleRoot) => `${bundleRoot}/Contents/_CodeSignature/`,
158
181
  configKey: 'gjsify.ship.sign.darwin.identity',
159
- gap: 'the images are signed and the bundle is sealed; UNVERIFIED end to end — no macOS host in this ' +
160
- 'repository has run a `--sign` over a real `.app`, and ADR 0024 § A16 still leaves library ' +
161
- 'validation unmeasured',
182
+ gap: 'the images are signed and the bundle is sealed, and the seal survives both the `.zip` and the ' +
183
+ '`.dmg` round trip; § A16 stands measured in ONE direction only — library validation refuses an ' +
184
+ 'ad-hoc-signed dylib, so the ad-hoc identity grants `disable-library-validation` while a named ' +
185
+ 'one does not, and no run in this repository has ever held the named one',
162
186
  },
163
187
  win32: {
164
188
  layoutOs: 'win32',
@@ -180,8 +204,10 @@ export const SIGNERS = {
180
204
  // Windows has no equivalent concept. An Authenticode signature carries no
181
205
  // capability claims — what a hardened runtime and an entitlement do on
182
206
  // macOS is done on Windows by the manifest and by AppContainer, neither of
183
- // which this signer touches.
184
- entitlements: [],
207
+ // which this signer touches. `() => []` rather than `[]` because the field is
208
+ // a function of the identity (see the darwin row), and this one ignores the
209
+ // argument instead of reading `adhoc` out of it.
210
+ entitlements: () => [],
185
211
  // A Windows program directory is a DIRECTORY and nothing more: there is no
186
212
  // manifest to seal and no per-directory signature format. `signtool` signs
187
213
  // images, one at a time, which is what the loop above already does.
@@ -303,9 +329,10 @@ export async function signPayload(input) {
303
329
  writePayload(workDir, payload, '');
304
330
  const adhoc = input.identity === ADHOC_IDENTITY;
305
331
  let entitlements;
306
- if (signer.entitlements.length > 0) {
332
+ const granted = signer.entitlements(adhoc);
333
+ if (granted.length > 0) {
307
334
  mkdirSync(dirname(input.entitlementsPath), { recursive: true });
308
- writeFileSync(input.entitlementsPath, renderEntitlements(signer.entitlements));
335
+ writeFileSync(input.entitlementsPath, renderEntitlements(granted));
309
336
  entitlements = input.entitlementsPath;
310
337
  }
311
338
  const run = async (file, what) => {
@@ -102,6 +102,18 @@ export interface StageManifest {
102
102
  mtime: number;
103
103
  /** GI namespaces the bundle imports, `Ns-Version` where the specifier pins one. */
104
104
  namespaces: string[];
105
+ /**
106
+ * The subset of {@link namespaces} the BUNDLE marks optional (ADR 0087).
107
+ *
108
+ * OPTIONAL on read, deliberately, and that is the {STAGE_SCHEMA_VERSION} header's
109
+ * "a field an older reader would simply ignore" case: a stage written before the
110
+ * split has no such key, and answering "nothing is optional" makes every
111
+ * namespace a `Depends:` again — today's behaviour, and the SAFE direction. A
112
+ * newer reader packing an OLDER stage therefore over-declares instead of dropping
113
+ * a requirement the artifact cannot run without, which is the one error here that
114
+ * ships a package that dies at first launch.
115
+ */
116
+ optionalNamespaces?: string[];
105
117
  settings: PackSettings;
106
118
  staged: StageFileRecord[];
107
119
  overlay: Partial<Record<FormatId, StageOverlayFile[]>>;
@@ -113,6 +125,8 @@ export interface StageManifestInput {
113
125
  formats: readonly FormatDescriptor[];
114
126
  mtime: number;
115
127
  namespaces: readonly string[];
128
+ /** The optional subset of {@link namespaces} — see {@link StageManifest}. */
129
+ optionalNamespaces?: readonly string[];
116
130
  staged: readonly StagedFile[];
117
131
  /** Per-format overlay, as `planOverlay` produced it. */
118
132
  overlay: ReadonlyMap<FormatId, readonly StagedFile[]>;
@@ -187,6 +187,7 @@ export function writeStageManifest(input) {
187
187
  formats: input.formats.map((format) => format.id),
188
188
  mtime: input.mtime,
189
189
  namespaces: [...input.namespaces],
190
+ optionalNamespaces: [...(input.optionalNamespaces ?? [])],
190
191
  settings: toPackSettings(input.settings),
191
192
  staged: input.staged.map((file) => ({
192
193
  path: file.path,
@@ -276,6 +277,12 @@ export function readStageManifest(stageDir) {
276
277
  }),
277
278
  mtime: expectInteger(data.mtime, at('mtime')),
278
279
  namespaces: expectArray(data.namespaces, at('namespaces')).map((entry, index) => expectString(entry, at(`namespaces[${index}]`))),
280
+ // Absent is the empty set, not an error — see the field's doc comment.
281
+ ...(data.optionalNamespaces === undefined
282
+ ? {}
283
+ : {
284
+ optionalNamespaces: expectArray(data.optionalNamespaces, at('optionalNamespaces')).map((entry, index) => expectString(entry, at(`optionalNamespaces[${index}]`))),
285
+ }),
279
286
  settings: readPackSettings(record(data.settings, at('settings')), at('settings')),
280
287
  staged: expectArray(data.staged, at('staged')).map((entry, index) => {
281
288
  const file = record(entry, at(`staged[${index}]`));
@@ -423,6 +423,8 @@ export interface PackSettings {
423
423
  * model back in the tree — which is the one thing § 8 gates the whole migration
424
424
  * on. A project that genuinely needs to BUILD something inside the sandbox
425
425
  * still has `gjsify flatpak init` + `gjsify flatpak build`, unchanged.
426
+ * A project that sets those keys and ships `flatpak` anyway is refused up front
427
+ * (`assertNoDroppedFlatpakModules`), never silently stripped of them.
426
428
  */
427
429
  export interface ShipFlatpakSettings {
428
430
  /** Runtime id, e.g. `org.gnome.Platform`. */
@@ -1,4 +1,4 @@
1
- export type WebextBrowser = 'chrome' | 'edge' | 'firefox' | 'safari';
1
+ export type WebextBrowser = 'chrome' | 'edge' | 'firefox' | 'opera' | 'safari';
2
2
  export interface WebextTarget {
3
3
  /** As spelled in `gjsify.webext.targets` and the output directory name. */
4
4
  readonly id: string;
@@ -4,14 +4,22 @@
4
4
  // `@gjsify/manifest-conformance` (plain `.mjs`, no import from here), so a
5
5
  // target added on one side and not the other is refused by the rule before a
6
6
  // build ever sees it.
7
- export const WEBEXT_BROWSERS = ['chrome', 'edge', 'firefox', 'safari'];
7
+ export const WEBEXT_BROWSERS = ['chrome', 'edge', 'firefox', 'opera', 'safari'];
8
8
  /** What a project gets when it declares no `targets`. */
9
9
  export const DEFAULT_WEBEXT_TARGETS = ['chrome-mv3', 'firefox-mv3'];
10
10
  /**
11
- * Chromium-based browsers no longer load Manifest V2 at all, so a `chrome-mv2`
12
- * folder would build, zip, and then be rejected by the browser and the store.
11
+ * Why a browser takes no Manifest V2 folder, absent where it still does. Chrome
12
+ * and Edge removed MV2 in the browser, so the folder would build, zip, and then
13
+ * be refused on load. Opera still LOADS an MV2 extension but its store is
14
+ * MV3-only (Opera, 2025-09), so the folder is refused a step later instead. The
15
+ * outcome is the same either way, which is why the sentence is per browser and
16
+ * not one claim the three do not all support.
13
17
  */
14
- const MV2_REMOVED = new Set(['chrome', 'edge']);
18
+ const MV2_UNSUPPORTED = new Map([
19
+ ['chrome', 'chrome has removed Manifest V2'],
20
+ ['edge', 'edge has removed Manifest V2'],
21
+ ['opera', "opera's extension store accepts Manifest V3 only"],
22
+ ]);
15
23
  export function parseWebextTarget(id) {
16
24
  const match = /^([a-z]+)-mv([23])$/.exec(id);
17
25
  const browser = match?.[1];
@@ -20,9 +28,9 @@ export function parseWebextTarget(id) {
20
28
  `${WEBEXT_BROWSERS.join(', ')} (e.g. chrome-mv3, firefox-mv2).`);
21
29
  }
22
30
  const manifestVersion = match[2] === '2' ? 2 : 3;
23
- if (manifestVersion === 2 && MV2_REMOVED.has(browser)) {
24
- throw new Error(`gjsify webext: target "${id}" does not exist any more: ${browser} has removed Manifest V2. ` +
25
- `Use ${browser}-mv3.`);
31
+ const why = manifestVersion === 2 ? MV2_UNSUPPORTED.get(browser) : undefined;
32
+ if (why !== undefined) {
33
+ throw new Error(`gjsify webext: target "${id}" does not exist any more: ${why}. Use ${browser}-mv3.`);
26
34
  }
27
35
  return { id, browser, manifestVersion };
28
36
  }
@@ -30,7 +38,11 @@ export function parseWebextTarget(id) {
30
38
  export function webExtRunTarget(target) {
31
39
  if (target.browser === 'firefox')
32
40
  return 'firefox-desktop';
33
- if (target.browser === 'chrome' || target.browser === 'edge')
41
+ // By ENGINE, not by brand: the chromium driver's only browser-specific input is the
42
+ // binary, and `--browser-binary` already carries it — the claim branded Chrome and
43
+ // Edge already make. Refusing opera here would be a claim about the operator's
44
+ // binary, which is not what this switch is about.
45
+ if (target.browser === 'chrome' || target.browser === 'edge' || target.browser === 'opera')
34
46
  return 'chromium';
35
47
  return null;
36
48
  }