@gjsify/cli 0.64.12 → 0.64.13

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.
@@ -74,13 +74,57 @@
74
74
  // ad-hoc answer, and § A4's re-sign of every image stays the design of record for
75
75
  // the Developer ID that notarisation will actually require.
76
76
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
77
- import { dirname, join, sep } from 'node:path';
77
+ import { basename, dirname, join, sep } from 'node:path';
78
78
  import { isOnPath } from '../check-system-deps.js';
79
79
  import { describeExit, spawnToCompletion } from '../spawn.js';
80
80
  import { listFilesRecursive } from './discover.js';
81
81
  import { classifyBinary } from './payload.js';
82
82
  import { renderEntitlements } from './plist.js';
83
83
  import { writePayload } from './stage-writer.js';
84
+ /**
85
+ * An explicit `--identifier` for an image whose FILE NAME is not ASCII, or
86
+ * `undefined` when codesign's own default is already right.
87
+ *
88
+ * WHY THIS EXISTS, measured on macOS 2026-10-30 (steuererklaerung CI run
89
+ * 41050980433, plus mini bundles beside it): the darwin layout puts the
90
+ * interpreter at `Contents/Helpers/<display name>`, so an app called
91
+ * "Steuererklärung" carries a non-ASCII leaf. codesign derives an image's code
92
+ * identifier from that file name; with a NAMED identity the designated
93
+ * requirement becomes `identifier "<derived>" and certificate leaf …`, and
94
+ * `codesign --verify --strict <App>.app` then fails with *"the sealed resource
95
+ * directory is invalid"* for the NFD-derived identifier (`…61cc88…`); why
96
+ * codesign rejects it is not established, only that it does. Ad-hoc (a cdhash
97
+ * requirement, no identifier at all) and ASCII names are unaffected; the same
98
+ * bundle with `--identifier eu.test.x.helper` verifies, and an NFC identifier
99
+ * still does not, so the value has to be plain ASCII rather than merely
100
+ * normalised.
101
+ *
102
+ * Hence: an ASCII leaf keeps codesign's default (this returns `undefined` and
103
+ * the flag is not sent, so every name that already worked is unchanged), and
104
+ * only a non-ASCII leaf gets a stable ASCII identifier. NFD first, so a
105
+ * precomposed "ä" and a decomposed "a"+U+0308 fold to the same value instead of
106
+ * to two identifiers that differ by bytes nobody can see.
107
+ */
108
+ export function asciiCodesignIdentifier(fileName) {
109
+ // `fileName` may be a path, and codesign derives the identifier from the
110
+ // LEAF — so both the ASCII test and the fallback bytes are about the leaf.
111
+ const leaf = basename(fileName);
112
+ if ([...leaf].every((char) => (char.codePointAt(0) ?? 0) < 0x80))
113
+ return undefined;
114
+ const slug = leaf
115
+ .normalize('NFD')
116
+ .replace(/\p{M}+/gu, '')
117
+ .replace(/[^A-Za-z0-9.-]+/g, '-')
118
+ .replace(/^-+|-+$/g, '');
119
+ if (/[A-Za-z0-9]/.test(slug))
120
+ return slug;
121
+ // Nothing Latin survives (a name that is entirely CJK or emoji): a readable
122
+ // slug would be the empty string, which is not an identifier, so the UTF-8
123
+ // bytes are spelled out — ASCII, unique, and reproducible from the name it
124
+ // came from.
125
+ const bytes = new TextEncoder().encode(leaf);
126
+ return `x-${[...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`;
127
+ }
84
128
  /**
85
129
  * The signers, keyed by the LAYOUT they sign — never by the host they run on.
86
130
  *
@@ -116,16 +160,25 @@ export const SIGNERS = {
116
160
  // the timestamp authority service cannot be contacted … the signing
117
161
  // operation will fail", which is not a failure worth inventing for a
118
162
  // signature that cannot carry the result.
119
- args: ({ identity, file, entitlements, adhoc }) => [
120
- '--force',
121
- '--sign',
122
- identity,
123
- '--options',
124
- 'runtime',
125
- ...(entitlements === undefined ? [] : ['--entitlements', entitlements]),
126
- ...(adhoc ? [] : ['--timestamp']),
127
- file,
128
- ],
163
+ args: ({ identity, file, entitlements, adhoc, seal }) => {
164
+ // A non-ASCII leaf gets an explicit ASCII identifier — see
165
+ // {@link asciiCodesignIdentifier}. NEVER for the seal: the bundle
166
+ // takes its identifier from `CFBundleIdentifier` in `Info.plist`,
167
+ // and overriding that would make the seal disagree with the bundle
168
+ // the system reads.
169
+ const derived = seal ? undefined : asciiCodesignIdentifier(file);
170
+ return [
171
+ '--force',
172
+ '--sign',
173
+ identity,
174
+ '--options',
175
+ 'runtime',
176
+ ...(entitlements === undefined ? [] : ['--entitlements', entitlements]),
177
+ ...(adhoc ? [] : ['--timestamp']),
178
+ ...(derived === undefined ? [] : ['--identifier', derived]),
179
+ file,
180
+ ];
181
+ },
129
182
  // FIVE OF THE REFERENCE'S SIX for an ad-hoc identity, FOUR for a real one,
130
183
  // and the difference is the one § A16 left open — now MEASURED, on
131
184
  // darwin-arm64 with the published `@gjsify/gtk-runtime-darwin-arm64` staged
@@ -182,7 +235,9 @@ export const SIGNERS = {
182
235
  gap: 'the images are signed and the bundle is sealed, and the seal survives both the `.zip` and the ' +
183
236
  '`.dmg` round trip; § A16 stands measured in ONE direction only — library validation refuses an ' +
184
237
  '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',
238
+ 'one does not; a named Developer ID was signed and notarised on 2026-10-30 for an ASCII-named ' +
239
+ 'app, and a non-ASCII image name verifies only with the explicit ASCII `--identifier` this ' +
240
+ 'signer now passes',
186
241
  },
187
242
  win32: {
188
243
  layoutOs: 'win32',
@@ -335,8 +390,14 @@ export async function signPayload(input) {
335
390
  writeFileSync(input.entitlementsPath, renderEntitlements(granted));
336
391
  entitlements = input.entitlementsPath;
337
392
  }
338
- const run = async (file, what) => {
339
- const args = signer.args({ identity: input.identity, file, adhoc, ...(entitlements ? { entitlements } : {}) });
393
+ const run = async (file, what, seal) => {
394
+ const args = signer.args({
395
+ identity: input.identity,
396
+ file,
397
+ adhoc,
398
+ seal,
399
+ ...(entitlements ? { entitlements } : {}),
400
+ });
340
401
  if (input.verbose)
341
402
  input.log(`${signer.tool} ${args.join(' ')}`);
342
403
  const result = await spawnToCompletion(signer.tool, args, {
@@ -367,7 +428,7 @@ export async function signPayload(input) {
367
428
  return [...payload];
368
429
  }
369
430
  for (const entry of signable) {
370
- await run(join(workDir, entry.path.split('/').join(sep)), entry.path);
431
+ await run(join(workDir, entry.path.split('/').join(sep)), entry.path, false);
371
432
  }
372
433
  // THE IMAGES FIRST, THE SEAL LAST, and the order is not stylistic: a bundle
373
434
  // seal hashes the files it seals, so sealing before the closure is signed
@@ -381,7 +442,7 @@ export async function signPayload(input) {
381
442
  'stage would sign the payload of every other format beside it.');
382
443
  }
383
444
  sealPrefix = signer.sealAddPrefix(input.bundleRoot);
384
- await run(join(workDir, input.bundleRoot.split('/').join(sep)), `${input.bundleRoot} (bundle seal)`);
445
+ await run(join(workDir, input.bundleRoot.split('/').join(sep)), `${input.bundleRoot} (bundle seal)`, true);
385
446
  }
386
447
  // THE FILE SET MAY ONLY GROW WHERE THE SEAL WRITES. Both tools rewrite an
387
448
  // image in place, so any other new path means something else ran — and a file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.64.12",
3
+ "version": "0.64.13",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -142,22 +142,22 @@
142
142
  "cli"
143
143
  ],
144
144
  "dependencies": {
145
- "@gjsify/blueprint": "^0.64.12",
146
- "@gjsify/buffer": "^0.64.12",
147
- "@gjsify/create-app": "^0.64.12",
148
- "@gjsify/node-globals": "^0.64.12",
149
- "@gjsify/node-polyfills": "^0.64.12",
150
- "@gjsify/npm-registry": "^0.64.12",
151
- "@gjsify/resolve-npm": "^0.64.12",
152
- "@gjsify/rolldown-plugin-gjsify": "^0.64.12",
153
- "@gjsify/vite-plugin-blueprint": "^0.64.12",
154
- "@gjsify/rolldown-plugin-pnp": "^0.64.12",
155
- "@gjsify/semver": "^0.64.12",
156
- "@gjsify/tar": "^0.64.12",
157
- "@gjsify/tsc": "^0.64.12",
158
- "@gjsify/utils": "^0.64.12",
159
- "@gjsify/web-polyfills": "^0.64.12",
160
- "@gjsify/workspace": "^0.64.12",
145
+ "@gjsify/blueprint": "^0.64.13",
146
+ "@gjsify/buffer": "^0.64.13",
147
+ "@gjsify/create-app": "^0.64.13",
148
+ "@gjsify/node-globals": "^0.64.13",
149
+ "@gjsify/node-polyfills": "^0.64.13",
150
+ "@gjsify/npm-registry": "^0.64.13",
151
+ "@gjsify/resolve-npm": "^0.64.13",
152
+ "@gjsify/rolldown-plugin-gjsify": "^0.64.13",
153
+ "@gjsify/vite-plugin-blueprint": "^0.64.13",
154
+ "@gjsify/rolldown-plugin-pnp": "^0.64.13",
155
+ "@gjsify/semver": "^0.64.13",
156
+ "@gjsify/tar": "^0.64.13",
157
+ "@gjsify/tsc": "^0.64.13",
158
+ "@gjsify/utils": "^0.64.13",
159
+ "@gjsify/web-polyfills": "^0.64.13",
160
+ "@gjsify/workspace": "^0.64.13",
161
161
  "acorn": "^8.17.0",
162
162
  "cosmiconfig": "^9.0.2",
163
163
  "get-tsconfig": "^4.14.0",
@@ -166,13 +166,13 @@
166
166
  "yargs": "^18.0.0"
167
167
  },
168
168
  "devDependencies": {
169
- "@gjsify/unit": "^0.64.12",
169
+ "@gjsify/unit": "^0.64.13",
170
170
  "@types/yargs": "^17.0.35",
171
171
  "typescript": "^6.0.3"
172
172
  },
173
173
  "peerDependencies": {
174
- "@gjsify/oxfmt-native": "^0.64.12",
175
- "@gjsify/rolldown-native": "^0.64.12"
174
+ "@gjsify/oxfmt-native": "^0.64.13",
175
+ "@gjsify/rolldown-native": "^0.64.13"
176
176
  },
177
177
  "peerDependenciesMeta": {
178
178
  "@gjsify/oxfmt-native": {
@@ -1,23 +0,0 @@
1
- /**
2
- * What a bundle loads, split by whether the host must provide it.
3
- *
4
- * `namespaces` is the union — the same answer {@link scanGiNamespaces} has always
5
- * given — and `optional` is the subset the artifact itself marks optional. A namespace
6
- * in both is still a hard dependency as far as anything else is concerned: the
7
- * importer asked for it both ways, and only one of those edges degrades.
8
- */
9
- export interface GiRequirements {
10
- /** Every GI namespace the bundle loads, `Ns-Version` where a specifier pins one. */
11
- namespaces: string[];
12
- /** The subset carrying the bundler's optional marker — absent is not fatal. */
13
- optional: string[];
14
- }
15
- /**
16
- * Extract the GI namespaces a bundle imports, as `Ns-Version` when the
17
- * specifier pins one and bare `Ns` when it does not.
18
- */
19
- export declare function scanGiNamespaces(source: string): string[];
20
- /** {@link scanGiNamespaces}, plus which of them the artifact marks optional (ADR 0087). */
21
- export declare function scanGiRequirements(source: string): GiRequirements;
22
- /** `gi://Gtk?version=4.0` → `Gtk-4.0`; `gi://Gtk` → `Gtk`; anything else → null. */
23
- export declare function parseGiSpecifier(specifier: string): string | null;
@@ -1,275 +0,0 @@
1
- // Which GI namespaces does this bundle actually load?
2
- //
3
- // Read off the EMITTED bundle, because that is the file that gets installed —
4
- // and the emitted form is not one form but two, which is the correction this
5
- // header carries. Under `--app gjs`, `gi://` is a real module protocol, so the
6
- // bundler keeps those specifiers in the output verbatim
7
- // (`rolldown-plugin-gjsify`'s externals plugin) and the artifact carries its own
8
- // dependency list. Under `--app node` there is no such protocol:
9
- // `gjsGiNodePlugin` rewrites every `gi://Ns?version=X` into a shim whose body is
10
- // `require('@gjsify/node-gi/gi').requireGi("Ns", "X")`, so the specifier this
11
- // scanner was written to find is gone from the file by construction.
12
- //
13
- // WHAT THAT COST, and why reading one form is not "mostly right": these
14
- // namespaces are what `depends.ts` maps to `gir1.2-gtk-4.0` and friends
15
- // (ADR 0024 § 6). A `--app node` project therefore derived an EMPTY typelib
16
- // dependency set — the package installed cleanly and died at its first GI call,
17
- // which is the same failure the bare-side-effect-import fix below was written to
18
- // prevent, arriving again through the other build target. Nothing caught it
19
- // because every ship fixture's "node bundle" was hand-written with `gi://`
20
- // imports in it: a shape `gjsify build --app node` cannot emit and node cannot
21
- // run.
22
- //
23
- // PARSED, not pattern-matched, and the first version of this file is why. A
24
- // regex over the bundle text got it wrong in both directions at once:
25
- //
26
- // * it missed `import "gi://Soup?version=3.0"` — the bare side-effect form,
27
- // which is exactly what `@gjsify/fetch` puts at the top of every bundle
28
- // that pulls it. The package would have shipped without libsoup, installed
29
- // cleanly, and died at the first request.
30
- // * it matched `gi://…` inside a diagnostic STRING containing the word
31
- // `from`, and since an unmapped namespace fails the build, that made a
32
- // correct project unbuildable.
33
- //
34
- // `walkModuleAst` answers both questions in ONE pass, and it is the same acorn
35
- // pass the CLI already uses to compute its own runtime closure — so there is one
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';
49
- import { importedSpecifier, staticStringValue, walkModuleAst, } from '../cli-runtime-closure.js';
50
- /** The module a `--app node` bundle reaches GI through. */
51
- const NODE_GI_MODULE = '@gjsify/node-gi/gi';
52
- /** Namespaces are GI identifiers; anything else is a call this reader misread. */
53
- const NAMESPACE = /^[A-Za-z][A-Za-z\d_]*$/;
54
- /**
55
- * Extract the GI namespaces a bundle imports, as `Ns-Version` when the
56
- * specifier pins one and bare `Ns` when it does not.
57
- */
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) {
63
- const found = new Set();
64
- const optional = new Set();
65
- // TWO SETS, because the two kinds of binding are not interchangeable and
66
- // treating them as one over-approximated on a real minified bundle. A
67
- // namespace object (`import * as gi`) and a required module object are only
68
- // ever the OBJECT of `.requireGi(…)`: calling a module namespace is a
69
- // TypeError, so a bare `gi(…)` is never one of ours. Only `requireGi` itself
70
- // — the named import, or the default export, which IS `requireGi` — is
71
- // callable on its own.
72
- //
73
- // Measured on `gjsify build --app node` (minify is the DEFAULT): a file with
74
- // `import * as gi from '@gjsify/node-gi/gi'` and a callback parameter emitted
75
- // as `function render(e,t){return e(t)+e(\`Zzqfoo\`)}` — where the minifier
76
- // gave the import and the parameter the SAME short name in different scopes.
77
- // Read as one set, `e(\`Zzqfoo\`)` became the namespace `Zzqfoo`, and
78
- // `deriveDepends` then refused to package a correct project.
79
- const callable = new Set();
80
- const objects = new Set();
81
- const calls = [];
82
- walkModuleAst(source, (node) => {
83
- const specifier = importedSpecifier(node);
84
- if (specifier !== null) {
85
- const key = parseGiSpecifier(specifier);
86
- if (key !== null)
87
- found.add(key);
88
- if (node.type === 'ImportDeclaration' && specifier === NODE_GI_MODULE) {
89
- collectNodeGiBindings(node, callable, objects);
90
- }
91
- return;
92
- }
93
- if (node.type === 'VariableDeclarator' && loadsNodeGiModule(node.init)) {
94
- const id = node.id;
95
- // An OBJECT: `const gi = require('@gjsify/node-gi/gi')` is the module,
96
- // reached as `gi.requireGi(…)` and never called directly.
97
- if (id?.type === 'Identifier' && typeof id.name === 'string')
98
- objects.add(id.name);
99
- return;
100
- }
101
- // Collected rather than resolved in place: a call can precede the
102
- // `require` that binds its callee (the shim's `load()` is hoisted above
103
- // nothing, but a minifier is free to reorder declarations), so the
104
- // binding set has to be complete before any call is judged.
105
- if (node.type === 'CallExpression')
106
- calls.push(node);
107
- });
108
- for (const call of calls) {
109
- const marker = optionalMarkerNamespace(call);
110
- if (marker !== null) {
111
- optional.add(marker);
112
- continue;
113
- }
114
- const key = requireGiNamespace(call, callable, objects);
115
- if (key !== null)
116
- found.add(key);
117
- }
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}`;
160
- }
161
- /** `gi://Gtk?version=4.0` → `Gtk-4.0`; `gi://Gtk` → `Gtk`; anything else → null. */
162
- export function parseGiSpecifier(specifier) {
163
- if (!specifier.startsWith('gi://'))
164
- return null;
165
- const rest = specifier.slice('gi://'.length);
166
- const [namespace, query] = rest.split('?', 2);
167
- if (namespace === undefined || !NAMESPACE.test(namespace))
168
- return null;
169
- if (query === undefined)
170
- return namespace;
171
- const version = /(?:^|&)version=([^&]+)/.exec(query)?.[1];
172
- return version === undefined ? namespace : `${namespace}-${version}`;
173
- }
174
- /**
175
- * What an `import … from '@gjsify/node-gi/gi'` binds, split by what it can BE.
176
- *
177
- * All three spellings appear in real code and the default export IS `requireGi`
178
- * (`packages/node-gi/node-gi/gi.d.ts`), so the named import (under any local
179
- * name — `import { requireGi as gi }` renames it) and the default import are
180
- * CALLABLE. A namespace import is not: calling a module namespace object throws,
181
- * so it can only ever be the object of `.requireGi(…)`, and putting it in the
182
- * callable set is what let a minified callback parameter of the same name be
183
- * read as a `requireGi` call.
184
- */
185
- function collectNodeGiBindings(node, callable, objects) {
186
- const specifiers = node.specifiers ?? [];
187
- for (const specifier of specifiers) {
188
- const local = specifier.local;
189
- if (local?.type !== 'Identifier' || typeof local.name !== 'string')
190
- continue;
191
- const imported = specifier.imported;
192
- if (specifier.type === 'ImportNamespaceSpecifier')
193
- objects.add(local.name);
194
- else if (specifier.type === 'ImportDefaultSpecifier')
195
- callable.add(local.name);
196
- else if (imported?.type === 'Identifier' && imported.name === 'requireGi')
197
- callable.add(local.name);
198
- }
199
- }
200
- /**
201
- * A call that LOADS `@gjsify/node-gi/gi` — recognised by its argument, never by
202
- * the name of the callee.
203
- *
204
- * THE CALLEE NAME DOES NOT SURVIVE BUNDLING, and reading it is how the first cut
205
- * of this function passed its own tests while answering `[]` for every real
206
- * artifact. Measured on `gjsify build --app node` output for a two-import app,
207
- * which is the exact input this module exists to read:
208
- *
209
- * unminified const require$1 = createRequire(import.meta.url);
210
- * … require$1("@gjsify/node-gi/gi").requireGi("Gtk", "4.0")
211
- * minified const n = e(import.meta.url);
212
- * … n(`@gjsify/node-gi/gi`).requireGi(`Gtk`, `4.0`)
213
- *
214
- * Two shims in one bundle means two `require` bindings, so the second is renamed
215
- * even without `--minify` (which is the DEFAULT). A reader keyed on the
216
- * identifier `require` therefore loses one namespace unminified and both
217
- * minified — silently, since an empty namespace list is a legal answer.
218
- *
219
- * The module STRING is what survives every rename, and it is a precise
220
- * discriminator: a call taking `'@gjsify/node-gi/gi'` as its first argument is a
221
- * load of that module under any name a bundler gives the loader.
222
- */
223
- function loadsNodeGiModule(node) {
224
- if (!node || typeof node !== 'object')
225
- return false;
226
- const call = node;
227
- if (call.type !== 'CallExpression')
228
- return false;
229
- const args = call.arguments ?? [];
230
- return staticStringValue(args[0]) === NODE_GI_MODULE;
231
- }
232
- /**
233
- * The namespace a `requireGi(…)` call names, or `null` when this is not one.
234
- *
235
- * BINDING-TRACED rather than name-matched, and the asymmetry is deliberate: a
236
- * bare `.requireGi(…)` on any object would over-approximate, and
237
- * over-approximating is NOT the harmless direction here — an unmapped namespace
238
- * fails the build, so a foreign method sharing the name would make a correct
239
- * project unpackageable. Every accepted shape traces back to an import or
240
- * require of `@gjsify/node-gi/gi` in this same file, which is what a bundle
241
- * always has: the shim carries its own `require`, and a hand-written node-gi app
242
- * carries its own import.
243
- *
244
- * Shadowing is the one gap left, and the split above is what makes it small.
245
- * This reader has no scope analysis, so a local binding that reuses the name of
246
- * the CALLABLE import — `requireGi` itself, or the default import — and is called
247
- * with a GI-shaped string literal would still be read as one. A minifier can
248
- * manufacture exactly that collision, which is why the two shapes it produces
249
- * most (the namespace object and the required module object) are the two that no
250
- * longer count as callees.
251
- */
252
- function requireGiNamespace(call, callable, objects) {
253
- const callee = call.callee;
254
- let reached = false;
255
- if (callee?.type === 'Identifier') {
256
- reached = typeof callee.name === 'string' && callable.has(callee.name);
257
- }
258
- else if (callee?.type === 'MemberExpression' && callee.computed !== true) {
259
- const property = callee.property;
260
- if (property?.type !== 'Identifier' || property.name !== 'requireGi')
261
- return null;
262
- const object = callee.object;
263
- reached =
264
- loadsNodeGiModule(object) ||
265
- (object?.type === 'Identifier' && typeof object.name === 'string' && objects.has(object.name));
266
- }
267
- if (!reached)
268
- return null;
269
- const args = call.arguments ?? [];
270
- const namespace = staticStringValue(args[0]);
271
- if (namespace === null || !NAMESPACE.test(namespace))
272
- return null;
273
- const version = staticStringValue(args[1]);
274
- return version === null ? namespace : `${namespace}-${version}`;
275
- }
@@ -1,18 +0,0 @@
1
- /** What {@link planGiSelection} decided, and what it wants the build to say out loud. */
2
- export interface GiSelection {
3
- /** The namespaces to hand the AAR builder, as the config spells them, deduped and sorted. */
4
- selected: string[];
5
- /** Declared but not found in the bundle — worth a warning, never a refusal. */
6
- unscanned: string[];
7
- }
8
- /**
9
- * Reconcile the configured namespace set with the one the emitted bundle imports.
10
- *
11
- * @param spec.declared `nativescript.giNamespaces`, as `Ns` or `Ns-Version`
12
- * @param spec.scanned what {@link scanGiNamespaces} read off the emitted bundle
13
- * @throws when the bundle imports a namespace the config does not cover
14
- */
15
- export declare function planGiSelection({ declared, scanned }: {
16
- declared: string[];
17
- scanned: string[];
18
- }): GiSelection;
@@ -1,73 +0,0 @@
1
- // Which GI namespaces does this Android APK carry? — ADR 0117 § 2, the CLI half.
2
- //
3
- // THE CONFIG DECIDES AND THE BUNDLE IS CHECKED AGAINST IT, which is the asymmetry
4
- // the ADR turns on. The payload is cross-compiled hours earlier by
5
- // `build-gi-runtime-android.mjs` against a sha256-pinned sysroot; nothing at app-build
6
- // time can conjure a typelib that is not in it. So the config is the input, and the
7
- // scan of the emitted bundle is the EVIDENCE that the input is complete.
8
- //
9
- // The scan is a LOWER BOUND, and the two directions of disagreement are therefore not
10
- // symmetric:
11
- //
12
- // * IMPORTED, NOT CONFIGURED → build error naming the namespace. The scanner found a
13
- // real import; the APK will not have the typelib; the device throws at that import,
14
- // after the download, reading like an application bug. This is the same failure
15
- // `deriveDepends` refuses for distro packages (ADR 0024 § 6), reached from Android.
16
- // * CONFIGURED, NOT IMPORTED → warning. `scanGiNamespaces` reads the emitted file and
17
- // cannot see a namespace reached through a computed specifier, nor one loaded by a
18
- // native dependency. Refusing here would make a correct project unbuildable on the
19
- // scanner's blind spot — so it reports a probably-larger-than-necessary APK and
20
- // builds it.
21
- //
22
- // COMPARED BY NAMESPACE, NOT BY STRING. A config may pin an ABI (`Gtk-4.0`) where the
23
- // bundle's specifier carries no version (`import 'gi://Gtk'`), and vice versa. Both
24
- // sides mean the same namespace, and string equality would fail a correct project on
25
- // the spelling.
26
- /** `Gtk-4.0` → `Gtk`; `Gtk` → `Gtk`. */
27
- function namespaceOf(entry) {
28
- const dash = entry.indexOf('-');
29
- return dash === -1 ? entry : entry.slice(0, dash);
30
- }
31
- /**
32
- * The namespaces every AAR carries whether or not anything declares them.
33
- *
34
- * These back `libnode_gi.so` itself, so they are in the payload's DT_NEEDED closure
35
- * before any selection happens (ADR 0110's measured core set). Demanding them in the
36
- * config would make the key boilerplate and fail every project that exists today, for
37
- * a selection that cannot change the bytes.
38
- */
39
- const ALWAYS_PRESENT = new Set(['GLib', 'GObject', 'Gio', 'GioUnix', 'GLibUnix', 'GModule', 'GIRepository']);
40
- /**
41
- * Reconcile the configured namespace set with the one the emitted bundle imports.
42
- *
43
- * @param spec.declared `nativescript.giNamespaces`, as `Ns` or `Ns-Version`
44
- * @param spec.scanned what {@link scanGiNamespaces} read off the emitted bundle
45
- * @throws when the bundle imports a namespace the config does not cover
46
- */
47
- export function planGiSelection({ declared, scanned }) {
48
- // A bare and a pinned declaration of one namespace are one selection, and the
49
- // pinned spelling wins: it says something the bare one does not.
50
- const byNamespace = new Map();
51
- for (const entry of declared) {
52
- const ns = namespaceOf(entry);
53
- const held = byNamespace.get(ns);
54
- if (held === undefined || (held === ns && entry !== ns))
55
- byNamespace.set(ns, entry);
56
- }
57
- const missing = [
58
- ...new Set(scanned.map(namespaceOf).filter((ns) => !byNamespace.has(ns) && !ALWAYS_PRESENT.has(ns))),
59
- ].sort();
60
- if (missing.length > 0) {
61
- throw new Error(`The bundle imports ${missing.join(', ')}, which nativescript.giNamespaces does not declare. ` +
62
- 'An Android APK carries only the typelibs the config selects (ADR 0117), so this would ' +
63
- `install and then throw at the import. Add ${missing.length === 1 ? 'it' : 'them'} to ` +
64
- 'nativescript.giNamespaces, or stop importing ' +
65
- `${missing.length === 1 ? 'it' : 'them'}.`);
66
- }
67
- const seen = new Set(scanned.map(namespaceOf));
68
- const unscanned = [...byNamespace.entries()]
69
- .filter(([ns]) => !seen.has(ns))
70
- .map(([, entry]) => entry)
71
- .sort();
72
- return { selected: [...byNamespace.values()].sort(), unscanned };
73
- }