@gjsify/node-gi 0.64.2 → 0.64.4

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/README.md CHANGED
@@ -148,6 +148,15 @@ export halves, the legacy `imports.*` modules, cairo, and the measured
148
148
  divergences from GJS — is
149
149
  [docs/node-gi-gjs-surface.md](../../../docs/node-gi-gjs-surface.md).
150
150
 
151
+ One divergence is deliberate rather than measured, and it only exists under the
152
+ `nativescript` export condition: there `registerClass` accepts a parent class
153
+ that is not a GObject class, so an `@nativescript/core` widget can BE a real
154
+ GObject subclass (properties, signals, `bind_property`, `Gio.ListStore`
155
+ identity) while staying the widget its layout demands. Every other host throws
156
+ GJS's invalid-base-class error, unchanged — see
157
+ [§ `registerClass` over a FOREIGN parent class](../../../docs/node-gi-gjs-surface.md)
158
+ and [ADR 0105](../../../docs/adr/0105-real-gobject-backs-the-nativescript-subset-on-android.md).
159
+
151
160
  ## Runtimes (Node / Bun / Deno)
152
161
 
153
162
  The engine is a **Node-API** addon, so the same binary loads and runs on **Node,
package/gi.js CHANGED
@@ -11,7 +11,14 @@ import { ensureNative, onNativeLoaded } from './index.js';
11
11
  // Timers, `process` and the windowing env record come from the host seam (host.node.js
12
12
  // explains why they are the runtime's own, not the globals; host.nativescript.js is the
13
13
  // NativeScript Android variant).
14
- import { setImmediate, setInterval, clearInterval, runtimeProcess, windowingEnvWrites } from '#host';
14
+ import {
15
+ setImmediate,
16
+ setInterval,
17
+ clearInterval,
18
+ runtimeProcess,
19
+ windowingEnvWrites,
20
+ FOREIGN_PARENT_CLASSES,
21
+ } from '#host';
15
22
  import { createGioDBus } from './overrides/gio-dbus.js';
16
23
  import { createGioSettings } from './overrides/gio-settings.js';
17
24
 
@@ -434,7 +441,7 @@ function wrapSignalCallback(cb) {
434
441
  // means `this` is the full userProto-carrying wrapper — the SAME cached proxy the user
435
442
  // constructed. Emitter at param 0, as in wrapSignalCallback.
436
443
  function resolveTemplateCallback(handle, handlerName) {
437
- return (...args) => {
444
+ const dispatcher = (...args) => {
438
445
  const proxy = wrapInstance(handle);
439
446
  const userProto = proxy[USER_PROTO];
440
447
  const desc = userProto !== undefined ? findProtoDescriptor(userProto, handlerName) : undefined;
@@ -445,6 +452,14 @@ function resolveTemplateCallback(handle, handlerName) {
445
452
  }
446
453
  return desc.value.apply(proxy, args.map(wrapReturn));
447
454
  };
455
+ // The native closure holds this dispatcher WEAKLY (src/signals.cc), and the
456
+ // dispatcher captures `handle` — so without a handle-keyed retain it would be
457
+ // garbage the moment create_closure returns, and the widget would be immortal.
458
+ // GJS's Gtk override reaches the same lifetime with
459
+ // `Gi.associateClosure(lifetimeObject, _createClosure(…))`. A fresh key per
460
+ // closure: one handler name can back several `<signal>` elements.
461
+ native.retainClosure(handle, Symbol(handlerName), dispatcher);
462
+ return dispatcher;
448
463
  }
449
464
  onNativeLoaded(() => native.setTemplateCallbackResolver(resolveTemplateCallback));
450
465
 
@@ -464,7 +479,12 @@ function runCtorForCObject(handle, gtypeName) {
464
479
  try {
465
480
  // No props: GtkBuilder already applied the construct properties to the C object;
466
481
  // the ctor's `params` arg is conventionally optional for a template child.
467
- Reflect.construct(cls, []);
482
+ const instance = Reflect.construct(cls, []);
483
+ // A FOREIGN-PARENT class has no makeClass base ctor to consume the latch, and its
484
+ // attach is lazy — so if the ctor body touched nothing, `adoptHandle` would still
485
+ // be set here and a later attach would build a SECOND GObject, orphaning the one
486
+ // C already created. Force the attach while the latch is still live.
487
+ if (adoptHandle !== undefined && isForeignInstance(instance)) foreignHandle(instance);
468
488
  } finally {
469
489
  adoptHandle = prev;
470
490
  }
@@ -1128,6 +1148,13 @@ function assignTemplateChildren(instance, handle, reg) {
1128
1148
  // outside what either runtime honours.
1129
1149
  const propertySettersByProto = new WeakMap();
1130
1150
 
1151
+ // The property setters THIS file generated for a foreign-parent class (route D, see the
1152
+ // FOREIGN-PARENT section). They must be invisible to resolvePropertySetter: a generated
1153
+ // setter's whole body is `native.setProperty`, and the engine answers every custom-property
1154
+ // store by calling runJsPropertySetter — which would resolve the generated setter and store
1155
+ // again, forever. Only a setter the CLASS declared is a JS setter worth replaying.
1156
+ const generatedForeignSetters = new WeakSet();
1157
+
1131
1158
  function findPropertySetter(userProto, propertyName) {
1132
1159
  let perProto = propertySettersByProto.get(userProto);
1133
1160
  if (perProto === undefined) propertySettersByProto.set(userProto, (perProto = new Map()));
@@ -1144,7 +1171,9 @@ function resolvePropertySetter(userProto, propertyName) {
1144
1171
  const camel = propertyName.replace(/-([a-z])/g, (m) => m[1].toUpperCase());
1145
1172
  for (const spelling of [propertyName, underscored, camel]) {
1146
1173
  const desc = findProtoDescriptor(userProto, spelling);
1147
- if (desc !== undefined && typeof desc.set === 'function') return desc.set;
1174
+ if (desc !== undefined && typeof desc.set === 'function' && !generatedForeignSetters.has(desc.set)) {
1175
+ return desc.set;
1176
+ }
1148
1177
  }
1149
1178
  return undefined;
1150
1179
  }
@@ -1587,7 +1616,11 @@ function wrapInstance(handle, userProto) {
1587
1616
  // the leaf's OWN methods resolve). Never DOWNGRADE: a more-derived cached proto is
1588
1617
  // kept. The userProto lives on the target (read via the proxy here), so the
1589
1618
  // upgrade is in place — identity (===) and any expando fields are preserved.
1590
- if (userProto !== undefined && userProto !== cached[USER_PROTO]) {
1619
+ // NEVER re-link a foreign-parent instance (route D): linkInstanceProto does
1620
+ // `Object.setPrototypeOf`, and that object's [[Prototype]] IS its NativeScript
1621
+ // widget chain — replacing it would turn a real Label into something `_addView`
1622
+ // rejects. Its user prototype is already the registered one, fixed at attach.
1623
+ if (userProto !== undefined && userProto !== cached[USER_PROTO] && !isForeignInstance(cached)) {
1591
1624
  const current = cached[USER_PROTO];
1592
1625
  if (current === undefined || isProtoSameOrDescendantOf(userProto, current)) {
1593
1626
  linkInstanceProto(cached, userProto);
@@ -2612,7 +2645,11 @@ function findParentGType(klass) {
2612
2645
  for (let c = Object.getPrototypeOf(klass); typeof c === 'function'; c = Object.getPrototypeOf(c)) {
2613
2646
  const reg = registeredClasses.get(c);
2614
2647
  if (reg !== undefined) {
2615
- return { parentGTypeHandle: reg.typeHandle };
2648
+ // `parentForeign` carries the FOREIGN-PARENT marker down the chain: a subclass
2649
+ // of `registerClass(…, class extends Label {})` has a perfectly ordinary
2650
+ // registered parent by this test, yet ITS instances are still NativeScript
2651
+ // objects that need the attach path rather than the wrapper Proxy.
2652
+ return { parentGTypeHandle: reg.typeHandle, parentForeign: reg.foreign === true };
2616
2653
  }
2617
2654
  const gt = c.$gtypeName;
2618
2655
  if (typeof gt === 'string') {
@@ -2620,9 +2657,19 @@ function findParentGType(klass) {
2620
2657
  if (dot > 0) {
2621
2658
  return { parentNamespace: gt.slice(0, dot), parentType: gt.slice(dot + 1) };
2622
2659
  }
2623
- // A dotless `$gtypeName` with no registeredClasses entry should never occur
2624
- // (only registered types are dotless, and they are in the registry); be
2625
- // explicit rather than mis-resolving the namespace split.
2660
+ // A dotless `$gtypeName` and no registry entry, with the name INHERITED: `c` is
2661
+ // an ordinary unregistered subclass of a registered ancestor further up, reading
2662
+ // that ancestor's static through the class-side prototype chain. Keep walking —
2663
+ // `c` contributes no GType, and the registered ancestor above it is the parent.
2664
+ // gjs answers the same way (measured, 1.88.1): `registerClass(C extends B)` where
2665
+ // `class B extends RegisteredA {}` gives `type_parent(C) === A`. Without this the
2666
+ // walk threw on its own inherited value, which is every port class that puts a
2667
+ // plain subclass between two registrations — `AdwSwitchRow` between the two
2668
+ // route-D mixins (ADR 0105 § 2) is the case that found it.
2669
+ if (!Object.prototype.hasOwnProperty.call(c, '$gtypeName')) continue;
2670
+ // An OWN dotless `$gtypeName` with no registry entry should never occur (only
2671
+ // registered types are dotless, and they are in the registry); be explicit
2672
+ // rather than mis-resolving the namespace split.
2626
2673
  throw new TypeError("GObject.registerClass: parent '" + gt + "' has no resolvable GType");
2627
2674
  }
2628
2675
  }
@@ -2787,6 +2834,335 @@ function collectVfuncs(klass) {
2787
2834
  * @param {Function} [maybeClass]
2788
2835
  * @returns {Function} the same `klass` (now registered)
2789
2836
  */
2837
+ // ---- FOREIGN-PARENT classes: a registered GObject attached to a host widget ----
2838
+ //
2839
+ // THE ONE DELIBERATE DIVERGENCE FROM GJS, and it only exists where GJS cannot run.
2840
+ // Gated on `FOREIGN_PARENT_CLASSES` (#host): true on NativeScript, false everywhere
2841
+ // else, where registerClass keeps throwing GJS's invalid-base error verbatim. See
2842
+ // host.nativescript.js for why that gate is sound, ADR 0105 § 2 for the decision and
2843
+ // docs/node-gi-gjs-surface.md for the surface this adds.
2844
+ //
2845
+ // What it buys: `registerClass({GTypeName, Properties, Signals}, class MyLabel extends
2846
+ // Label {})` where `Label` is a `@nativescript/core` widget. The JS object stays a REAL
2847
+ // Label — its [[Prototype]] is the untouched NativeScript chain — so `stack.addChild(w)`,
2848
+ // `ContentView.content = w` and `_addChildFromBuilder` pass their hard `instanceof
2849
+ // ViewBase` gates with no `.view` unwrap anywhere in consumer code. Beside it sits a real
2850
+ // GObject of a registered GType whose GType PARENT is GObject.Object (the GType parent is
2851
+ // independent of the JS prototype chain), so the object also answers `instanceof
2852
+ // GObject.Object`, carries custom properties with `notify::`, and emits registered signals.
2853
+ //
2854
+ // ATTACH, never wrap. Everywhere else node-gi hands back a Proxy whose get/set traps
2855
+ // resolve the GObject surface dynamically; here there is no Proxy to put traps on, so:
2856
+ // • `[HANDLE]`/`[USER_PROTO]` become non-enumerable OWN props of the widget object
2857
+ // (`Symbol.hasInstance` resolves `instanceof` through `instance[HANDLE]`, which is
2858
+ // what makes the widget a GObject.Object);
2859
+ // • everything the traps would have answered — connect/emit/notify, the property
2860
+ // accessors, the GObject.Object methods — is installed as OWN members of
2861
+ // `klass.prototype`, once, at registerClass time;
2862
+ // • `Object.setPrototypeOf` is never called on such an object (see linkInstanceProto).
2863
+ //
2864
+ // LAZY. The GObject is constructed on the first access that needs it (`foreignHandle`),
2865
+ // because nothing in a NativeScript widget's construction runs node-gi code: there is no
2866
+ // base ctor to route `new` through the way makeClass does. Every entry point goes through
2867
+ // `foreignHandle`, including `unwrapArg` reading `[HANDLE]` through the prototype getter,
2868
+ // so by the time anything can OBSERVE the GObject it exists — `new MyLabel() instanceof
2869
+ // GObject.Object` attaches and answers true. Construct properties are the one thing this
2870
+ // cannot carry (the widget ctor owns its own arguments); set them after construction.
2871
+ //
2872
+ // NOT supported, deliberately: `vfunc_*` overrides. collectVfuncs finds none on a widget
2873
+ // chain, and a vfunc trampoline would need to re-link the instance prototype — the one
2874
+ // thing this path must never do.
2875
+ const FOREIGN_MATERIALISED = Symbol('nodeGiForeignMaterialised');
2876
+
2877
+ // A registered foreign-parent class's PROTOTYPE → its registration. The instance carries
2878
+ // no class pointer we can trust (its `constructor` is the widget's), so the attach walks
2879
+ // the real prototype chain and takes the nearest entry — which is the LEAF's, since a
2880
+ // subclass's own prototype comes first.
2881
+ const foreignRegByProto = new WeakMap();
2882
+
2883
+ // Gated first, so on a host where foreign parents cannot exist this is one boolean read
2884
+ // rather than a prototype-chain walk — it sits on wrapInstance's upgrade path.
2885
+ function isForeignInstance(obj) {
2886
+ return FOREIGN_PARENT_CLASSES && foreignRegFor(obj) !== undefined;
2887
+ }
2888
+
2889
+ function foreignRegFor(obj) {
2890
+ for (let p = Object.getPrototypeOf(obj); p !== null; p = Object.getPrototypeOf(p)) {
2891
+ const reg = foreignRegByProto.get(p);
2892
+ if (reg !== undefined) return reg;
2893
+ }
2894
+ return undefined;
2895
+ }
2896
+
2897
+ // Construct this object's GObject and bind the two together. Not a wrap: the object is
2898
+ // handed back unchanged apart from three non-enumerable own symbols.
2899
+ function attachForeignGObject(obj, reg) {
2900
+ let handle;
2901
+ if (adoptHandle !== undefined) {
2902
+ // A C-created instance (runCtorForCObject) — the engine already built the GObject.
2903
+ // Consume the latch atomically, exactly as the makeClass base ctor does.
2904
+ handle = adoptHandle;
2905
+ adoptHandle = undefined;
2906
+ } else {
2907
+ handle = native.constructType(reg.typeHandle, {});
2908
+ }
2909
+ // HANDLE first: it shadows the prototype getter, so anything the flush below runs can
2910
+ // re-enter `foreignHandle` without constructing a second GObject.
2911
+ Object.defineProperty(obj, HANDLE, { value: handle, configurable: true });
2912
+ Object.defineProperty(obj, USER_PROTO, { value: reg.foreignProto, configurable: true });
2913
+ instanceCache.set(handle, obj);
2914
+ flushPropertiesToJsSetters(obj, handle, reg);
2915
+ return handle;
2916
+ }
2917
+
2918
+ // This object's GObject handle, attaching on first demand. Every member installed below
2919
+ // goes through here, as does `unwrapArg` (via the prototype [HANDLE] getter).
2920
+ function foreignHandle(obj) {
2921
+ if (Object.hasOwn(obj, HANDLE)) return obj[HANDLE];
2922
+ const reg = foreignRegFor(obj);
2923
+ if (reg === undefined) {
2924
+ throw new TypeError('node-gi: this object is not an instance of a GObject.registerClass()-registered class');
2925
+ }
2926
+ return attachForeignGObject(obj, reg);
2927
+ }
2928
+
2929
+ // Which custom properties of this instance hold a materialised value — the state GJS's
2930
+ // generated accessors keep in their private field, and what the equal-value guard
2931
+ // compares against. Same rule as the wrapper's `materialise` (see wrapInstance): seeded
2932
+ // from what the engine has stored, then extended by every read and write.
2933
+ function foreignMaterialise(obj, name) {
2934
+ let seen = obj[FOREIGN_MATERIALISED];
2935
+ if (seen === undefined) {
2936
+ seen = new Set(typeof native.storedPropertyNames === 'function' ? native.storedPropertyNames(obj[HANDLE]) : []);
2937
+ Object.defineProperty(obj, FOREIGN_MATERIALISED, { value: seen, configurable: true });
2938
+ }
2939
+ const had = seen.has(name);
2940
+ seen.add(name);
2941
+ return had;
2942
+ }
2943
+
2944
+ // The GObject.Object instance methods installed as callMethod thunks. A MODEST list on
2945
+ // purpose — only introspectable, GJS-reachable members with no gpointer/GValue-array
2946
+ // argument (`g_object_get_data`, `getv` and friends are deliberately absent rather than
2947
+ // present and throwing). The wrapper Proxy answers these from introspection dynamically;
2948
+ // this path has to name them.
2949
+ const FOREIGN_OBJECT_METHODS = [
2950
+ 'get_property',
2951
+ 'set_property',
2952
+ 'bind_property',
2953
+ 'notify_by_pspec',
2954
+ 'freeze_notify',
2955
+ 'thaw_notify',
2956
+ 'run_dispose',
2957
+ 'ref',
2958
+ 'unref',
2959
+ 'ref_sink',
2960
+ 'force_floating',
2961
+ 'is_floating',
2962
+ ];
2963
+
2964
+ function snakeToCamel(name) {
2965
+ return name.replace(/_([a-z])/g, (m) => m[1].toUpperCase());
2966
+ }
2967
+
2968
+ /** The distinct spellings a member is installed under: one entry for an underscore-free name. */
2969
+ function memberSpellings(name) {
2970
+ const camel = snakeToCamel(name);
2971
+ return camel === name ? [name] : [name, camel];
2972
+ }
2973
+
2974
+ // Install a member under `name` unless the host chain already answers under it — a
2975
+ // widget's own method ALWAYS wins, because consumer code and NativeScript's own
2976
+ // internals call it. Returns false (and the caller records the skip) when it loses.
2977
+ function defineForeignMember(proto, name, descriptor, skipped) {
2978
+ if (findProtoDescriptor(proto, name) !== undefined) {
2979
+ skipped.push(name);
2980
+ return false;
2981
+ }
2982
+ Object.defineProperty(proto, name, { configurable: true, enumerable: false, ...descriptor });
2983
+ return true;
2984
+ }
2985
+
2986
+ // Everything the instance wrapper's traps would have resolved, as own members of a
2987
+ // foreign-parent class's prototype. Returns the names the host chain already owned, so a
2988
+ // program (and the route-D spec) can see exactly what it did NOT get.
2989
+ function installForeignGObjectSurface(proto, properties) {
2990
+ const skipped = [];
2991
+
2992
+ // `[HANDLE]` as a lazily-attaching GETTER is the whole load-bearing trick: it makes
2993
+ // `instanceof GObject.Object`, `unwrapArg` and every member below attach on demand,
2994
+ // with no construction hook on the widget side. An attach replaces it with an own
2995
+ // data property, so this getter runs at most once per instance.
2996
+ Object.defineProperty(proto, HANDLE, {
2997
+ configurable: true,
2998
+ get() {
2999
+ return foreignHandle(this);
3000
+ },
3001
+ });
3002
+
3003
+ defineForeignMember(
3004
+ proto,
3005
+ '$typeName',
3006
+ {
3007
+ get() {
3008
+ return native.getTypeName(foreignHandle(this));
3009
+ },
3010
+ },
3011
+ skipped,
3012
+ );
3013
+
3014
+ // Signal API. The same four thunks the wrapper builds, including the (fn → ids)
3015
+ // bookkeeping signal_handlers_*_by_func resolves through.
3016
+ defineForeignMember(
3017
+ proto,
3018
+ 'connect',
3019
+ {
3020
+ value: function connect(signal, cb) {
3021
+ const handle = foreignHandle(this);
3022
+ const id = native.connectSignal(handle, signal, wrapSignalCallback(cb), false);
3023
+ recordSignalHandler(handle, cb, id);
3024
+ return id;
3025
+ },
3026
+ },
3027
+ skipped,
3028
+ );
3029
+ const connectAfter = function connect_after(signal, cb) {
3030
+ const handle = foreignHandle(this);
3031
+ const id = native.connectSignal(handle, signal, wrapSignalCallback(cb), true);
3032
+ recordSignalHandler(handle, cb, id);
3033
+ return id;
3034
+ };
3035
+ defineForeignMember(proto, 'connect_after', { value: connectAfter }, skipped);
3036
+ defineForeignMember(proto, 'connectAfter', { value: connectAfter }, skipped);
3037
+ defineForeignMember(
3038
+ proto,
3039
+ 'disconnect',
3040
+ {
3041
+ value: function disconnect(id) {
3042
+ const handle = foreignHandle(this);
3043
+ forgetSignalHandlerId(handle, id);
3044
+ return native.disconnectSignal(handle, id);
3045
+ },
3046
+ },
3047
+ skipped,
3048
+ );
3049
+ defineForeignMember(
3050
+ proto,
3051
+ 'emit',
3052
+ {
3053
+ value: function emit(signal, ...args) {
3054
+ return wrapReturn(native.emitSignal(foreignHandle(this), signal, unwrapArgs(args)));
3055
+ },
3056
+ },
3057
+ skipped,
3058
+ );
3059
+
3060
+ // `notify` is the ONE name that cannot be skipped and cannot be taken: NativeScript's
3061
+ // Observable.prototype.notify({eventName, object}) is called by the widget machinery
3062
+ // itself (and by consumer code), while GObject's notify(propertyName) is what a
3063
+ // custom property's setter needs. They are told apart by ARGUMENT TYPE — a string is
3064
+ // a GObject property name, anything else is a NativeScript event payload — and the
3065
+ // inherited implementation is captured HERE, before this definition shadows it, so
3066
+ // both keep working on one object. This is the only collision in the
3067
+ // @nativescript/core 9.1 Label chain (measured: Observable.prototype.notify is the
3068
+ // sole hit for the whole installed surface).
3069
+ const inheritedNotify = findProtoDescriptor(proto, 'notify');
3070
+ const hostNotify = inheritedNotify !== undefined ? inheritedNotify.value : undefined;
3071
+ Object.defineProperty(proto, 'notify', {
3072
+ configurable: true,
3073
+ enumerable: false,
3074
+ value: function notify(arg, ...rest) {
3075
+ if (typeof arg === 'string') {
3076
+ return wrapReturn(native.callMethod(foreignHandle(this), 'notify', [arg]));
3077
+ }
3078
+ if (typeof hostNotify === 'function') return hostNotify.call(this, arg, ...rest);
3079
+ throw new TypeError('notify(): expected a GObject property name');
3080
+ },
3081
+ });
3082
+
3083
+ for (const name of FOREIGN_OBJECT_METHODS) {
3084
+ const thunk = function giMethod(...args) {
3085
+ return wrapReturn(native.callMethod(foreignHandle(this), name, unwrapArgs(args)));
3086
+ };
3087
+ // `memberSpellings(name)` and not `[name, snakeToCamel(name)]`: a name with no
3088
+ // underscore camel-cases to ITSELF (`ref`, `unref`), so the second call would
3089
+ // find the descriptor the first one just installed and record a skip against
3090
+ // the host for a member the host never had. `$foreignSkipped` is read as "what
3091
+ // did the widget already own", so a self-skip there is a false statement.
3092
+ for (const spelling of memberSpellings(name)) {
3093
+ defineForeignMember(proto, spelling, { value: thunk }, skipped);
3094
+ }
3095
+ }
3096
+
3097
+ // gjs's GObject.Object.prototype shims over namespace-level g_signal_* /
3098
+ // bind_property_full — not introspected instance methods, so named explicitly here
3099
+ // just as the wrapper's get trap routes them explicitly.
3100
+ for (const name of [
3101
+ 'block_signal_handler',
3102
+ 'unblock_signal_handler',
3103
+ 'stop_emission_by_name',
3104
+ 'bind_property_full',
3105
+ ]) {
3106
+ for (const spelling of memberSpellings(name)) {
3107
+ const shim = objectPrototypeShim(spelling);
3108
+ defineForeignMember(
3109
+ proto,
3110
+ spelling,
3111
+ {
3112
+ value: function shimmed(...args) {
3113
+ return shim(foreignHandle(this), args);
3114
+ },
3115
+ },
3116
+ skipped,
3117
+ );
3118
+ }
3119
+ }
3120
+
3121
+ installForeignPropertyAccessors(proto, properties, skipped);
3122
+ return skipped;
3123
+ }
3124
+
3125
+ // GJS-style generated accessors for the DECLARED custom properties, under all three
3126
+ // spellings GJS makes equivalent (dash, underscore, camel — `_checkAccessors` mirrors the
3127
+ // declared one onto the other two). Elsewhere node-gi installs none and resolves the
3128
+ // spellings at the point of use instead (findPropertySetter), because the wrapper Proxy
3129
+ // can; this path has no Proxy, so the accessors have to exist. A ROUTE-D-ONLY divergence
3130
+ // from the rest of node-gi, and the reason the equal-value guard is replicated here.
3131
+ //
3132
+ // A spelling the host chain already answers is left ALONE: NativeScript defines `text`,
3133
+ // `width`, … on its own prototypes with real setters driving the native view, and
3134
+ // shadowing one would break the widget. The engine's store still routes to it —
3135
+ // runJsPropertySetter resolves the class-or-chain setter — so declaring a GObject
3136
+ // property named after a widget property composes instead of colliding.
3137
+ function installForeignPropertyAccessors(proto, properties, skipped) {
3138
+ for (const spec of properties) {
3139
+ const name = spec.name;
3140
+ if (typeof name !== 'string' || name.length === 0) continue;
3141
+ const spellings = new Set([
3142
+ name,
3143
+ name.replace(/-/g, '_'),
3144
+ name.replace(/-([a-z])/g, (m) => m[1].toUpperCase()),
3145
+ ]);
3146
+ const get = function () {
3147
+ const handle = foreignHandle(this);
3148
+ foreignMaterialise(this, name);
3149
+ return wrapReturn(native.getProperty(handle, name));
3150
+ };
3151
+ const set = function (value) {
3152
+ const handle = foreignHandle(this);
3153
+ // Equal-value guard, GJS's _generateAccessors setter verbatim — see the same
3154
+ // guard in wrapInstance's set trap for why it is only armed once the value
3155
+ // has been materialised.
3156
+ if (foreignMaterialise(this, name) && wrapReturn(native.getProperty(handle, name)) === value) return;
3157
+ native.setProperty(handle, name, unwrapArg(value));
3158
+ };
3159
+ generatedForeignSetters.add(set);
3160
+ for (const spelling of spellings) {
3161
+ defineForeignMember(proto, spelling, { get, set }, skipped);
3162
+ }
3163
+ }
3164
+ }
3165
+
2790
3166
  // GJS's meta FIELD form: a class may declare its meta as static symbol fields
2791
3167
  // (`Klass[GObject.GTypeName] = 'X'`) instead of passing a meta object, and GJS treats
2792
3168
  // the symbols as the real source — `registerClass(meta, klass)` only copies meta onto
@@ -2826,13 +3202,19 @@ function registerClass(metaOrClass, maybeClass) {
2826
3202
  meta = { ...fieldMeta, ...meta };
2827
3203
 
2828
3204
  const parent = findParentGType(klass);
2829
- if (parent === undefined) {
3205
+ // A parent that is not a node-gi GObject class at all. GJS throws here
3206
+ // ("registerClass() used with invalid base class", refs/gjs GObject.js) and so does
3207
+ // every host but NativeScript — where the FOREIGN-PARENT path instead registers the
3208
+ // GType under GObject.Object and ATTACHES it to the host widget object. See the
3209
+ // FOREIGN-PARENT section above.
3210
+ if (parent === undefined && !FOREIGN_PARENT_CLASSES) {
2830
3211
  throw new TypeError(
2831
3212
  'GObject.registerClass: ' +
2832
3213
  (klass.name || 'the class') +
2833
3214
  ' must extend a node-gi GObject class (e.g. GObject.Object)',
2834
3215
  );
2835
3216
  }
3217
+ const foreign = parent === undefined || parent.parentForeign === true;
2836
3218
 
2837
3219
  const properties = [];
2838
3220
  if (meta.Properties !== null && typeof meta.Properties === 'object') {
@@ -2874,10 +3256,16 @@ function registerClass(metaOrClass, maybeClass) {
2874
3256
  // from its runtime GType handle (it has no introspection entry); an INTROSPECTED
2875
3257
  // parent is resolved by namespace+type. Construction (the leaf's registered GType
2876
3258
  // via the makeClass new.target routing) is already multi-level-correct either way.
3259
+ // A FOREIGN parent contributes no GType at all (it is a host widget class), so the
3260
+ // registered type is rooted at GObject.Object — the GType parent is independent of
3261
+ // the JS prototype chain, which is exactly what makes route D possible. A foreign
3262
+ // SUBCLASS still has a registered parent and takes the ordinary by-GType branch.
2877
3263
  const typeHandle =
2878
- parent.parentGTypeHandle !== undefined
2879
- ? native.registerClassFromGType(gtypeName, parent.parentGTypeHandle, options)
2880
- : native.registerClass(gtypeName, parent.parentNamespace, parent.parentType, options);
3264
+ parent === undefined
3265
+ ? native.registerClass(gtypeName, 'GObject', 'Object', options)
3266
+ : parent.parentGTypeHandle !== undefined
3267
+ ? native.registerClassFromGType(gtypeName, parent.parentGTypeHandle, options)
3268
+ : native.registerClass(gtypeName, parent.parentNamespace, parent.parentType, options);
2881
3269
 
2882
3270
  // G3: register the GType for the GIVEN class IN PLACE (no throwaway Subclass) and
2883
3271
  // return that same class. `$gtypeName`/`$gtype` are defined as OWN properties:
@@ -2917,6 +3305,8 @@ function registerClass(metaOrClass, maybeClass) {
2917
3305
  children: children.length > 0 ? children : undefined,
2918
3306
  internalChildren: internalChildren.length > 0 ? internalChildren : undefined,
2919
3307
  constructPropertyNames: constructPropertyNames.length > 0 ? constructPropertyNames : undefined,
3308
+ foreign: foreign ? true : undefined,
3309
+ foreignProto: foreign ? klass.prototype : undefined,
2920
3310
  });
2921
3311
  // Which properties the equal-value guard owns: the ones DECLARED here, union the
2922
3312
  // registered parent's (a G2 subclass inherits its parent's custom properties and
@@ -2933,6 +3323,36 @@ function registerClass(metaOrClass, maybeClass) {
2933
3323
  // Reverse index for runCtorForCObject: the engine identifies a C-created instance
2934
3324
  // by its GType name (= gtypeName, what native.registerClass registered).
2935
3325
  classesByGType.set(gtypeName, klass);
3326
+ // FOREIGN parent: install the GObject surface as own members of this prototype and
3327
+ // index the prototype, so an instance can find ITS leaf registration by walking the
3328
+ // real (host-widget) prototype chain. Both before type_class_ref, like every record
3329
+ // above it. `foreignSkipped` names the members the host chain already owned — the
3330
+ // honest answer to "what did this class NOT get", which the route-D spec reports.
3331
+ if (foreign) {
3332
+ const reg = registeredClasses.get(klass);
3333
+ reg.foreignSkipped = installForeignGObjectSurface(klass.prototype, properties);
3334
+ foreignRegByProto.set(klass.prototype, reg);
3335
+ }
3336
+ // `$foreignSkipped`, readable from outside, because "which GObject spelling did the
3337
+ // host widget already own?" is the one question this route cannot answer in the
3338
+ // abstract: it depends on the parent class, so it has to be reported per
3339
+ // registration rather than documented once.
3340
+ //
3341
+ // On EVERY registration of this host, not only the foreign ones, and an empty array
3342
+ // where nothing was skipped. A class's static side is a Proxy that answers any
3343
+ // unknown name with a static-method thunk, so `X.$foreignSkipped` is already
3344
+ // truthy — a FUNCTION — wherever the property is absent, and `if
3345
+ // (X.$foreignSkipped.length)` would then throw rather than read "nothing". Defining
3346
+ // it is what makes the read mean what it says. Gated on the host flag all the same,
3347
+ // so the default host's registerClass keeps exactly GJS's static surface.
3348
+ if (FOREIGN_PARENT_CLASSES) {
3349
+ Object.defineProperty(klass, '$foreignSkipped', {
3350
+ value: Object.freeze([...(registeredClasses.get(klass).foreignSkipped ?? [])]),
3351
+ configurable: true,
3352
+ enumerable: false,
3353
+ writable: false,
3354
+ });
3355
+ }
2936
3356
  // GJS parity: registration must be COMPLETE when registerClass returns. The
2937
3357
  // engine installs the custom properties, signals and vfunc trampolines in
2938
3358
  // class_init (src/class.cc), which GObject runs lazily on the first
@@ -3025,6 +3445,12 @@ function signalEmitByName(object, ...nameAndArgs) {
3025
3445
  // makeClass). Merged enums are cached so identity is stable.
3026
3446
  const OVERLAY_NAMES = new Set([
3027
3447
  'registerClass',
3448
+ // Whether THIS host registers a class over a non-GObject parent (the FOREIGN-PARENT
3449
+ // section above) or throws on one. A caller that wants route D has no other way to
3450
+ // ask: the only alternative is to register and catch, which registers a GType as a
3451
+ // side effect of a question and turns "this host cannot" into the same outcome as
3452
+ // "this registration was wrong". Read by @gjsify/adwaita-gobject-native's engine.
3453
+ '$foreignParentClasses',
3028
3454
  'GTypeName',
3029
3455
  'properties',
3030
3456
  'signals',
@@ -3094,6 +3520,7 @@ function decorateGObjectNamespace(baseNs) {
3094
3520
  if (cache.has(prop)) return cache.get(prop);
3095
3521
  let value;
3096
3522
  if (prop === 'registerClass') value = registerClass;
3523
+ else if (prop === '$foreignParentClasses') value = FOREIGN_PARENT_CLASSES;
3097
3524
  else if (prop === 'GTypeName') value = GTypeNameSymbol;
3098
3525
  else if (prop === 'properties') value = propertiesSymbol;
3099
3526
  else if (prop === 'signals') value = signalsSymbol;
@@ -50,6 +50,19 @@ export const RUNTIME = 'nativescript';
50
50
  */
51
51
  export const LAZY_NATIVE = true;
52
52
 
53
+ /**
54
+ * Whether `GObject.registerClass` may register a class whose JS parent is NOT a node-gi
55
+ * GObject class — `class MyLabel extends Label {}`, where `Label` is a
56
+ * `@nativescript/core` widget. GJS rejects that outright and so does node-gi on every
57
+ * other host; this is the ONE deliberate divergence, and it is deliberate because on
58
+ * NativeScript there is no GJS to be compatible with: node-gi is the only GI runtime that
59
+ * ever executes here, so a program written against this cannot be a program that behaves
60
+ * differently under GJS. See the FOREIGN-PARENT section in gi.js, ADR 0105 § 2 and
61
+ * docs/node-gi-gjs-surface.md.
62
+ * @type {boolean}
63
+ */
64
+ export const FOREIGN_PARENT_CLASSES = true;
65
+
53
66
  // Timers from the global scope: NativeScript has no node:timers, and there is no
54
67
  // GLib-backed replacement to avoid here. setImmediate is not a V8 builtin.
55
68
  export const setImmediate =
package/host.node.js CHANGED
@@ -61,6 +61,16 @@ export const RUNTIME =
61
61
  */
62
62
  export const LAZY_NATIVE = false;
63
63
 
64
+ /**
65
+ * Whether `GObject.registerClass` may register a class whose JS parent is NOT a node-gi
66
+ * GObject class. FALSE here, and that is the point: on Node/Bun/Deno/GJS a program can be
67
+ * run under real GJS, so registerClass must keep throwing GJS's invalid-base error
68
+ * (refs/gjs/modules/core/overrides/GObject.js) rather than quietly accept a class GJS
69
+ * would reject. Only host.nativescript.js enables it — see the note there.
70
+ * @type {boolean}
71
+ */
72
+ export const FOREIGN_PARENT_CLASSES = false;
73
+
64
74
  // GJS host mode: `GjsifyNapi.init()` installs the loader as
65
75
  // `globalThis.__gjsifyNapiLoadAddon` and re-installs it if another consumer (the
66
76
  // @gjsify/napi L1) already captured + deleted it. Needs the GjsifyNapi typelib
package/index.d.ts CHANGED
@@ -542,6 +542,18 @@ export function emitSignal(handle: GObjectHandle, signalName: string, args?: unk
542
542
  /** Disconnect a previously connected signal handler. */
543
543
  export function disconnectSignal(handle: GObjectHandle, handlerId: number): void;
544
544
 
545
+ /**
546
+ * INTERNAL. Keep a callback alive for as long as `handle` is alive. Signal and
547
+ * template-callback GClosures hold their JS callback through a WEAK napi_ref, so
548
+ * a connected handler is not a GC root (which is what makes a handler capturing
549
+ * its own emitter collectable, as in GJS); this handle-keyed WeakMap is the
550
+ * ephemeron that keeps it callable meanwhile. `connectSignal` does it for you.
551
+ */
552
+ export function retainClosure(handle: GObjectHandle, key: number | symbol, fn: Function): void;
553
+
554
+ /** INTERNAL. Drop a closure retained by {@link retainClosure}. */
555
+ export function releaseClosure(handle: GObjectHandle, key: number | symbol): void;
556
+
545
557
  /**
546
558
  * Register the L1 resolver mapping a Gtk.Template `<signal handler="…">` handler
547
559
  * name to the instance's bound JS method (engine template-callback scope).
package/index.js CHANGED
@@ -94,9 +94,16 @@ export function ensureNative() {
94
94
  makePumpPendingCount = addon.makePumpPendingCount;
95
95
  pumpKick = addon.pumpKick;
96
96
  setMicrotaskDrain = addon.setMicrotaskDrain;
97
- connectSignal = addon.connectSignal;
98
97
  emitSignal = addon.emitSignal;
99
- disconnectSignal = addon.disconnectSignal;
98
+ connectSignal = (handle, signalName, callback, after) => {
99
+ const id = addon.connectSignal(handle, signalName, callback, after);
100
+ retainClosure(handle, id, callback);
101
+ return id;
102
+ };
103
+ disconnectSignal = (handle, handlerId) => {
104
+ releaseClosure(handle, handlerId);
105
+ return addon.disconnectSignal(handle, handlerId);
106
+ };
100
107
  setTemplateCallbackResolver = addon.setTemplateCallbackResolver;
101
108
  setConstructCallback = addon.setConstructCallback;
102
109
  setPropertySetCallback = addon.setPropertySetCallback;
@@ -205,6 +212,52 @@ export let mainContextHasPending;
205
212
  export let makePumpPendingCount;
206
213
  export let pumpKick;
207
214
  export let setMicrotaskDrain;
215
+ // ---- signal-closure keep-alive (GJS's associate_closure, built in JS) ----
216
+ //
217
+ // A signal / template-callback GClosure holds its JS callback through a WEAK
218
+ // napi_ref (src/signals.cc), so a C-held closure is NOT a GC root. That is what
219
+ // makes `obj.connect('x', () => obj.y)` collectable instead of immortal, and it
220
+ // is GJS's design: its signal closures keep the callable unrooted and TRACE it
221
+ // from the owning wrapper (ObjectInstance::m_closures).
222
+ //
223
+ // node-gi has no tracing hook into V8, so the same edge is expressed as the one
224
+ // ephemeron a non-extensible External can take part in: a WeakMap keyed by the
225
+ // canonical handle. Handle reachable (JS holds the wrapper, or C owns the object
226
+ // and the toggle ref roots the handle) → callback reachable → a connected handler
227
+ // still fires. Handle reachable ONLY through the callback it retains → key and
228
+ // value die together, which is exactly the cycle that used to leak.
229
+ //
230
+ // Pruned on disconnect. A handler disconnected by a route that bypasses
231
+ // `disconnectSignal` (the introspected GObject.signal_handler_disconnect) leaves
232
+ // its entry behind until the handle itself goes — harmless, the closure is
233
+ // already gone, and the same divergence the gi.js handler-id registry documents.
234
+ const retainedClosures = new WeakMap();
235
+
236
+ /**
237
+ * Keep `fn` alive for as long as `handle` is alive. INTERNAL — the JS half of the
238
+ * weak closure refs in src/signals.cc.
239
+ * @param {object} handle canonical GObject handle (the WeakMap key / lifetime)
240
+ * @param {number | symbol} key handler id, or a unique key for a non-signal closure
241
+ * @param {Function} fn the callback the native closure holds weakly
242
+ */
243
+ export function retainClosure(handle, key, fn) {
244
+ let byKey = retainedClosures.get(handle);
245
+ if (byKey === undefined) {
246
+ byKey = new Map();
247
+ retainedClosures.set(handle, byKey);
248
+ }
249
+ byKey.set(key, fn);
250
+ }
251
+
252
+ /**
253
+ * Drop a retained closure (on disconnect). INTERNAL — see retainClosure.
254
+ * @param {object} handle canonical GObject handle
255
+ * @param {number | symbol} key the key it was retained under
256
+ */
257
+ export function releaseClosure(handle, key) {
258
+ retainedClosures.get(handle)?.delete(key);
259
+ }
260
+
208
261
  export let connectSignal;
209
262
  export let emitSignal;
210
263
  export let disconnectSignal;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/node-gi",
3
- "version": "0.64.2",
3
+ "version": "0.64.4",
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/signals.cc CHANGED
@@ -7,22 +7,36 @@ namespace nodegi {
7
7
 
8
8
  // ---- signals (milestone 1) ----
9
9
  //
10
- // A GClosure that wraps a JS callback. The callback is held by a strong
11
- // napi_ref; the closure's finalize notifier drops it. The generic marshal
12
- // converts the signal's GValue params to JS — the EMITTER instance (param 0)
13
- // first, then the signal's own params (1..n), matching GJS — and the JS return
14
- // into the signal return GValue.
10
+ // A GClosure that wraps a JS callback. The closure's finalize notifier drops the
11
+ // napi_ref. The generic marshal converts the signal's GValue params to JS — the
12
+ // EMITTER instance (param 0) first, then the signal's own params (1..n), matching
13
+ // GJS — and the JS return into the signal return GValue.
15
14
  //
16
- // Narrowed-leak semantics (with the toggle-ref bridge): a handler that does NOT
17
- // close over its own object is now collectable once C is the sole owner
18
- // (toggle-down → weak → GC). A handler that DOES close over its object forms a
19
- // GObject -> GClosure -> napi_ref -> callback -> wrapper -> handle cycle the GC
20
- // cannot break across C, so it keeps the object alive until disconnect — which is
21
- // the GJS-faithful contract (a connected self-referential handler is a reason to
22
- // stay alive). disconnect drops the closure's napi_ref (JsClosureFinalize),
23
- // breaking the cycle so the next GC collects the object. The callback ref stays
24
- // STRONG while connected (a connected handler must fire). Verified by the
25
- // signal-cycle case in test/gc-identity.test.mjs.
15
+ // REF STRENGTH IS THE WHOLE CONTRACT, and it differs per closure KIND, exactly as
16
+ // in GJS (refs/gjs/gi/closure.h: `create_marshaled` roots, `create_for_signal`
17
+ // does not):
18
+ //
19
+ // * GENERIC closure (a JS function marshalled as a GClosure IN-arg, invoked by
20
+ // arbitrary C) — STRONG. Nothing on the JS side owns it; the ref IS the owner.
21
+ // * SIGNAL closure (`.connect()`) and TEMPLATE-CALLBACK closure — WEAK. A
22
+ // handler that captures its own emitter otherwise forms a
23
+ // GObject → GClosure → napi_ref → callback → wrapper → toggle ref → GObject
24
+ // cycle that spans C, and no GC can break it: `obj.connect('x', () => obj.y)`
25
+ // made `obj` immortal. GJS has the identical cycle on paper and collects it,
26
+ // because its signal closures keep the callable UNROOTED and trace it from
27
+ // the owning wrapper (ObjectInstance::m_closures / trace_impl), which puts
28
+ // the whole cycle inside one heap the GC can reason about.
29
+ //
30
+ // node-gi has no tracing hook, so the equivalent edge is built in JS: index.js
31
+ // retains the callback in a WeakMap keyed by the canonical handle — the only
32
+ // ephemeron V8 offers over a non-extensible External, and the same shape
33
+ // `associate_closure` has. The handle is reachable exactly when GJS would trace
34
+ // the closure (JS holds the wrapper, or C owns the object and toggle.cc roots
35
+ // it), so a connected handler still fires for as long as anything can emit to it,
36
+ // and the self-referential cycle collects. A collected callback leaves the weak
37
+ // ref empty and the marshal returns early (it already tolerates a null value).
38
+ // Measured against gjs 1.88.1 with a FinalizationRegistry witness; verified by the
39
+ // signal-cycle cases in test/gc-identity.test.mjs.
26
40
 
27
41
  struct JsClosureData {
28
42
  napi_env env;
@@ -191,8 +205,11 @@ static void JsGenericClosureMarshal(GClosure* closure, GValue* return_value, gui
191
205
 
192
206
  // JS function → a floating marshaled GClosure (the engine's twin of GJS's
193
207
  // Gjs::Closure::create_marshaled for "boxed" closure args — refs/gjs/gi/
194
- // arg-cache.cpp GClosureInTransferNone::in). The closure holds a strong ref to
195
- // the function; JsClosureFinalize drops it when the closure is invalidated /
208
+ // arg-cache.cpp GClosureInTransferNone::in). The closure holds a STRONG ref to
209
+ // the function — deliberately, and unlike the signal/template closures above:
210
+ // `create_marshaled` defaults `root = true` (refs/gjs/gi/closure.h), because no
211
+ // JS-side owner exists for a closure handed to arbitrary C. JsClosureFinalize
212
+ // drops it when the closure is invalidated /
196
213
  // finalized (the callee released its last ref, or the invoke-site released the
197
214
  // only ref because the callee never kept one). Ref/sink + release policy lives at
198
215
  // the marshalling site (marshal.cc / calls.cc CreatedClosures).
@@ -231,9 +248,11 @@ Napi::Value ConnectSignal(const Napi::CallbackInfo& info) {
231
248
  return env.Null();
232
249
  }
233
250
 
251
+ // WEAK (refcount 0) — the keep-alive is the JS-side handle→callback edge
252
+ // index.js installs; see the ref-strength contract at the top of this file.
234
253
  JsClosureData* jc = g_new0(JsClosureData, 1);
235
254
  jc->env = env;
236
- napi_create_reference(env, info[2], 1, &jc->callback);
255
+ napi_create_reference(env, info[2], 0, &jc->callback);
237
256
 
238
257
  GClosure* closure = g_closure_new_simple(sizeof(GClosure), jc);
239
258
  g_closure_set_marshal(closure, JsClosureMarshal);
@@ -468,13 +487,17 @@ static GClosure* NodeGiScopeCreateClosure(void* selfScope, void* builder,
468
487
  }
469
488
 
470
489
  // Wrap the resolved (instance-bound, arg-wrapping) JS function in a JsClosure —
471
- // the same closure type a `.connect()` produces. The closure owns a strong ref to
472
- // the function; g_object_watch_closure ties its lifetime to the widget (the
473
- // handler `this`) and invalidates it when the widget is finalized, exactly like
490
+ // the same closure type a `.connect()` produces, and WEAK for the same reason:
491
+ // the resolver's dispatcher captures the widget handle, so a strong ref made
492
+ // every templated widget immortal. gi.js's resolveTemplateCallback retains the
493
+ // dispatcher on the widget handle before returning it, which is what GJS's Gtk
494
+ // override does with `Gi.associateClosure(lifetimeObject, …)`
495
+ // (refs/gjs/modules/core/overrides/Gtk.js). g_object_watch_closure additionally
496
+ // ties the closure to the widget and invalidates it on finalize, exactly like
474
497
  // GtkBuilderCScope's own create_closure.
475
498
  JsClosureData* jc = g_new0(JsClosureData, 1);
476
499
  jc->env = rawEnv;
477
- napi_create_reference(rawEnv, resolved, 1, &jc->callback);
500
+ napi_create_reference(rawEnv, resolved, 0, &jc->callback);
478
501
  GClosure* closure = g_closure_new_simple(sizeof(GClosure), jc);
479
502
  g_closure_set_marshal(closure, JsClosureMarshal);
480
503
  g_closure_add_finalize_notifier(closure, jc, JsClosureFinalize);