@gjsify/node-gi 0.59.1 → 0.61.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/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
@@ -809,6 +809,12 @@ function handlerIdsForFunc(handle, fn) {
809
809
  // routing keys on the leaf so construction is correct at any depth.
810
810
  const registeredClasses = new Map();
811
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
+
812
818
  // GTypeName → registered JS class, the reverse of registeredClasses. The engine's
813
819
  // NodeGiConstructor hands runCtorForCObject (below) the C-created instance's GType
814
820
  // NAME (g_type_name), which this resolves back to the class to Reflect.construct.
@@ -1309,6 +1315,33 @@ function wrapInstance(handle, userProto) {
1309
1315
  const target = { [HANDLE]: handle };
1310
1316
  const proto = userProto ?? protoForInstance(handle);
1311
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
+ };
1312
1345
  const proxy = new Proxy(target, {
1313
1346
  get(t, prop) {
1314
1347
  if (prop === HANDLE) return handle;
@@ -1400,6 +1433,14 @@ function wrapInstance(handle, userProto) {
1400
1433
  if (shim !== undefined) return (...args) => shim(handle, args);
1401
1434
  const propName = toKebab(prop);
1402
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
+ }
1403
1444
  return wrapReturn(native.getProperty(handle, propName));
1404
1445
  }
1405
1446
  // LAST resort before treating an unknown name as a GI method: an INHERITED
@@ -1444,6 +1485,29 @@ function wrapInstance(handle, userProto) {
1444
1485
  }
1445
1486
  const propName = toKebab(prop);
1446
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
+ }
1447
1511
  native.setProperty(handle, propName, unwrapArg(value));
1448
1512
  return true;
1449
1513
  }
@@ -1695,10 +1759,17 @@ function makeClass(namespace, typeName) {
1695
1759
  handle = native.constructType(reg.typeHandle, props ? unwrapProps(props) : {});
1696
1760
  }
1697
1761
  const instance = wrapInstance(handle, nt.prototype);
1698
- assignTemplateChildren(instance, handle, reg);
1699
1762
  // Route construct-time property values through the class's JS setters now
1700
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.
1701
1771
  flushPropertiesToJsSetters(instance, handle, reg);
1772
+ assignTemplateChildren(instance, handle, reg);
1702
1773
  return instance;
1703
1774
  }
1704
1775
  // `nt` is a subclass of this introspected GObject class but was NEVER passed to
@@ -2349,6 +2420,19 @@ function collectVfuncs(klass) {
2349
2420
  * @param {Function} [maybeClass]
2350
2421
  * @returns {Function} the same `klass` (now registered)
2351
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
+
2352
2436
  function registerClass(metaOrClass, maybeClass) {
2353
2437
  let meta;
2354
2438
  let klass;
@@ -2363,6 +2447,17 @@ function registerClass(metaOrClass, maybeClass) {
2363
2447
  throw new TypeError('GObject.registerClass: expected a class to register');
2364
2448
  }
2365
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
+
2366
2461
  const parent = findParentGType(klass);
2367
2462
  if (parent === undefined) {
2368
2463
  throw new TypeError(
@@ -2456,6 +2551,18 @@ function registerClass(metaOrClass, maybeClass) {
2456
2551
  internalChildren: internalChildren.length > 0 ? internalChildren : undefined,
2457
2552
  constructPropertyNames: constructPropertyNames.length > 0 ? constructPropertyNames : undefined,
2458
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);
2459
2566
  // Reverse index for runCtorForCObject: the engine identifies a C-created instance
2460
2567
  // by its GType name (= gtypeName, what native.registerClass registered).
2461
2568
  classesByGType.set(gtypeName, klass);
@@ -2551,6 +2658,9 @@ function signalEmitByName(object, ...nameAndArgs) {
2551
2658
  // makeClass). Merged enums are cached so identity is stable.
2552
2659
  const OVERLAY_NAMES = new Set([
2553
2660
  'registerClass',
2661
+ 'GTypeName',
2662
+ 'properties',
2663
+ 'signals',
2554
2664
  'ParamSpec',
2555
2665
  'ParamFlags',
2556
2666
  'SignalFlags',
@@ -2617,6 +2727,9 @@ function decorateGObjectNamespace(baseNs) {
2617
2727
  if (cache.has(prop)) return cache.get(prop);
2618
2728
  let value;
2619
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;
2620
2733
  else if (prop === 'ParamSpec') value = ParamSpec;
2621
2734
  else if (prop === 'ParamFlags') value = mergeFlags(baseNs.ParamFlags, ParamFlags);
2622
2735
  else if (prop === 'SignalFlags') value = mergeFlags(baseNs.SignalFlags, SignalFlags);
@@ -157,18 +157,74 @@ function configureEnvironment() {
157
157
  setNativeEnv('XDG_RUNTIME_DIR', `${appDir}/.run`);
158
158
  }
159
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
+
160
209
  /**
161
210
  * Load the addon from the APK's native libs and point GI at the app's typelibs.
162
211
  * @returns {Record<string, any>} the native engine
163
212
  */
164
213
  export function loadNativeHost() {
165
214
  configureEnvironment();
166
- // webpack rewrites __non_webpack_require__ to the runtime's own `require`.
167
- const load = typeof __non_webpack_require__ === 'function' ? __non_webpack_require__ : null;
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
+ );
168
221
  if (load === null) {
169
222
  throw new Error(
170
- '@gjsify/node-gi: the NativeScript host needs __non_webpack_require__ ' +
171
- '(bundle with @nativescript/webpack, which adds the `nativescript` resolve condition).',
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.',
172
228
  );
173
229
  }
174
230
  const native = load('system_lib://libnode_gi.so');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/node-gi",
3
- "version": "0.59.1",
3
+ "version": "0.61.0",
4
4
  "description": "GObject-Introspection runtime for Node.js — load gi:// namespaces (GLib/GObject/Gio/…) with GJS-compatible semantics. Vendored + rewritten from node-gtk under MIT.",
5
5
  "type": "module",
6
6
  "license": "MIT",
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/src/marshal.cc CHANGED
@@ -605,6 +605,40 @@ bool JsToGIArgument(Napi::Env env, Napi::Value v, GITypeInfo* type, GIArgument*
605
605
  out->v_string = const_cast<char*>(heldString->c_str());
606
606
  }
607
607
  return true;
608
+ case GI_TYPE_TAG_VOID:
609
+ // A bare `gpointer` IN argument: the `user_data` of the log handlers
610
+ // (`GLib.log_default_handler(domain, level, message, null)`), of
611
+ // `g_log_writer_default`, and of every introspected function that takes a
612
+ // closure's data separately. There is nothing to marshal INTO a void* from
613
+ // JS, so gjs installs Arg::NullIn for this tag (refs/gjs/gi/arg-cache.cpp
614
+ // ArgsCache::build_normal_in_arg) — it writes NULL and ignores the value
615
+ // entirely. Without this arm the whole CALL failed with "Unsupported IN
616
+ // argument type tag 0", which is how `GLib.log_default_handler` — the only
617
+ // introspectable way to make GLib write to fd 1/2, and so the only way to
618
+ // SEE the Android stdout/stderr redirect from JS — was unreachable.
619
+ //
620
+ // Deliberate divergence: a value that is not null/undefined is REFUSED here
621
+ // where gjs drops it silently. node-gi cannot turn a JS value into a
622
+ // gpointer, and a caller that passes one meant it to arrive; swallowing it
623
+ // hides the mistake at the only place that can still name the argument.
624
+ // (The nullability of the arg is not known at this level — JsToGIArgument
625
+ // sees the GITypeInfo, not the GIArgInfo — so a non-nullable void* gets
626
+ // NULL instead of gjs's "argument may not be null". In the introspected API
627
+ // such an argument is `allow-none` throughout.)
628
+ if (!gi_type_info_is_pointer(type)) return true; // plain `void`: no slot to fill
629
+ if (v.IsNull() || v.IsUndefined()) {
630
+ out->v_pointer = nullptr;
631
+ return true;
632
+ }
633
+ {
634
+ std::string msg = std::string("Expected null for pointer ") +
635
+ (argName != nullptr ? std::string("argument '") + argName + "'"
636
+ : std::string("argument")) +
637
+ " but got type " + InformalValueTypeName(v) +
638
+ " (node-gi cannot marshal a JS value into a gpointer)";
639
+ Napi::TypeError::New(env, msg).ThrowAsJavaScriptException();
640
+ }
641
+ return false;
608
642
  case GI_TYPE_TAG_INTERFACE: {
609
643
  // Object/interface instances arrive as opaque External<GObject> handles;
610
644
  // enums/flags as plain numbers. Other interface kinds (structs/unions/
package/src/signals.cc CHANGED
@@ -270,6 +270,19 @@ Napi::Value EmitSignal(const Napi::CallbackInfo& info) {
270
270
  g_signal_query(sigid, &query);
271
271
 
272
272
  guint n = query.n_params;
273
+ // ARITY, gjs's message verbatim ("Signal 'changed' on ArSig requires 2 args got 1",
274
+ // measured on gjs 1.88.1 — which rejects too MANY as well as too few). Without it a
275
+ // missing argument was marshalled from `undefined`: a signal declared (string, int)
276
+ // and emitted with one argument reached its handlers with a silent "" / 0 in the
277
+ // gap, so the caller's mistake surfaced as wrong data somewhere downstream instead
278
+ // of as a throw at the emit.
279
+ if (args.Length() != n) {
280
+ Napi::Error::New(env, std::string("Signal '") + name + "' on " + G_OBJECT_TYPE_NAME(obj) +
281
+ " requires " + std::to_string(n) + " args got " +
282
+ std::to_string(args.Length()))
283
+ .ThrowAsJavaScriptException();
284
+ return env.Null();
285
+ }
273
286
  std::vector<GValue> params(n + 1); // [0] = instance
274
287
  g_value_init(&params[0], gtype);
275
288
  g_value_set_object(&params[0], obj);
@@ -279,7 +292,7 @@ Napi::Value EmitSignal(const Napi::CallbackInfo& info) {
279
292
  GType pt = query.param_types[i] & ~G_SIGNAL_TYPE_STATIC_SCOPE;
280
293
  g_value_init(&params[i + 1], pt);
281
294
  initialised = i + 2;
282
- Napi::Value v = i < args.Length() ? args.Get(i) : env.Undefined();
295
+ Napi::Value v = args.Get(i);
283
296
  // An EMPTY v is the residue of a swallowed args.Get() failure (terminating
284
297
  // env / throwing getter): JsToGValue's coercions on it would abort via
285
298
  // Error::New(nullptr)'s fatal sites — bail before marshalling.