@gjsify/node-gi 0.59.0 → 0.60.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/binding.gyp CHANGED
@@ -4,6 +4,8 @@
4
4
  "target_name": "node_gi",
5
5
  "sources": [
6
6
  "src/addon.cc",
7
+ "src/android-gdk.cc",
8
+ "src/android-log.cc",
7
9
  "src/cairo.cc",
8
10
  "src/calls.cc",
9
11
  "src/class.cc",
package/gettext.js CHANGED
@@ -54,7 +54,7 @@ let announcedStaleAddon = false;
54
54
  function noLocaleBinders(fn) {
55
55
  if (!announcedStaleAddon) {
56
56
  announcedStaleAddon = true;
57
- process.emitWarning(
57
+ globalThis.process?.emitWarning?.(
58
58
  `@gjsify/node-gi: the loaded native addon predates the gettext binders, so ` +
59
59
  `${fn}() cannot take effect and messages stay untranslated. Rebuild the addon ` +
60
60
  `(npm run rebuild) or update @gjsify/node-gi.`,
@@ -190,7 +190,7 @@ export function bindtextdomainCodeset(_domainName, _codeset) {
190
190
  */
191
191
  export const LocaleCategory = haveLocaleBinders
192
192
  ? native.localeCategories()
193
- : (FALLBACK_CATEGORIES[process.platform] ?? FALLBACK_CATEGORIES.linux);
193
+ : (FALLBACK_CATEGORIES[globalThis.process?.platform] ?? FALLBACK_CATEGORIES.linux);
194
194
 
195
195
  /**
196
196
  * The GJS `Gettext` module as a default export — the object shape
package/gi.d.ts CHANGED
@@ -138,6 +138,16 @@ export interface GObjectNamespace extends GiNamespace {
138
138
  */
139
139
  registerClass(klass: Function): Function;
140
140
  registerClass(meta: RegisterClassMeta, klass: Function): Function;
141
+ /**
142
+ * Static-field key for {@link RegisterClassMeta.GTypeName} — gjs's meta FIELD form,
143
+ * `Klass[GObject.GTypeName] = 'X'` instead of a meta object. A meta object passed to
144
+ * `registerClass` still wins over the field.
145
+ */
146
+ GTypeName: symbol;
147
+ /** Static-field key for {@link RegisterClassMeta.Properties} (the field form of the meta). */
148
+ properties: symbol;
149
+ /** Static-field key for {@link RegisterClassMeta.Signals} (the field form of the meta). */
150
+ signals: symbol;
141
151
  ParamSpec: ParamSpecFactories;
142
152
  /**
143
153
  * The FULL introspected `GParamFlags` bitfield (READABLE/WRITABLE/READWRITE/
package/gi.js CHANGED
@@ -7,20 +7,11 @@
7
7
  // Reference: gjs/modules/esm/gi.js for the require shape; node-gtk (romgrk, MIT) for the
8
8
  // binding lineage. Hand-authored JS — the package ships no build step.
9
9
  import * as native from './index.js';
10
- // From node:timers, not the globals, for two reasons: setImmediate is a Node/Bun global
11
- // but Deno only exposes it here; and a GJS source running through the bridge routinely
12
- // replaces the global timers with GLib-backed ones (`@gjsify/node-globals/register`
13
- // swaps them for `GLib.timeout_add`), which deadlocks the pump — the timer meant to
14
- // dispatch GLib sources would itself be a GLib source nobody dispatches.
15
- import { setImmediate, setInterval, clearInterval } from 'node:timers';
16
- // Same reasoning for `process`: `globalThis.process` may be the `@gjsify/process`
17
- // polyfill, whose EventEmitter never emits the runtime's 'beforeExit', so the
18
- // loop-liveness hooks below would silently never fire.
19
- import runtimeProcess from 'node:process';
10
+ // Timers, `process` and the windowing env record come from the host seam (host.node.js
11
+ // explains why they are the runtime's own, not the globals; host.nativescript.js is the
12
+ // NativeScript Android variant).
13
+ import { setImmediate, setInterval, clearInterval, runtimeProcess, windowingEnvWrites } from '#host';
20
14
  import { createGioDBus } from './overrides/gio-dbus.js';
21
- // The windowing loader's record of what it wrote into process.env. Read here and not there
22
- // because replaying it needs a GLib (mirrorWindowingEnvIntoCrt below).
23
- import { windowingEnvWrites } from './gtk-runtime.js';
24
15
 
25
16
  // The raw native GObject handle on a wrapped instance, so it can be unwrapped again
26
17
  // when passed back into the engine as a GI argument.
@@ -818,6 +809,12 @@ function handlerIdsForFunc(handle, fn) {
818
809
  // routing keys on the leaf so construction is correct at any depth.
819
810
  const registeredClasses = new Map();
820
811
 
812
+ // A registered class's prototype → the kebab names of the CUSTOM GObject properties
813
+ // it declared, plus every registered ancestor's. Keyed by the prototype because that
814
+ // is what an instance carries (USER_PROTO), and the set trap is the only consumer:
815
+ // see the equal-value guard in wrapInstance.
816
+ const customPropertiesByProto = new WeakMap();
817
+
821
818
  // GTypeName → registered JS class, the reverse of registeredClasses. The engine's
822
819
  // NodeGiConstructor hands runCtorForCObject (below) the C-created instance's GType
823
820
  // NAME (g_type_name), which this resolves back to the class to Reflect.construct.
@@ -1082,6 +1079,11 @@ function syncPumpKeepAlive() {
1082
1079
  pumpHoldsRuntime = hold;
1083
1080
  }
1084
1081
 
1082
+ /** Hosts whose own loop already dispatches GLib, so the portable timer pump must not arm. */
1083
+ function hostPumpsItself() {
1084
+ return native.isNodeRuntime || native.RUNTIME === 'gjs' || native.RUNTIME === 'nativescript';
1085
+ }
1086
+
1085
1087
  /**
1086
1088
  * Start co-pumping the default GLib main context from a runtime timer (Bun/Deno).
1087
1089
  * No-op on Node, where the libuv↔GLib bridge already co-pumps. Reference-counted;
@@ -1097,7 +1099,7 @@ export function startMainContextPump() {
1097
1099
  // Node: the libuv↔GLib bridge already co-pumps. GJS: the host loop IS
1098
1100
  // GLib's default main context — a JS-timer pump would only iterate the
1099
1101
  // context recursively from within its own dispatch.
1100
- if (native.isNodeRuntime || native.RUNTIME === 'gjs') return () => {};
1102
+ if (hostPumpsItself()) return () => {};
1101
1103
  pumpRefCount++;
1102
1104
  if (pumpTimer === null) {
1103
1105
  if (pendingView === null) pendingView = native.makePumpPendingCount();
@@ -1122,7 +1124,7 @@ export function startMainContextPump() {
1122
1124
 
1123
1125
  /** Decrement the main-context pump reference count; clears the timer at zero. */
1124
1126
  export function stopMainContextPump() {
1125
- if (native.isNodeRuntime || native.RUNTIME === 'gjs' || pumpRefCount === 0) return;
1127
+ if (hostPumpsItself() || pumpRefCount === 0) return;
1126
1128
  pumpRefCount--;
1127
1129
  if (pumpRefCount === 0 && pumpTimer !== null) {
1128
1130
  clearInterval(pumpTimer);
@@ -1313,6 +1315,33 @@ function wrapInstance(handle, userProto) {
1313
1315
  const target = { [HANDLE]: handle };
1314
1316
  const proto = userProto ?? protoForInstance(handle);
1315
1317
  if (proto !== undefined) linkInstanceProto(target, proto);
1318
+ // Which CUSTOM properties of this instance have a value yet — the thing GJS's
1319
+ // generated accessors track with the PRESENCE of their private field, and what its
1320
+ // setter's equal-value guard actually compares against (`value !== this[privateName]`,
1321
+ // refs/gjs/modules/core/_common.js). Not the ParamSpec default: on gjs 1.88.1 a fresh
1322
+ // object's `o.p = <default>` DOES notify (the field holds `undefined`), while the same
1323
+ // assignment after a READ does not — the getter installs the default into the field
1324
+ // first (`if (!(privateName in this)) this[privateName] = defaultValue`) — and nor
1325
+ // does it after `new K({p: <default>})`, since the construct value went through the
1326
+ // setter. Three behaviours, one state: has the value been materialised.
1327
+ //
1328
+ // Seeded lazily from what the ENGINE has stored (`storedPropertyNames` — construct
1329
+ // values included), then extended by every read and write through this wrapper. A set
1330
+ // from C on a property no JS code has touched is the one gap, and it falls back to
1331
+ // notifying — what node-gi did before the guard existed. Same for an addon too old to
1332
+ // export storedPropertyNames (the cross-version pairing of the shipped-closure legs,
1333
+ // see runJsPropertySetter): the guard then only narrows, never widens.
1334
+ let materialised;
1335
+ const materialise = (name) => {
1336
+ if (materialised === undefined) {
1337
+ materialised = new Set(
1338
+ typeof native.storedPropertyNames === 'function' ? native.storedPropertyNames(handle) : [],
1339
+ );
1340
+ }
1341
+ const had = materialised.has(name);
1342
+ materialised.add(name);
1343
+ return had;
1344
+ };
1316
1345
  const proxy = new Proxy(target, {
1317
1346
  get(t, prop) {
1318
1347
  if (prop === HANDLE) return handle;
@@ -1404,6 +1433,14 @@ function wrapInstance(handle, userProto) {
1404
1433
  if (shim !== undefined) return (...args) => shim(handle, args);
1405
1434
  const propName = toKebab(prop);
1406
1435
  if (native.hasProperty(handle, propName)) {
1436
+ // A READ materialises a custom property's value, exactly as GJS's generated
1437
+ // getter writes the default into its private field on first look-up — which
1438
+ // is what makes a LATER assignment of that same default silent. See the
1439
+ // equal-value guard in the set trap.
1440
+ const up = t[USER_PROTO];
1441
+ if (up !== undefined && customPropertiesByProto.get(up)?.has(propName) === true) {
1442
+ materialise(propName);
1443
+ }
1407
1444
  return wrapReturn(native.getProperty(handle, propName));
1408
1445
  }
1409
1446
  // LAST resort before treating an unknown name as a GI method: an INHERITED
@@ -1448,6 +1485,29 @@ function wrapInstance(handle, userProto) {
1448
1485
  }
1449
1486
  const propName = toKebab(prop);
1450
1487
  if (native.hasProperty(handle, propName)) {
1488
+ // Equal-value guard, GJS's `_generateAccessors` setter verbatim:
1489
+ // `if (value !== this[privateName]) { …; this.notify(name) }`
1490
+ // (refs/gjs/modules/core/_common.js). GJS puts it in the accessor it
1491
+ // GENERATES for a declared property, so it covers exactly the custom
1492
+ // properties with no accessor of the class's own — the branch above
1493
+ // already took every class-declared accessor, and `set_property`
1494
+ // bypasses the accessor and so keeps notifying unconditionally on both
1495
+ // runtimes (measured on gjs 1.88.1: assignment [1,1,2], set_property
1496
+ // [1,2,3]). node-gi installs no generated accessors (the engine store
1497
+ // IS the backing store, see findPropertySetter), so without this the
1498
+ // store's unconditional g_object_notify_by_pspec made every assignment
1499
+ // notify — [1,2,3] where gjs gives [1,1,2], and the three spellings of
1500
+ // a dashed name notified once EACH instead of sharing one.
1501
+ //
1502
+ // Only once the value is MATERIALISED (see `materialise`): an unset
1503
+ // property's private field is `undefined` on gjs, never the ParamSpec
1504
+ // default, so the first assignment notifies even when it equals the
1505
+ // default. The dash spelling is the key, since toKebab canonicalises
1506
+ // all three spellings onto it.
1507
+ const custom = up !== undefined && customPropertiesByProto.get(up)?.has(propName) === true;
1508
+ if (custom && materialise(propName)) {
1509
+ if (wrapReturn(native.getProperty(handle, propName)) === value) return true;
1510
+ }
1451
1511
  native.setProperty(handle, propName, unwrapArg(value));
1452
1512
  return true;
1453
1513
  }
@@ -1699,10 +1759,17 @@ function makeClass(namespace, typeName) {
1699
1759
  handle = native.constructType(reg.typeHandle, props ? unwrapProps(props) : {});
1700
1760
  }
1701
1761
  const instance = wrapInstance(handle, nt.prototype);
1702
- assignTemplateChildren(instance, handle, reg);
1703
1762
  // Route construct-time property values through the class's JS setters now
1704
1763
  // that USER_PROTO is attached — before the user ctor body runs (GJS order).
1764
+ //
1765
+ // BEFORE the template children, because that is the order GJS produces: a
1766
+ // construct property's setter runs inside g_object_new, where the template
1767
+ // is not yet built, so it sees `this._child === undefined`; the children
1768
+ // appear only once construction returns. Binding them first made a setter
1769
+ // observe a child GJS cannot have handed it yet, so a class that guards on
1770
+ // `if (this._child)` took the wrong branch on node-gi and only on node-gi.
1705
1771
  flushPropertiesToJsSetters(instance, handle, reg);
1772
+ assignTemplateChildren(instance, handle, reg);
1706
1773
  return instance;
1707
1774
  }
1708
1775
  // `nt` is a subclass of this introspected GObject class but was NEVER passed to
@@ -2353,6 +2420,19 @@ function collectVfuncs(klass) {
2353
2420
  * @param {Function} [maybeClass]
2354
2421
  * @returns {Function} the same `klass` (now registered)
2355
2422
  */
2423
+ // GJS's meta FIELD form: a class may declare its meta as static symbol fields
2424
+ // (`Klass[GObject.GTypeName] = 'X'`) instead of passing a meta object, and GJS treats
2425
+ // the symbols as the real source — `registerClass(meta, klass)` only copies meta onto
2426
+ // them and reads them back (refs/gjs/modules/core/_common.js). The descriptions match
2427
+ // GJS's so a stringified symbol reads the same in both runtimes.
2428
+ //
2429
+ // GObject.interfaces / GObject.interface requires are deliberately absent: registerClass
2430
+ // implements no interfaces in EITHER form yet, and a symbol that is accepted and then
2431
+ // ignored promises more than the object form delivers.
2432
+ const GTypeNameSymbol = Symbol('GType name');
2433
+ const propertiesSymbol = Symbol('GObject properties');
2434
+ const signalsSymbol = Symbol('GObject signals');
2435
+
2356
2436
  function registerClass(metaOrClass, maybeClass) {
2357
2437
  let meta;
2358
2438
  let klass;
@@ -2367,6 +2447,17 @@ function registerClass(metaOrClass, maybeClass) {
2367
2447
  throw new TypeError('GObject.registerClass: expected a class to register');
2368
2448
  }
2369
2449
 
2450
+ // Fold in whatever the class declared as symbol fields, with the meta OBJECT winning
2451
+ // — the same precedence GJS gets from copying meta onto the symbols before reading
2452
+ // them. Until the symbols existed on the namespace, `{[G.GTypeName]: 'X'}` wrote a
2453
+ // literal "undefined" key and the whole declaration vanished without a word: the
2454
+ // GTypeName fell back to the class name and the signals were never registered.
2455
+ const fieldMeta = {};
2456
+ if (klass[GTypeNameSymbol] !== undefined) fieldMeta.GTypeName = klass[GTypeNameSymbol];
2457
+ if (klass[propertiesSymbol] !== undefined) fieldMeta.Properties = klass[propertiesSymbol];
2458
+ if (klass[signalsSymbol] !== undefined) fieldMeta.Signals = klass[signalsSymbol];
2459
+ meta = { ...fieldMeta, ...meta };
2460
+
2370
2461
  const parent = findParentGType(klass);
2371
2462
  if (parent === undefined) {
2372
2463
  throw new TypeError(
@@ -2460,6 +2551,18 @@ function registerClass(metaOrClass, maybeClass) {
2460
2551
  internalChildren: internalChildren.length > 0 ? internalChildren : undefined,
2461
2552
  constructPropertyNames: constructPropertyNames.length > 0 ? constructPropertyNames : undefined,
2462
2553
  });
2554
+ // Which properties the equal-value guard owns: the ones DECLARED here, union the
2555
+ // registered parent's (a G2 subclass inherits its parent's custom properties and
2556
+ // the guard must follow them down). Introspected properties are deliberately
2557
+ // absent — their C setter decides whether an equal value notifies, and guarding
2558
+ // them here would answer for it.
2559
+ const inheritedCustom = registeredClasses.get(Object.getPrototypeOf(klass))?.customProperties;
2560
+ const customProperties = new Set(inheritedCustom);
2561
+ for (const p of properties) {
2562
+ if (typeof p.name === 'string') customProperties.add(p.name);
2563
+ }
2564
+ registeredClasses.get(klass).customProperties = customProperties;
2565
+ customPropertiesByProto.set(klass.prototype, customProperties);
2463
2566
  // Reverse index for runCtorForCObject: the engine identifies a C-created instance
2464
2567
  // by its GType name (= gtypeName, what native.registerClass registered).
2465
2568
  classesByGType.set(gtypeName, klass);
@@ -2555,6 +2658,9 @@ function signalEmitByName(object, ...nameAndArgs) {
2555
2658
  // makeClass). Merged enums are cached so identity is stable.
2556
2659
  const OVERLAY_NAMES = new Set([
2557
2660
  'registerClass',
2661
+ 'GTypeName',
2662
+ 'properties',
2663
+ 'signals',
2558
2664
  'ParamSpec',
2559
2665
  'ParamFlags',
2560
2666
  'SignalFlags',
@@ -2621,6 +2727,9 @@ function decorateGObjectNamespace(baseNs) {
2621
2727
  if (cache.has(prop)) return cache.get(prop);
2622
2728
  let value;
2623
2729
  if (prop === 'registerClass') value = registerClass;
2730
+ else if (prop === 'GTypeName') value = GTypeNameSymbol;
2731
+ else if (prop === 'properties') value = propertiesSymbol;
2732
+ else if (prop === 'signals') value = signalsSymbol;
2624
2733
  else if (prop === 'ParamSpec') value = ParamSpec;
2625
2734
  else if (prop === 'ParamFlags') value = mergeFlags(baseNs.ParamFlags, ParamFlags);
2626
2735
  else if (prop === 'SignalFlags') value = mergeFlags(baseNs.SignalFlags, SignalFlags);
@@ -2794,6 +2903,10 @@ export function requireGi(namespace, version) {
2794
2903
  // ready and re-arm — if GLib still has scheduled work, the armed (ref'd)
2795
2904
  // uv timer revives the loop; otherwise the process exits normally.
2796
2905
  runtimeProcess.on('beforeExit', () => native.pumpKick());
2906
+ } else if (native.RUNTIME === 'nativescript') {
2907
+ // The native ALooper pump owns GLib dispatch on Android: no beforeExit kick
2908
+ // (the Looper never runs empty) and no portable timer pump.
2909
+ native.startMainLoop();
2797
2910
  } else if (native.RUNTIME !== 'gjs') {
2798
2911
  // Bun/Deno: no libuv to hook, so the portable timer pump IS the auto-pump.
2799
2912
  // Armed HERE, not left to the caller, so that `bun bundle.mjs` /
@@ -0,0 +1,242 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // @gjsify/node-gi — the NativeScript (Android) host, selected by the `nativescript`
3
+ // condition on `#host` (see nativescript.webpack.js, which adds the condition to
4
+ // @nativescript/webpack's resolver). NativeScript's V8 is a Node-API host WITHOUT Node's
5
+ // module system: no node:fs/module/path/process/timers, no prebuild tree, no re-exec.
6
+ // This file is the whole difference — gi.js and index.js import the same names from
7
+ // `#host` on every runtime.
8
+ //
9
+ // Configuration (globals, set before the first import — this package must not hard-import
10
+ // @nativescript/core):
11
+ // __NODE_GI_APP_DIR app-private writable dir (default: the files dir, derived from
12
+ // com.tns.NativeScriptApplication). HOME / XDG_{DATA,CONFIG,CACHE}_HOME and
13
+ // XDG_RUNTIME_DIR are pointed below it, via android.system.Os.setenv
14
+ // (a libc setenv) before the addon — and so GLib — is loaded: GLib caches
15
+ // these dirs on first use, a later GLib.setenv would be too late.
16
+ // __NODE_GI_TYPELIB_PATH directory holding the *.typelib files (the app bundles them, e.g.
17
+ // `knownFolders.currentApp().path + '/girepository-1.0'`); prepended to
18
+ // the GIRepository search path. Default: `<app dir>/app/girepository-1.0`.
19
+ // __NODE_GI_SYSTEM_DIR root of the bundled GNOME system prefix, holding `share/` and `etc/`
20
+ // as GTK's own Android runtime lays them out (XDG_DATA_DIRS=<root>/share,
21
+ // XDG_CONFIG_DIRS=<root>/etc, plus FONTCONFIG_PATH=<root>/etc/fonts since
22
+ // fontconfig's compiled-in /etc/fonts is Android's own and holds no
23
+ // fonts.conf). Unset ⇒ nothing is written and GLib keeps its /usr/…
24
+ // defaults, which exist on no Android device: a host that bundles its
25
+ // own GSettings schemas, icon themes or GTK resources must set it.
26
+ // GTK does the same from startRuntime() via g_set_user_dirs(), which is
27
+ // not introspectable — see refs/gtk/gdk/android/gdkandroidruntime.c.
28
+ // __NODE_GI_ANDROID_GDK truthy ⇒ bring GDK's Android backend up while loading the addon:
29
+ // gdk_android_initialize(env, app classloader, Application), the pair
30
+ // GTK's RuntimeApplication.startRuntime() passes. Needed before the
31
+ // first Gtk call and NOT done by anything else — GIRepository dlopens
32
+ // libgtk-4.so, which (unlike System.loadLibrary) never runs its
33
+ // JNI_OnLoad, and that JNI_OnLoad passes a NULL context anyway, on
34
+ // which gtk_init's Context.getSystemService() aborts the process.
35
+ // Unset ⇒ libgtk-4.so is not loaded here at all, which is what a
36
+ // GLib-only host wants (src/android-gdk.cc).
37
+ // __NODE_GI_ANDROID_JNI_BOOTSTRAP
38
+ // required by the above: the fully-qualified name of a class in
39
+ // the APK whose static initializer calls
40
+ // System.loadLibrary("node_gi"). Nothing else runs this addon's
41
+ // JNI_OnLoad, and without that there is no JavaVM to reach GDK
42
+ // with — see ensureJavaVm().
43
+
44
+ /** @type {'nativescript'} */
45
+ export const RUNTIME = 'nativescript';
46
+
47
+ // Timers from the global scope: NativeScript has no node:timers, and there is no
48
+ // GLib-backed replacement to avoid here. setImmediate is not a V8 builtin.
49
+ export const setImmediate =
50
+ typeof globalThis.setImmediate === 'function'
51
+ ? globalThis.setImmediate.bind(globalThis)
52
+ : (fn, ...args) => globalThis.setTimeout(fn, 0, ...args);
53
+ export const setInterval = globalThis.setInterval.bind(globalThis);
54
+ export const clearInterval = globalThis.clearInterval.bind(globalThis);
55
+
56
+ // The only `process` surface gi.js reads is on('beforeExit'), and NativeScript has no such
57
+ // event: its loop is the Android main Looper, which never "runs empty". The stand-in
58
+ // swallows registrations (nothing needs re-arming — the native ALooper pump owns GLib
59
+ // dispatch). exit() is the honest Android answer: end this process.
60
+ const env = {};
61
+ export const runtimeProcess = {
62
+ env,
63
+ platform: 'android',
64
+ argv: [],
65
+ on() {
66
+ return runtimeProcess;
67
+ },
68
+ once() {
69
+ return runtimeProcess;
70
+ },
71
+ exit(code = 0) {
72
+ const proc = globalThis.android?.os?.Process;
73
+ if (proc) proc.killProcess(proc.myPid());
74
+ throw new Error(`@gjsify/node-gi: process.exit(${code}) is unavailable outside Android`);
75
+ },
76
+ };
77
+
78
+ // No bundled-GTK windowing loader on Android: nothing was written, nothing to replay.
79
+ export function windowingEnvWrites() {
80
+ return [];
81
+ }
82
+
83
+ // NativeScript's runtime class hands out the Application without @nativescript/core
84
+ // (android.app.ActivityThread is a hidden API and absent from the metadata).
85
+ function deriveAppDir() {
86
+ try {
87
+ return globalThis.com?.tns?.NativeScriptApplication?.getInstance()?.getFilesDir()?.getPath() ?? null;
88
+ } catch {
89
+ // NativeScript surfaces a Java exception as a JS one, and getFilesDir() raises
90
+ // before the Application is fully constructed. No dir is not a failure here: it
91
+ // means the host sets __NODE_GI_APP_DIR itself, or keeps GLib's defaults.
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function setNativeEnv(name, value) {
97
+ env[name] = value;
98
+ globalThis.android?.system?.Os?.setenv(name, value, true);
99
+ }
100
+
101
+ function appDirectory() {
102
+ const set = globalThis.__NODE_GI_APP_DIR;
103
+ if (typeof set === 'string' && set !== '') return set;
104
+ return deriveAppDir();
105
+ }
106
+
107
+ // The addon needs a JavaVM to hand GDK a JNIEnv, and a native library gets one only from
108
+ // its own JNI_OnLoad — which ART runs when it loads the library on behalf of a class.
109
+ // NativeScript's `system_lib://` is a plain dlopen: the library is mapped and its
110
+ // Node-API exports work, but ART never saw it, so JNI_OnLoad did not run.
111
+ //
112
+ // Loading it a second time from Java does run it, and costs nothing — same soname in the
113
+ // same linker namespace is the same handle, not a second copy. The catch is where the
114
+ // load is issued from, because ART resolves the library against the *calling class's*
115
+ // classloader:
116
+ //
117
+ // java.lang.System.loadLibrary('node_gi') straight from JS fails with "library
118
+ // libnode_gi.so not found": the call arrives through JNI with no Java frame below it,
119
+ // so the caller is the system classloader, whose search path is /system/lib* only.
120
+ // System.load() with the absolute path from ApplicationInfo.nativeLibraryDir does not
121
+ // throw but leaves the VM unset — measured on API 36.
122
+ //
123
+ // So the load has to come from a class in the APK: __NODE_GI_ANDROID_JNI_BOOTSTRAP names
124
+ // one whose static initializer calls System.loadLibrary("node_gi"), and initializing it
125
+ // through Class.forName runs that block under the app's own classloader. Registering the
126
+ // name with the addon first is what lets its JNI_OnLoad cache that classloader, the only
127
+ // moment app classes are visible to it.
128
+ function ensureJavaVm(native) {
129
+ if (typeof native.androidHasJavaVm !== 'function' || native.androidHasJavaVm()) return;
130
+ const bootstrap = globalThis.__NODE_GI_ANDROID_JNI_BOOTSTRAP;
131
+ if (typeof bootstrap !== 'string' || bootstrap === '') {
132
+ throw new Error(
133
+ '@gjsify/node-gi: __NODE_GI_ANDROID_GDK needs __NODE_GI_ANDROID_JNI_BOOTSTRAP — ' +
134
+ 'the name of an app class whose static initializer calls ' +
135
+ 'System.loadLibrary("node_gi"). Only such a class can run this addon\'s ' +
136
+ 'JNI_OnLoad, and without it there is no JavaVM to initialize GDK with.',
137
+ );
138
+ }
139
+ native.androidRegisterJniBootstrap(bootstrap.replace(/\./g, '/'));
140
+ const loader = globalThis.com?.tns?.NativeScriptApplication?.getInstance()?.getClassLoader();
141
+ globalThis.java?.lang?.Class?.forName(bootstrap, true, loader);
142
+ }
143
+
144
+ function configureEnvironment() {
145
+ const systemDir = globalThis.__NODE_GI_SYSTEM_DIR;
146
+ if (typeof systemDir === 'string' && systemDir !== '') {
147
+ setNativeEnv('XDG_DATA_DIRS', `${systemDir}/share`);
148
+ setNativeEnv('XDG_CONFIG_DIRS', `${systemDir}/etc`);
149
+ setNativeEnv('FONTCONFIG_PATH', `${systemDir}/etc/fonts`);
150
+ }
151
+ const appDir = appDirectory();
152
+ if (appDir === null) return;
153
+ setNativeEnv('HOME', appDir);
154
+ setNativeEnv('XDG_DATA_HOME', `${appDir}/.local/share`);
155
+ setNativeEnv('XDG_CONFIG_HOME', `${appDir}/.config`);
156
+ setNativeEnv('XDG_CACHE_HOME', `${appDir}/.cache`);
157
+ setNativeEnv('XDG_RUNTIME_DIR', `${appDir}/.run`);
158
+ }
159
+
160
+ /**
161
+ * The names under which NativeScript's own module loader can be reached from the GLOBAL scope,
162
+ * in the order a bundle may offer them. `system_lib://` is the runtime's scheme, so whichever
163
+ * of these answers, it is the same loader underneath.
164
+ *
165
+ * * `require` — the loader the runtime installs globally. A CommonJS bundle also has a
166
+ * module-scoped `require`, but that one is the BUNDLER's; reading the global by name is
167
+ * what keeps the two apart, and it is why this list is read off a scope object rather
168
+ * than written as bare identifiers.
169
+ * * `__nativeRequire` — the same loader under the name the runtime uses internally
170
+ * (measured in @nativescript/android 9.1's libNativeScript.so, beside `ns:module`'s
171
+ * `createRequire`). It is the fallback for a runtime that stops exposing `require`
172
+ * globally, which an ESM-only bundle would otherwise have no way to reach.
173
+ */
174
+ const RUNTIME_REQUIRE_GLOBALS = ['require', '__nativeRequire'];
175
+
176
+ /**
177
+ * Pick the function that can load `system_lib://libnode_gi.so`.
178
+ *
179
+ * WHY THIS IS A CHAIN AND NOT webpack's ONE NAME. `__non_webpack_require__` only exists
180
+ * because webpack REWRITES that free identifier to the runtime's require; under
181
+ * @nativescript/vite (Rolldown, ESM output) nothing rewrites it, so the old single check made
182
+ * every non-webpack NativeScript bundle fail at the first `requireGi` with a message telling
183
+ * the app to switch bundlers — which is not a fix available to a vite app.
184
+ *
185
+ * ORDER MATTERS. webpack's rewrite wins first: in a webpack bundle the module-scoped `require`
186
+ * is webpack's own and would resolve `system_lib://…` against the bundle graph instead of the
187
+ * runtime. Only after that do the runtime's global names get a turn.
188
+ *
189
+ * Pure on purpose — `scope` is an argument so the chain is testable without a device
190
+ * (`test/host-nativescript.test.mjs`).
191
+ *
192
+ * @param {Record<string, unknown>} scope the global object to read the runtime names off
193
+ * @param {unknown} webpackRequire webpack's rewritten `__non_webpack_require__`, if any
194
+ * @returns {{ load: ((id: string) => any) | null, via: string | null }}
195
+ */
196
+ export function resolveNativeRequire(scope, webpackRequire) {
197
+ if (typeof webpackRequire === 'function') {
198
+ return { load: /** @type {(id: string) => any} */ (webpackRequire), via: '__non_webpack_require__' };
199
+ }
200
+ for (const name of RUNTIME_REQUIRE_GLOBALS) {
201
+ const candidate = scope?.[name];
202
+ if (typeof candidate === 'function') {
203
+ return { load: /** @type {(id: string) => any} */ (candidate), via: name };
204
+ }
205
+ }
206
+ return { load: null, via: null };
207
+ }
208
+
209
+ /**
210
+ * Load the addon from the APK's native libs and point GI at the app's typelibs.
211
+ * @returns {Record<string, any>} the native engine
212
+ */
213
+ export function loadNativeHost() {
214
+ configureEnvironment();
215
+ // The bare identifier is what webpack rewrites; everything else is read off the global
216
+ // scope inside resolveNativeRequire.
217
+ const { load } = resolveNativeRequire(
218
+ globalThis,
219
+ typeof __non_webpack_require__ === 'function' ? __non_webpack_require__ : undefined,
220
+ );
221
+ if (load === null) {
222
+ throw new Error(
223
+ '@gjsify/node-gi: the NativeScript host found no module loader to pass ' +
224
+ "'system_lib://libnode_gi.so' to. It looked for __non_webpack_require__ (webpack " +
225
+ `rewrites it) and then for ${RUNTIME_REQUIRE_GLOBALS.map((n) => `globalThis.${n}`).join(' and ')}` +
226
+ " — the names NativeScript's own loader answers to. None of them is a function, which " +
227
+ 'means this is not a NativeScript runtime, or its loader moved.',
228
+ );
229
+ }
230
+ const native = load('system_lib://libnode_gi.so');
231
+ const appDir = appDirectory();
232
+ // `app/` is where NativeScript extracts the bundled assets under the files dir.
233
+ const typelibs = globalThis.__NODE_GI_TYPELIB_PATH ?? (appDir === null ? null : `${appDir}/app/girepository-1.0`);
234
+ if (typeof typelibs === 'string' && typelibs !== '') native.prependSearchPath(typelibs);
235
+ // The addon names the Context provider rather than guessing: on NativeScript that is
236
+ // the runtime's own Application holder, the same one deriveAppDir() reads.
237
+ if (globalThis.__NODE_GI_ANDROID_GDK && typeof native.androidInitGdk === 'function') {
238
+ ensureJavaVm(native);
239
+ native.androidInitGdk('com/tns/NativeScriptApplication', 'getInstance', '()Landroid/app/Application;');
240
+ }
241
+ return native;
242
+ }