@gjsify/node-gi 0.13.0 → 0.21.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/src/class.cc ADDED
@@ -0,0 +1,1303 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // registerClass: subtype registration, custom properties/signals, vfunc overrides + chain-up.
3
+
4
+ #include "common.h"
5
+
6
+ namespace nodegi {
7
+
8
+ // ---- subclassing (registerClass — minimal: subtype + construct) ----
9
+ //
10
+ // Register a new GObject subclass of `parentNamespace.parentTypeName` named
11
+ // `name`, inheriting the parent's class/instance layout. Supports custom
12
+ // properties + signals (installed in class_init — see below) and vfunc overrides.
13
+ // A dynamic subtype's instances are ordinary GObjects owned through the canonical
14
+ // toggle-ref bridge (so a JS-subclassed instance kept by C stays rooted, keeping
15
+ // its overridden-vfunc wrapper + JS state alive). Returns an opaque type handle
16
+ // (the GType) for constructType().
17
+
18
+ // GTypes are process-stable and never freed (static registration), so the
19
+ // handle carries no finalizer. registerClass returns a kGTypeHandleTag External,
20
+ // so validate the tag (a GObject/boxed handle is also an External, but holds a
21
+ // non-GType pointer — reinterpreting it as a GType would be type-confusion).
22
+ static GType UnwrapGType(Napi::Env env, Napi::Value v) {
23
+ GType gt = ReadGTypeHandle(v);
24
+ if (gt == 0) {
25
+ Napi::TypeError::New(env, "expected a node-gi type handle from registerClass()")
26
+ .ThrowAsJavaScriptException();
27
+ return 0;
28
+ }
29
+ return gt;
30
+ }
31
+
32
+ // ---- registerClass custom properties + signals (class_init) ----
33
+ //
34
+ // A registered subclass can declare custom GObject properties and signals.
35
+ // In class_init we install the GParamSpecs + override get/set_property (routing
36
+ // custom props to a per-instance value store) and g_signal_newv each signal.
37
+ // Inherited (introspected-parent) properties still flow through the parent's
38
+ // vfuncs — a property is "ours" iff its owner GType carries node-gi class-data.
39
+ // Backing the values with a per-instance store (not C struct fields) keeps a
40
+ // plain dynamic subtype's instances ordinary GObjects, owned through the
41
+ // canonical toggle-ref bridge above.
42
+
43
+ // Map a JS type-name to a GType (shared by property + signal specs).
44
+ static GType TypeNameToGType(const std::string& t) {
45
+ if (t == "string" || t == "utf8") return G_TYPE_STRING;
46
+ if (t == "boolean" || t == "bool") return G_TYPE_BOOLEAN;
47
+ if (t == "int") return G_TYPE_INT;
48
+ if (t == "uint") return G_TYPE_UINT;
49
+ if (t == "int64") return G_TYPE_INT64;
50
+ if (t == "uint64") return G_TYPE_UINT64;
51
+ if (t == "double") return G_TYPE_DOUBLE;
52
+ if (t == "float") return G_TYPE_FLOAT;
53
+ if (t == "object") return G_TYPE_OBJECT;
54
+ if (t == "void" || t == "none") return G_TYPE_NONE;
55
+ // The GByteArray boxed type (a byte-array signal param / property). Named
56
+ // explicitly (not just via g_type_from_name) so referencing G_TYPE_BYTE_ARRAY
57
+ // also REGISTERS it — g_type_from_name returns 0 for a type never yet touched.
58
+ if (t == "GByteArray" || t == "bytearray") return G_TYPE_BYTE_ARRAY;
59
+ // General fallback: any already-registered GType by its canonical name (e.g.
60
+ // "GBytes", "GdkRGBA", an enum/boxed/object type). Keeps the fast-path names
61
+ // above (they're the common shorthands) while letting a fully-qualified GType
62
+ // name resolve — matching gjs, which accepts a GType for a signal param type.
63
+ GType named = g_type_from_name(t.c_str());
64
+ if (named != G_TYPE_INVALID) return named;
65
+ return G_TYPE_INVALID;
66
+ }
67
+
68
+ // Build a floating GParamSpec from a JS spec `{ name, type, flags?, default?,
69
+ // minimum?, maximum? }`. Returns nullptr + sets *err on an unsupported type.
70
+ static GParamSpec* BuildParamSpec(Napi::Env env, Napi::Object spec, std::string* err) {
71
+ if (!spec.Has("name") || !spec.Get("name").IsString()) {
72
+ *err = "property requires a string 'name'";
73
+ return nullptr;
74
+ }
75
+ std::string name = spec.Get("name").As<Napi::String>().Utf8Value();
76
+ std::string type = (spec.Has("type") && spec.Get("type").IsString())
77
+ ? spec.Get("type").As<Napi::String>().Utf8Value()
78
+ : std::string("string");
79
+ GParamFlags flags = (spec.Has("flags") && spec.Get("flags").IsNumber())
80
+ ? static_cast<GParamFlags>(spec.Get("flags").As<Napi::Number>().Int32Value())
81
+ : G_PARAM_READWRITE;
82
+ Napi::Value def = spec.Get("default");
83
+ bool hasMin = spec.Has("minimum") && spec.Get("minimum").IsNumber();
84
+ bool hasMax = spec.Has("maximum") && spec.Get("maximum").IsNumber();
85
+ double mn = hasMin ? spec.Get("minimum").As<Napi::Number>().DoubleValue() : 0;
86
+ double mx = hasMax ? spec.Get("maximum").As<Napi::Number>().DoubleValue() : 0;
87
+ const char* nm = name.c_str();
88
+
89
+ if (type == "string" || type == "utf8") {
90
+ std::string d = def.IsString() ? def.As<Napi::String>().Utf8Value() : std::string();
91
+ return g_param_spec_string(nm, nm, nm, def.IsString() ? d.c_str() : nullptr, flags);
92
+ }
93
+ if (type == "boolean" || type == "bool") {
94
+ gboolean d = def.IsBoolean() ? def.As<Napi::Boolean>().Value()
95
+ : (def.IsNumber() ? NodeGiToBool(def) : FALSE);
96
+ return g_param_spec_boolean(nm, nm, nm, d, flags);
97
+ }
98
+ if (type == "int") {
99
+ return g_param_spec_int(nm, nm, nm, hasMin ? static_cast<gint>(mn) : G_MININT,
100
+ hasMax ? static_cast<gint>(mx) : G_MAXINT,
101
+ def.IsNumber() ? def.As<Napi::Number>().Int32Value() : 0, flags);
102
+ }
103
+ if (type == "uint") {
104
+ return g_param_spec_uint(nm, nm, nm, hasMin ? static_cast<guint>(mn) : 0,
105
+ hasMax ? static_cast<guint>(mx) : G_MAXUINT,
106
+ def.IsNumber() ? def.As<Napi::Number>().Uint32Value() : 0, flags);
107
+ }
108
+ if (type == "int64") {
109
+ return g_param_spec_int64(nm, nm, nm, hasMin ? static_cast<gint64>(mn) : G_MININT64,
110
+ hasMax ? static_cast<gint64>(mx) : G_MAXINT64,
111
+ def.IsNumber() ? def.As<Napi::Number>().Int64Value() : 0, flags);
112
+ }
113
+ if (type == "uint64") {
114
+ return g_param_spec_uint64(nm, nm, nm, hasMin ? static_cast<guint64>(mn) : 0,
115
+ hasMax ? static_cast<guint64>(mx) : G_MAXUINT64,
116
+ def.IsNumber() ? static_cast<guint64>(def.As<Napi::Number>().Int64Value())
117
+ : 0,
118
+ flags);
119
+ }
120
+ if (type == "double") {
121
+ return g_param_spec_double(nm, nm, nm, hasMin ? mn : -G_MAXDOUBLE, hasMax ? mx : G_MAXDOUBLE,
122
+ def.IsNumber() ? def.As<Napi::Number>().DoubleValue() : 0, flags);
123
+ }
124
+ if (type == "float") {
125
+ return g_param_spec_float(nm, nm, nm, hasMin ? static_cast<gfloat>(mn) : -G_MAXFLOAT,
126
+ hasMax ? static_cast<gfloat>(mx) : G_MAXFLOAT,
127
+ def.IsNumber() ? static_cast<gfloat>(def.As<Napi::Number>().DoubleValue())
128
+ : 0,
129
+ flags);
130
+ }
131
+ if (type == "object" || type == "boxed") {
132
+ // An object- or boxed-typed property (GObject.ParamSpec.object / .boxed). The
133
+ // value GType comes from the spec's `gtype` field — a node-gi GType handle
134
+ // (the L1 resolves a class ctor's `$gtype` to it). g_param_spec_object/boxed
135
+ // own no default beyond NULL, so `default`/`minimum`/`maximum` are ignored.
136
+ GType valueGType = spec.Has("gtype") ? ReadGTypeHandle(spec.Get("gtype")) : 0;
137
+ if (valueGType == 0) {
138
+ *err = "a '" + type + "' property requires a gtype (a class or GType handle)";
139
+ return nullptr;
140
+ }
141
+ if (type == "object") {
142
+ if (!g_type_is_a(valueGType, G_TYPE_OBJECT)) {
143
+ *err = std::string("an 'object' property gtype must be a GObject type, got ") +
144
+ g_type_name(valueGType);
145
+ return nullptr;
146
+ }
147
+ return g_param_spec_object(nm, nm, nm, valueGType, flags);
148
+ }
149
+ if (!G_TYPE_IS_BOXED(valueGType)) {
150
+ *err = std::string("a 'boxed' property gtype must be a boxed type, got ") +
151
+ g_type_name(valueGType);
152
+ return nullptr;
153
+ }
154
+ return g_param_spec_boxed(nm, nm, nm, valueGType, flags);
155
+ }
156
+ *err = "unsupported property type '" + type + "'";
157
+ return nullptr;
158
+ }
159
+
160
+ // ---- registerClass vfunc overrides (class-level refs; no toggle-ref) ----
161
+ //
162
+ // A registered subclass can override a parent GObject vfunc with a JS function.
163
+ // Each override is held by a per-CLASS record carrying a STRONG napi_ref to the
164
+ // JS impl plus the ffi closure written into the class vtable. Both live for the
165
+ // class lifetime and are NEVER freed — a GType is process-permanent, so this is
166
+ // the same ownership model as the signal class-handler (the override fn is
167
+ // class-level, not per-instance). The INSTANCE the trampoline passes as `this`
168
+ // goes through WrapGObject, so it resolves to the canonical toggle-ref wrapper —
169
+ // the same handle construct returns (and that vfunc `this` keeps that wrapper +
170
+ // its JS state alive while C owns a JS-subclassed instance).
171
+ //
172
+ // In class_init the vfunc info is resolved by walking the parent object-info
173
+ // chain (gi_object_info_find_vfunc), the vtable slot is located via the matching
174
+ // class-struct FIELD offset (gi_vfunc_info_get_offset is GI_UNKNOWN/0xFFFF for
175
+ // GObject's own vfuncs, so the GJS field-offset approach is authoritative), and
176
+ // the closure's native address is written into the class struct at that offset.
177
+ //
178
+ // CHAIN-UP: immediately BEFORE the trampoline address is written, the value
179
+ // currently in the vtable slot is captured in `parentPtr` — at class_init time
180
+ // the new type's class struct is a memcpy of the parent's, so the slot holds the
181
+ // parent's implementation (the C default, or a JS override further up the chain).
182
+ // That captured pointer is the `super.vfunc_<name>(...)` target: callParentVfunc
183
+ // ffi_call's it through `cif` (which already describes the exact instance+args→ret
184
+ // signature). Mirrors GJS's gi/object.cpp, where the introspected base's vfunc
185
+ // thunk calls the actual C parent vtable entry.
186
+ struct NodeGiVFunc {
187
+ napi_env env;
188
+ std::string name;
189
+ napi_ref fn; // strong ref to the JS impl (class lifetime; never freed)
190
+ GIVFuncInfo* info; // resolved vfunc info (owned; kept alive for the closure)
191
+ ffi_cif cif; // stable storage for the closure's cif (also used to call up)
192
+ ffi_closure* closure; // ffi closure (class lifetime; never freed)
193
+ gpointer parentPtr; // parent vtable fn captured pre-override (chain-up target)
194
+ };
195
+
196
+ // The ffi closure entry point invoked when C calls the overridden vfunc. For a
197
+ // method vfunc the ffi args are [instance, declared-arg-0, declared-arg-1, ...];
198
+ // the instance is passed as the JS receiver (`this`, GJS-faithful: vfunc impls
199
+ // are methods on the instance) and the declared args become the JS arguments.
200
+ // The return is marshalled into `result` exactly like NodeGiCallbackTrampoline.
201
+ static void NodeGiVFuncTrampoline(ffi_cif* /*cif*/, void* result, void** args,
202
+ gpointer user_data) {
203
+ NodeGiVFunc* vf = static_cast<NodeGiVFunc*>(user_data);
204
+ napi_env env = vf->env;
205
+ // ENV-TEARDOWN GATE: a dispose cascade can invoke this vfunc while the env may
206
+ // no longer enter JS — e.g. a queued idle teardown dispatched by RunCleanup's
207
+ // CleanupHandles() (after can_call_into_js=false), or a terminating worker's
208
+ // finalizer unref. Re-entering N-API then aborts the process via node-addon-api's
209
+ // noexcept throw path. Degrade to a no-op with a zeroed return slot — GJS-faithful
210
+ // (GJS never runs JS during GC/context teardown).
211
+ if (!NodeGiJsAvailable(env)) {
212
+ if (result != nullptr) static_cast<GIArgument*>(result)->v_uint64 = 0;
213
+ if (NodeGiToggleDebugEnabled())
214
+ NodeGiToggleDebugLog("vfunc '%s' skipped: JS unavailable on env %p (teardown/terminate)",
215
+ vf->name.c_str(), static_cast<void*>(env));
216
+ return;
217
+ }
218
+ Napi::Env napiEnv(env);
219
+ Napi::HandleScope scope(napiEnv);
220
+ // JS dispatched from a pump-driven context iteration must not inherit the
221
+ // pump's in-iteration flag (see NodeGiPumpJsDispatchScope in common.h).
222
+ NodeGiPumpJsDispatchScope pumpWindow;
223
+
224
+ GICallableInfo* ci = reinterpret_cast<GICallableInfo*>(vf->info);
225
+ // args[0] is the instance; declared args follow at args[1..].
226
+ Napi::Value recv = WrapGObject(
227
+ napiEnv, static_cast<GObject*>(static_cast<GIArgument*>(args[0])->v_pointer),
228
+ GI_TRANSFER_NOTHING);
229
+
230
+ unsigned int n = gi_callable_info_get_n_args(ci);
231
+ std::vector<napi_value> jsArgs;
232
+ jsArgs.reserve(n);
233
+ bool ok = true;
234
+ for (unsigned int i = 0; i < n; i++) {
235
+ GIArgInfo* ai = gi_callable_info_get_arg(ci, i);
236
+ GITypeInfo* ti = gi_arg_info_get_type_info(ai);
237
+ Napi::Value v =
238
+ GIArgumentToJs(napiEnv, ti, static_cast<GIArgument*>(args[i + 1]), GI_TRANSFER_NOTHING);
239
+ gi_base_info_unref(ti);
240
+ gi_base_info_unref(ai);
241
+ if (napiEnv.IsExceptionPending()) {
242
+ ok = false;
243
+ break;
244
+ }
245
+ jsArgs.push_back(v);
246
+ }
247
+
248
+ // Zero the result slot first (it is >= ffi_arg wide; narrow returns leave the
249
+ // upper bytes indeterminate otherwise).
250
+ if (result != nullptr) static_cast<GIArgument*>(result)->v_uint64 = 0;
251
+
252
+ GITypeInfo* retType = gi_callable_info_get_return_type(ci);
253
+ if (ok) {
254
+ napi_value fn = nullptr;
255
+ if (napi_get_reference_value(env, vf->fn, &fn) == napi_ok && fn != nullptr) {
256
+ napi_value ret = nullptr;
257
+ // napi_make_callback drains nextTick/microtasks around the call (Node; on
258
+ // Bun/Deno the checkpoint is run by NodeGiMaybeDrainMicrotasks below); the
259
+ // wrapped instance is the receiver (`this`).
260
+ g_loopDispatchDepth++;
261
+ napi_status st =
262
+ napi_make_callback(env, nullptr, recv, fn, jsArgs.size(), jsArgs.data(), &ret);
263
+ g_loopDispatchDepth--;
264
+ if (st == napi_ok && result != nullptr) {
265
+ GITypeTag rtag = gi_type_info_get_tag(retType);
266
+ if (rtag == GI_TYPE_TAG_UTF8 || rtag == GI_TYPE_TAG_FILENAME) {
267
+ // Hand the caller an owned copy — a JsToGIArgument string would point
268
+ // into a std::string that dies with this frame.
269
+ Napi::Value rv(env, ret);
270
+ static_cast<GIArgument*>(result)->v_string =
271
+ rv.IsString() ? g_strdup(rv.As<Napi::String>().Utf8Value().c_str()) : nullptr;
272
+ } else if (rtag != GI_TYPE_TAG_VOID) {
273
+ std::string held;
274
+ JsToGIArgument(napiEnv, Napi::Value(env, ret), retType, static_cast<GIArgument*>(result),
275
+ &held);
276
+ }
277
+ }
278
+ }
279
+ }
280
+ gi_base_info_unref(retType);
281
+ // A pending JS exception surfaces at the next N-API boundary (e.g. when the
282
+ // constructType / method call that triggered this vfunc returns).
283
+ //
284
+ // Cross-runtime microtask checkpoint (Bun/Deno — no-op on Node, see loop.cc):
285
+ // an outermost vfunc boundary drains queued promise continuations, matching
286
+ // Node's napi_make_callback checkpoint (which fires here for BOTH a
287
+ // loop-dispatched vfunc — a GTK measure/snapshot during a blocking run — and
288
+ // one under a synchronous JS caller). Skipped while an exception is pending
289
+ // (NodeGiMaybeDrainMicrotasks gates on NodeGiJsAvailable), so the
290
+ // leave-pending contract above is unaffected.
291
+ if (g_loopDispatchDepth == 0) NodeGiMaybeDrainMicrotasks(env);
292
+ }
293
+
294
+ static GQuark NodeGiClassDataQuark() {
295
+ static GQuark q = g_quark_from_static_string("node-gi-class-data");
296
+ return q;
297
+ }
298
+ static GQuark NodeGiInstancePropsQuark() {
299
+ static GQuark q = g_quark_from_static_string("node-gi-instance-props");
300
+ return q;
301
+ }
302
+
303
+ static void FreeStoredGValue(gpointer p) {
304
+ GValue* v = static_cast<GValue*>(p);
305
+ g_value_unset(v);
306
+ g_free(v);
307
+ }
308
+
309
+ // Nearest ancestor (incl. self) carrying node-gi class-data.
310
+ NodeGiClassData* FindClassData(GType type) {
311
+ for (GType t = type; t != 0; t = g_type_parent(t)) {
312
+ NodeGiClassData* cd = static_cast<NodeGiClassData*>(g_type_get_qdata(t, NodeGiClassDataQuark()));
313
+ if (cd != nullptr) return cd;
314
+ }
315
+ return nullptr;
316
+ }
317
+
318
+ // Find the DEEPEST vfunc override record named `name` walking up from the instance
319
+ // type — the registered level CLOSEST to the introspected base (the last matching
320
+ // record before the ancestry runs out of node-gi class-data). This is the correct
321
+ // chain-up target for `super.vfunc_<name>()` on a multi-level registered chain:
322
+ // `super` between two REGISTERED levels resolves via the JS PROTOTYPE chain (the
323
+ // ancestor's `vfunc_<name>` is a real JS method, called directly), so every
324
+ // registered level's JS impl has already run by the time the chain bottoms out at
325
+ // the introspected base's prototype and hits the chain-up thunk → callParentVfunc.
326
+ // At that single point we must invoke the C-side default below the deepest
327
+ // registered level — exactly the deepest record's captured `parentPtr` (its parent
328
+ // is introspected, so the captured slot is the C vtable entry, never another JS
329
+ // trampoline). Returning the NEAREST record instead would point parentPtr back at
330
+ // an intermediate level's trampoline and re-run that level (a double-run, or an
331
+ // infinite loop when it chains up again). Single-level chains have exactly one
332
+ // record, so deepest == nearest and behaviour is unchanged.
333
+ static NodeGiVFunc* FindDeepestVFuncRecord(GType type, const std::string& name) {
334
+ NodeGiVFunc* deepest = nullptr;
335
+ for (GType t = type; t != 0; t = g_type_parent(t)) {
336
+ NodeGiClassData* cd = static_cast<NodeGiClassData*>(g_type_get_qdata(t, NodeGiClassDataQuark()));
337
+ if (cd != nullptr) {
338
+ for (NodeGiVFunc* vf : cd->vfuncs) {
339
+ if (vf->name == name) deepest = vf; // keep the last (deepest) match
340
+ }
341
+ }
342
+ }
343
+ return deepest;
344
+ }
345
+
346
+ // A property is custom iff its owner GType carries node-gi class-data; otherwise
347
+ // it is inherited from the introspected parent and chains to the parent vfunc.
348
+ static void NodeGiGetProperty(GObject* obj, guint prop_id, GValue* value, GParamSpec* pspec) {
349
+ NodeGiClassData* ownerCd =
350
+ static_cast<NodeGiClassData*>(g_type_get_qdata(pspec->owner_type, NodeGiClassDataQuark()));
351
+ if (ownerCd != nullptr) {
352
+ GHashTable* store = static_cast<GHashTable*>(g_object_get_qdata(obj, NodeGiInstancePropsQuark()));
353
+ GValue* stored = store ? static_cast<GValue*>(g_hash_table_lookup(store, pspec->name)) : nullptr;
354
+ if (stored != nullptr && G_IS_VALUE(stored)) {
355
+ g_value_copy(stored, value);
356
+ } else {
357
+ g_param_value_set_default(pspec, value);
358
+ }
359
+ return;
360
+ }
361
+ NodeGiClassData* cd = FindClassData(G_OBJECT_TYPE(obj));
362
+ if (cd != nullptr && cd->parentGet != nullptr) cd->parentGet(obj, prop_id, value, pspec);
363
+ }
364
+
365
+ static void NodeGiSetProperty(GObject* obj, guint prop_id, const GValue* value, GParamSpec* pspec) {
366
+ NodeGiClassData* ownerCd =
367
+ static_cast<NodeGiClassData*>(g_type_get_qdata(pspec->owner_type, NodeGiClassDataQuark()));
368
+ if (ownerCd != nullptr) {
369
+ GHashTable* store = static_cast<GHashTable*>(g_object_get_qdata(obj, NodeGiInstancePropsQuark()));
370
+ if (store == nullptr) {
371
+ store = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, FreeStoredGValue);
372
+ g_object_set_qdata_full(obj, NodeGiInstancePropsQuark(), store,
373
+ reinterpret_cast<GDestroyNotify>(g_hash_table_destroy));
374
+ }
375
+ GValue* copy = g_new0(GValue, 1);
376
+ g_value_init(copy, G_VALUE_TYPE(value));
377
+ g_value_copy(value, copy);
378
+ g_hash_table_replace(store, g_strdup(pspec->name), copy);
379
+ g_object_notify_by_pspec(obj, pspec);
380
+ return;
381
+ }
382
+ NodeGiClassData* cd = FindClassData(G_OBJECT_TYPE(obj));
383
+ if (cd != nullptr && cd->parentSet != nullptr) cd->parentSet(obj, prop_id, value, pspec);
384
+ }
385
+
386
+ // ---- run the JS constructor for C/GtkBuilder-instantiated instances ----
387
+ //
388
+ // node-gi runs a registered class's JS constructor ONLY on a JS-side `new Sub()`
389
+ // (the makeClass new.target path in gi.js routes it to constructType, and ES
390
+ // super-substitution runs the ctor body). A GObject built from C — a GtkBuilder
391
+ // composite-template InternalChild is the common case — never reaches that path,
392
+ // so its ctor body (e.g. `this._x = …`) never runs. GJS solves this by overriding
393
+ // the GObjectClass `constructor` vfunc so BOTH paths funnel through it
394
+ // (refs/gjs/gi/gobject.cpp gjs_object_constructor). We mirror that: NodeGiConstructor
395
+ // chains to the first non-node-gi C constructor to create the instance, then — only
396
+ // for the C-driven path — runs the JS ctor on the canonical wrapper via the L1
397
+ // construct callback. The JS-`new` path is unchanged (the latch NodeGiJsConstructing
398
+ // is raised by ConstructGObject, so we return the instance and let makeClass run it).
399
+
400
+ // Thread-local so a worker_threads env has its own latch (GtkBuilder construction
401
+ // runs on the same thread as the ConstructGObject that raised it).
402
+ static thread_local bool g_nodegiJsConstructing = false;
403
+ bool NodeGiJsConstructing() { return g_nodegiJsConstructing; }
404
+ void NodeGiSetJsConstructing(bool v) { g_nodegiJsConstructing = v; }
405
+
406
+ static void RunJsConstructorForCObject(napi_env env, GObject* obj) {
407
+ // Teardown/terminate: never enter JS during GC/context teardown (GJS-faithful;
408
+ // same gate as the vfunc trampoline). The C object is still fully constructed.
409
+ if (!NodeGiJsAvailable(env)) return;
410
+ Napi::Env napiEnv(env);
411
+ Napi::HandleScope scope(napiEnv);
412
+ // JS dispatched from a pump-driven iteration must not inherit the in-iteration
413
+ // flag (mirrors the vfunc trampoline).
414
+ NodeGiPumpJsDispatchScope pumpWindow;
415
+
416
+ // Build this instance's OWN composite template first (a no-op unless it carries
417
+ // one), so the JS ctor's assignTemplateChildren sees bound children — the JS-`new`
418
+ // path does this in ConstructGObject before makeClass reads them.
419
+ MaybeInitTemplate(napiEnv, obj);
420
+
421
+ NodeGiEnvData* d = EnvData(env);
422
+ napi_value cb = nullptr;
423
+ if (d == nullptr || d->constructCallback == nullptr ||
424
+ napi_get_reference_value(env, d->constructCallback, &cb) != napi_ok || cb == nullptr) {
425
+ return; // L1 hasn't registered the callback (nothing to run)
426
+ }
427
+ napi_value handleVal = WrapGObject(napiEnv, obj, GI_TRANSFER_NOTHING);
428
+ napi_value nameVal = nullptr;
429
+ napi_create_string_utf8(env, g_type_name(G_OBJECT_TYPE(obj)), NAPI_AUTO_LENGTH, &nameVal);
430
+ napi_value args[2] = {handleVal, nameVal};
431
+ napi_value undef = nullptr;
432
+ napi_get_undefined(env, &undef);
433
+ napi_value ret = nullptr;
434
+ // A plain call (not napi_make_callback): the ctor is synchronous and we must not
435
+ // drain the microtask queue mid-g_object_new. Matches GJS's template-callback
436
+ // resolver + gjs_object_constructor's JS::Construct (no checkpoint).
437
+ napi_status st = napi_call_function(env, undef, cb, 2, args, &ret);
438
+ if (st != napi_ok) {
439
+ // The ctor threw. Never leave a pending JS exception across the return into C
440
+ // (GObject/GtkBuilder can't handle it) — clear it and fold the message into a
441
+ // g_warning (GJS logs the uncaught exception here too).
442
+ napi_value ex = nullptr;
443
+ if (napi_get_and_clear_last_exception(env, &ex) == napi_ok && ex != nullptr) {
444
+ napi_value msg = nullptr;
445
+ std::string detail;
446
+ if (napi_get_named_property(env, ex, "message", &msg) == napi_ok) {
447
+ size_t len = 0;
448
+ if (napi_get_value_string_utf8(env, msg, nullptr, 0, &len) == napi_ok) {
449
+ detail.resize(len);
450
+ napi_get_value_string_utf8(env, msg, detail.data(), len + 1, &len);
451
+ }
452
+ }
453
+ g_warning("node-gi: JS constructor for %s threw: %s", g_type_name(G_OBJECT_TYPE(obj)),
454
+ detail.empty() ? "(no message)" : detail.c_str());
455
+ }
456
+ }
457
+ }
458
+
459
+ // The overridden GObjectClass `constructor` vfunc (installed on every registered
460
+ // type in NodeGiClassInit). GJS parity: gi/gobject.cpp gjs_object_constructor.
461
+ static GObject* NodeGiConstructor(GType type, guint n_construct_properties,
462
+ GObjectConstructParam* construct_properties) {
463
+ // Chain to the first non-node-gi (C) constructor UP the hierarchy, skipping our
464
+ // own — that actually allocates + constructs the instance (running the real C
465
+ // construction chain). GJS does the identical skip-walk (gobject.cpp:155).
466
+ GType parent = g_type_parent(type);
467
+ while (parent != 0) {
468
+ gpointer pk = g_type_class_peek(parent);
469
+ if (pk == nullptr || G_OBJECT_CLASS(pk)->constructor != NodeGiConstructor) break;
470
+ parent = g_type_parent(parent);
471
+ }
472
+ gpointer pk = parent != 0 ? g_type_class_peek(parent) : nullptr;
473
+ GObject* obj = (pk != nullptr && G_OBJECT_CLASS(pk)->constructor != nullptr)
474
+ ? G_OBJECT_CLASS(pk)->constructor(type, n_construct_properties,
475
+ construct_properties)
476
+ : nullptr;
477
+ if (obj == nullptr) return nullptr;
478
+
479
+ // JS-`new` path: the makeClass super-substitution runs the user ctor after
480
+ // g_object_new returns — do NOT run it here (would double-run).
481
+ if (NodeGiJsConstructing()) return obj;
482
+
483
+ // C/GtkBuilder-driven: the user ctor has no other driver. Run it on the leaf
484
+ // registered class's env (correct across worker envs).
485
+ NodeGiClassData* cd = FindClassData(G_OBJECT_TYPE(obj));
486
+ if (cd != nullptr && cd->env != nullptr) RunJsConstructorForCObject(cd->env, obj);
487
+ return obj;
488
+ }
489
+
490
+ static void NodeGiClassInit(gpointer g_class, gpointer class_data) {
491
+ NodeGiClassData* cd = static_cast<NodeGiClassData*>(class_data);
492
+ GObjectClass* oc = G_OBJECT_CLASS(g_class);
493
+ g_type_set_qdata(G_TYPE_FROM_CLASS(g_class), NodeGiClassDataQuark(), cd);
494
+
495
+ // Route construction through NodeGiConstructor so a C/GtkBuilder-instantiated
496
+ // instance (a composite-template InternalChild) gets its JS constructor run —
497
+ // GJS parity (gi/gobject.cpp). The JS-`new` path is unaffected: NodeGiConstructor
498
+ // returns early while the NodeGiJsConstructing() latch is raised.
499
+ oc->constructor = NodeGiConstructor;
500
+
501
+ if (!cd->properties.empty()) {
502
+ cd->parentGet = oc->get_property; // capture before override (chain target)
503
+ cd->parentSet = oc->set_property;
504
+ oc->get_property = NodeGiGetProperty;
505
+ oc->set_property = NodeGiSetProperty;
506
+ guint id = 1;
507
+ for (GParamSpec* p : cd->properties) {
508
+ g_object_class_install_property(oc, id++, p);
509
+ }
510
+ }
511
+ for (const NodeGiSignalDef& s : cd->signals) {
512
+ g_signal_newv(s.name.c_str(), G_TYPE_FROM_CLASS(g_class), s.flags, nullptr, nullptr, nullptr,
513
+ nullptr, s.returnType, static_cast<guint>(s.paramTypes.size()),
514
+ s.paramTypes.empty() ? nullptr : const_cast<GType*>(s.paramTypes.data()));
515
+ }
516
+
517
+ if (!cd->vfuncs.empty()) {
518
+ GType newType = G_TYPE_FROM_CLASS(g_class);
519
+ GType parentType = g_type_parent(newType);
520
+ GIRepository* repo = gi_repository_dup_default();
521
+ for (NodeGiVFunc* vf : cd->vfuncs) {
522
+ // Resolve the vfunc info by walking the parent object-info chain; the
523
+ // declarer's class struct holds the vtable slot we write into.
524
+ GIVFuncInfo* vi = nullptr;
525
+ GIObjectInfo* declarer = nullptr;
526
+ for (GType t = parentType; t != 0 && vi == nullptr; t = g_type_parent(t)) {
527
+ GIBaseInfo* bi = gi_repository_find_by_gtype(repo, t);
528
+ if (bi != nullptr) {
529
+ if (GI_IS_OBJECT_INFO(bi)) {
530
+ vi = gi_object_info_find_vfunc(reinterpret_cast<GIObjectInfo*>(bi), vf->name.c_str());
531
+ if (vi != nullptr)
532
+ declarer = reinterpret_cast<GIObjectInfo*>(gi_base_info_ref(bi));
533
+ }
534
+ gi_base_info_unref(bi);
535
+ }
536
+ }
537
+ if (vi == nullptr) {
538
+ g_warning("node-gi: registerClass vfunc '%s' not found on any ancestor of %s",
539
+ vf->name.c_str(), g_type_name(newType));
540
+ continue;
541
+ }
542
+
543
+ // Locate the vtable slot. gi_vfunc_info_get_offset is GI_UNKNOWN (0xFFFF)
544
+ // for GObject's own vfuncs, so match the vfunc name to a class-struct field
545
+ // and use that field's offset (the GJS approach); fall back to the recorded
546
+ // offset only when the field lookup fails.
547
+ int offset = -1;
548
+ GIStructInfo* cs = gi_object_info_get_class_struct(declarer);
549
+ if (cs != nullptr) {
550
+ unsigned int nf = gi_struct_info_get_n_fields(cs);
551
+ for (unsigned int fi = 0; fi < nf && offset < 0; fi++) {
552
+ GIFieldInfo* f = gi_struct_info_get_field(cs, fi);
553
+ const char* fn = gi_base_info_get_name(reinterpret_cast<GIBaseInfo*>(f));
554
+ if (fn != nullptr && vf->name == fn) offset = gi_field_info_get_offset(f);
555
+ gi_base_info_unref(reinterpret_cast<GIBaseInfo*>(f));
556
+ }
557
+ gi_base_info_unref(reinterpret_cast<GIBaseInfo*>(cs));
558
+ }
559
+ if (offset < 0) {
560
+ size_t off = gi_vfunc_info_get_offset(vi);
561
+ if (off != 0 && off != 0xFFFF) offset = static_cast<int>(off);
562
+ }
563
+ if (offset < 0) {
564
+ g_warning("node-gi: could not resolve a vtable slot for vfunc '%s' on %s",
565
+ vf->name.c_str(), g_type_name(newType));
566
+ gi_base_info_unref(reinterpret_cast<GIBaseInfo*>(vi));
567
+ gi_base_info_unref(reinterpret_cast<GIBaseInfo*>(declarer));
568
+ continue;
569
+ }
570
+
571
+ // Create the ffi closure and write its native address into the class vtable.
572
+ // The vfunc info + closure + cif are kept alive for the class lifetime.
573
+ vf->info = vi; // retained (kept alive); never unref'd — class is permanent
574
+ vf->closure = gi_callable_info_create_closure(reinterpret_cast<GICallableInfo*>(vi), &vf->cif,
575
+ NodeGiVFuncTrampoline, vf);
576
+ gpointer native =
577
+ gi_callable_info_get_closure_native_address(reinterpret_cast<GICallableInfo*>(vi),
578
+ vf->closure);
579
+ if (native == nullptr) native = vf->closure;
580
+ // Capture the parent implementation BEFORE overwriting the slot: at this
581
+ // point g_class is a memcpy of the parent class struct, so the slot holds
582
+ // the parent's vfunc pointer (the C default, or a JS override further up).
583
+ // That is the super.vfunc_<name>() chain-up target (see callParentVfunc).
584
+ gpointer* slotAddr = reinterpret_cast<gpointer*>(reinterpret_cast<guint8*>(g_class) + offset);
585
+ vf->parentPtr = *slotAddr;
586
+ *slotAddr = native;
587
+ gi_base_info_unref(reinterpret_cast<GIBaseInfo*>(declarer));
588
+ }
589
+ g_object_unref(repo);
590
+ }
591
+
592
+ // ---- Gtk.Widget composite template ----
593
+ // Install the template + bind the declared children on the new GtkWidgetClass.
594
+ // g_class is the new type's class struct, which derives from GtkWidgetClass for
595
+ // any Gtk.Widget subtype — exactly the pointer gtk_widget_class_set_template*
596
+ // expects. Done in class_init, the idiomatic GTK lifecycle point (C widgets
597
+ // call set_template in their class_init too). get_template_child / init_template
598
+ // run later, per instance.
599
+ if (cd->hasTemplate) {
600
+ const GtkTemplateApi* gtk = GetGtkTemplateApi();
601
+ GType widgetType = g_type_from_name("GtkWidget");
602
+ bool isWidget = widgetType != 0 && g_type_is_a(G_TYPE_FROM_CLASS(g_class), widgetType);
603
+ if (gtk->ok && isWidget) {
604
+ if (!cd->cssName.empty()) gtk->set_css_name(g_class, cd->cssName.c_str());
605
+ if (cd->templateBytes != nullptr) {
606
+ gtk->set_template(g_class, cd->templateBytes);
607
+ } else if (!cd->templateResource.empty()) {
608
+ gtk->set_template_from_resource(g_class, cd->templateResource.c_str());
609
+ }
610
+ for (const std::string& c : cd->children)
611
+ gtk->bind_template_child_full(g_class, c.c_str(), FALSE, 0);
612
+ for (const std::string& c : cd->internalChildren)
613
+ gtk->bind_template_child_full(g_class, c.c_str(), TRUE, 0);
614
+ // Install a generic template-callback scope so any `<signal handler="…">` in
615
+ // the template resolves to the instance's JS method (mirrors GJS's
616
+ // set_template_scope path). Must follow set_template; used during
617
+ // init_template. Independent of the child binding above — Children /
618
+ // InternalChildren behaviour is unchanged.
619
+ NodeGiInstallTemplateScopeOnClass(cd, g_class);
620
+ } else if (!isWidget) {
621
+ g_warning(
622
+ "node-gi: a Template was set on %s, which is not a Gtk.Widget subclass — "
623
+ "composite templates require a Gtk.Widget ancestor; ignoring the template",
624
+ g_type_name(G_TYPE_FROM_CLASS(g_class)));
625
+ } else {
626
+ g_warning(
627
+ "node-gi: a Gtk.Widget Template was requested but the libgtk-4 template "
628
+ "API could not be resolved (is GTK 4 installed?)");
629
+ }
630
+ }
631
+ }
632
+
633
+ // callParentVfunc(handle, vfuncName, args?) -> unknown
634
+ //
635
+ // Chain up to the parent implementation of an overridden vfunc — the engine half
636
+ // of `super.vfunc_<name>(...)`. Resolves the NodeGiVFunc record for `vfuncName`
637
+ // nearest the instance's type (the record whose trampoline currently owns the
638
+ // vtable slot) and ffi_call's its captured parentPtr — the function that was in
639
+ // the slot BEFORE the override was installed (the C default, or a JS override
640
+ // further up the chain). The same `cif` the override's closure was built from
641
+ // describes the call signature (instance + declared args → return), so it is
642
+ // reused to call out. `this` (args[0]) goes back in as the instance, keeping the
643
+ // canonical toggle-ref wrapper identity. Marshals IN args (JsToGIArgument) +
644
+ // the return (gi_type_info_extract_ffi_return_value → GIArgumentToJs); throws a
645
+ // GLib.Error for a can-throw vfunc whose parent set the GError.
646
+ //
647
+ // OUT / INOUT args: routed through per-arg storage slots exactly like the
648
+ // function-invoke path (calls.cc InvokeFunctionInfo). The parent's ffi signature
649
+ // takes a POINTER for each OUT/INOUT param, so giArgs[1+i] carries &slots[i] (the
650
+ // stable storage the C parent writes THROUGH) — INOUT slots are pre-marshalled from
651
+ // the JS input, OUT slots start zeroed; a caller-allocates OUT (fixed C array /
652
+ // boxed struct) gets a g_malloc0 blob instead. The JS caller passes only IN + INOUT
653
+ // args positionally (OUT params are engine-managed), and the result follows the GJS
654
+ // return-tuple convention `[returnValue?, ...outArgs]` — one value bare, several as
655
+ // an Array, matching exactly what a JS override of that vfunc receives as its call
656
+ // args. Read-back reuses ReadOutOrReturn (array-length slots, containers, boxed).
657
+ // INOUT containers stay deferred (the same rare, ownership-tricky case the function
658
+ // path defers) with a clear, catchable throw BEFORE the ffi_call.
659
+ //
660
+ // MULTI-LEVEL chain-up (registered chains, G2): chains to the DEEPEST registered
661
+ // override's captured parent (the C-side default below the whole registered chain),
662
+ // NOT the nearest. On a multi-level chain the level-to-level `super.vfunc_<name>()`
663
+ // hops are resolved by the JS PROTOTYPE chain (each registered ancestor's
664
+ // `vfunc_<name>` is a real JS method, invoked directly), so every level's JS impl
665
+ // has already run by the time the chain bottoms out at the introspected base's
666
+ // prototype and reaches this thunk — at which point the only thing left to call is
667
+ // the C default. Using the nearest record would re-enter an intermediate level's
668
+ // trampoline (a double-run / infinite loop). See FindDeepestVFuncRecord.
669
+ Napi::Value CallParentVfunc(const Napi::CallbackInfo& info) {
670
+ Napi::Env env = info.Env();
671
+ if (info.Length() < 2 || !info[1].IsString()) {
672
+ Napi::TypeError::New(env, "callParentVfunc(handle, vfuncName: string, args?: unknown[])")
673
+ .ThrowAsJavaScriptException();
674
+ return env.Null();
675
+ }
676
+ GObject* obj = UnwrapGObject(env, info[0]);
677
+ if (obj == nullptr) return env.Null(); // UnwrapGObject threw or it was null
678
+ std::string vname = info[1].As<Napi::String>().Utf8Value();
679
+ Napi::Array args = (info.Length() >= 3 && info[2].IsArray()) ? info[2].As<Napi::Array>()
680
+ : Napi::Array::New(env, 0);
681
+
682
+ NodeGiVFunc* vf = FindDeepestVFuncRecord(G_OBJECT_TYPE(obj), vname);
683
+ if (vf == nullptr || vf->parentPtr == nullptr || vf->info == nullptr) {
684
+ Napi::Error::New(env, "no parent vfunc '" + vname + "' to chain up to on " +
685
+ g_type_name(G_OBJECT_TYPE(obj)) +
686
+ " (is it overridden by a registerClass subclass?)")
687
+ .ThrowAsJavaScriptException();
688
+ return env.Null();
689
+ }
690
+
691
+ GICallableInfo* ci = reinterpret_cast<GICallableInfo*>(vf->info);
692
+ unsigned int nDeclared = gi_callable_info_get_n_args(ci);
693
+ bool canThrow = gi_callable_info_can_throw_gerror(ci);
694
+
695
+ // Per-arg direction (used both to marshal IN/INOUT input and to read OUT/INOUT
696
+ // back after the call).
697
+ std::vector<GIDirection> dirs(nDeclared);
698
+ for (unsigned int i = 0; i < nDeclared; i++) {
699
+ GIArgInfo* ai = gi_callable_info_get_arg(ci, i);
700
+ dirs[i] = gi_arg_info_get_direction(ai);
701
+ gi_base_info_unref(ai);
702
+ }
703
+
704
+ // Array-length args (of an arg array OR of the return array) are engine-managed:
705
+ // never JS-consumed, never surfaced on their own. An OUT/INOUT length slot is
706
+ // wired so the callee writes the count we then size the array with; an IN length
707
+ // is autofilled from the array's element count. Mirrors InvokeFunctionInfo.
708
+ std::vector<bool> skip(nDeclared, false);
709
+ std::vector<bool> isLenArg(nDeclared, false);
710
+ for (unsigned int i = 0; i < nDeclared; i++) {
711
+ GIArgInfo* ai = gi_callable_info_get_arg(ci, i);
712
+ GITypeInfo* ti = gi_arg_info_get_type_info(ai);
713
+ if (gi_type_info_get_tag(ti) == GI_TYPE_TAG_ARRAY) {
714
+ unsigned int L = 0;
715
+ if (gi_type_info_get_array_length_index(ti, &L) && L < nDeclared) {
716
+ skip[L] = true;
717
+ isLenArg[L] = true;
718
+ }
719
+ }
720
+ gi_base_info_unref(ti);
721
+ gi_base_info_unref(ai);
722
+ }
723
+ {
724
+ GITypeInfo* rt = gi_callable_info_get_return_type(ci);
725
+ if (gi_type_info_get_tag(rt) == GI_TYPE_TAG_ARRAY) {
726
+ unsigned int L = 0;
727
+ if (gi_type_info_get_array_length_index(rt, &L) && L < nDeclared) {
728
+ skip[L] = true;
729
+ isLenArg[L] = true;
730
+ }
731
+ }
732
+ gi_base_info_unref(rt);
733
+ }
734
+
735
+ // ffi argument value array: [instance, declared-arg-0 .., (GError** if can-throw)].
736
+ // Each entry points at the value's storage; for the GIArgument union, &arg is the
737
+ // address of every member (they overlap at offset 0), so &giArgs[i] works for any
738
+ // primitive/pointer-typed argument. An OUT/INOUT arg's ffi param is a POINTER, so
739
+ // its giArgs entry holds &slots[i] — the stable per-arg storage (arg-indexed, as
740
+ // ReadOutOrReturn expects) the C parent writes THROUGH.
741
+ std::vector<GIArgument> giArgs(1 + nDeclared);
742
+ std::vector<GIArgument> slots(nDeclared); // OUT/INOUT storage (zero-initialised)
743
+ std::vector<std::string> holds(nDeclared);
744
+ std::vector<gpointer> callerAllocBlob(nDeclared, nullptr);
745
+ std::vector<GType> callerAllocGType(nDeclared, 0);
746
+ std::vector<InContainer> inContainers; // IN containers to free after the reads
747
+ std::vector<gpointer> ownedInStrings; // transfer-full IN/INOUT strings (#658 model)
748
+ std::vector<void*> avalue;
749
+ avalue.reserve(1 + nDeclared + (canThrow ? 1 : 0));
750
+ giArgs[0].v_pointer = obj;
751
+ avalue.push_back(&giArgs[0]);
752
+
753
+ // The JS caller passes IN + INOUT args positionally; OUT params are engine-managed
754
+ // (jsCursor bridges the declared-vs-positional gap once an OUT precedes an IN). For
755
+ // an all-IN vfunc jsCursor == i, so the IN-only path is byte-identical to before.
756
+ bool ok = true;
757
+ size_t jsCursor = 0;
758
+ for (unsigned int i = 0; i < nDeclared && ok; i++) {
759
+ if (skip[i]) {
760
+ // An OUT/INOUT array-length arg: wire its slot so the callee writes the count.
761
+ if (isLenArg[i] && (dirs[i] == GI_DIRECTION_OUT || dirs[i] == GI_DIRECTION_INOUT))
762
+ giArgs[1 + i].v_pointer = &slots[i];
763
+ avalue.push_back(&giArgs[1 + i]);
764
+ continue;
765
+ }
766
+ GIArgInfo* ai = gi_callable_info_get_arg(ci, i);
767
+ GIDirection dir = dirs[i];
768
+ GITypeInfo* ti = gi_arg_info_get_type_info(ai);
769
+ GITypeTag tg = gi_type_info_get_tag(ti);
770
+
771
+ if (dir == GI_DIRECTION_OUT || dir == GI_DIRECTION_INOUT) {
772
+ std::string why;
773
+ if (gi_arg_info_is_caller_allocates(ai)) {
774
+ // Caller-allocates: the callee fills storage WE provide (a single pointer,
775
+ // not a **) — the ffi param is that blob pointer directly. Supported: a
776
+ // fixed-size C array (fundamental elements) and a boxed struct/union.
777
+ // Mirrors InvokeFunctionInfo / refs/gjs arg-cache CALLER_ALLOCATES.
778
+ size_t size = 0;
779
+ GType boxedGType = 0;
780
+ if (tg == GI_TYPE_TAG_ARRAY && gi_type_info_get_array_type(ti) == GI_ARRAY_TYPE_C) {
781
+ size_t fixed = 0;
782
+ if (gi_type_info_get_array_fixed_size(ti, &fixed) && fixed > 0) {
783
+ GITypeInfo* el = gi_type_info_get_param_type(ti, 0);
784
+ if (el != nullptr && gi_type_info_get_tag(el) != GI_TYPE_TAG_INTERFACE)
785
+ size = CElementSize(el) * fixed;
786
+ if (el != nullptr) gi_base_info_unref(el);
787
+ }
788
+ } else if (tg == GI_TYPE_TAG_INTERFACE) {
789
+ GIBaseInfo* si = gi_type_info_get_interface(ti);
790
+ if (si != nullptr && GI_IS_STRUCT_INFO(si)) {
791
+ size = gi_struct_info_get_size(reinterpret_cast<GIStructInfo*>(si));
792
+ } else if (si != nullptr && GI_IS_UNION_INFO(si)) {
793
+ size = gi_union_info_get_size(reinterpret_cast<GIUnionInfo*>(si));
794
+ }
795
+ if (si != nullptr) {
796
+ GType gt = gi_registered_type_info_get_g_type(
797
+ reinterpret_cast<GIRegisteredTypeInfo*>(si));
798
+ if (gt != G_TYPE_INVALID && G_TYPE_IS_BOXED(gt)) boxedGType = gt;
799
+ gi_base_info_unref(si);
800
+ }
801
+ if (boxedGType == 0) size = 0; // non-boxed struct OUT needs field access
802
+ }
803
+ if (size == 0) {
804
+ Napi::TypeError::New(
805
+ env, "super." + vname + ": caller-allocates OUT parameter type is not yet supported")
806
+ .ThrowAsJavaScriptException();
807
+ ok = false;
808
+ } else {
809
+ gpointer blob = g_malloc0(size);
810
+ callerAllocBlob[i] = blob;
811
+ callerAllocGType[i] = boxedGType;
812
+ giArgs[1 + i].v_pointer = blob;
813
+ }
814
+ } else if (!IsSupportedOutType(ti, &why)) {
815
+ Napi::TypeError::New(env, "super." + vname + ": OUT " + why +
816
+ " parameters are not yet supported")
817
+ .ThrowAsJavaScriptException();
818
+ ok = false;
819
+ } else if (dir == GI_DIRECTION_INOUT &&
820
+ (tg == GI_TYPE_TAG_ARRAY || tg == GI_TYPE_TAG_GLIST ||
821
+ tg == GI_TYPE_TAG_GSLIST || tg == GI_TYPE_TAG_GHASH)) {
822
+ // INOUT container: same read-modify-write model as the function path. The ffi
823
+ // param is a single `container**` the parent reads (in) then reassigns (out);
824
+ // we stash the in-container in slots[i] and point giArgs[1+i] at &slots[i].
825
+ // OWNERSHIP: JsToInContainer records the ORIGINAL in-container in inContainers,
826
+ // so FreeInContainer (after the reads) releases it per the IN transfer; the
827
+ // out-container the parent wrote into slots[i] is read + freed per the OUT
828
+ // transfer by ReadOutOrReturn below.
829
+ Napi::Value v = jsCursor < args.Length() ? args.Get(jsCursor) : env.Undefined();
830
+ jsCursor++;
831
+ if (!IsSupportedContainerType(ti, &why)) {
832
+ Napi::TypeError::New(env, "super." + vname + ": INOUT " + why +
833
+ " parameters are not yet supported")
834
+ .ThrowAsJavaScriptException();
835
+ ok = false;
836
+ } else {
837
+ GITransfer tr = gi_arg_info_get_ownership_transfer(ai);
838
+ gpointer cptr = nullptr;
839
+ long ccount = 0;
840
+ if ((v.IsNull() || v.IsUndefined()) && gi_arg_info_may_be_null(ai)) {
841
+ ok = true; // nullable INOUT container → NULL in-container (count 0)
842
+ } else {
843
+ ok = JsToInContainer(env, v, ti, tr, &cptr, &ccount, &inContainers);
844
+ }
845
+ if (ok) {
846
+ slots[i].v_pointer = cptr;
847
+ giArgs[1 + i].v_pointer = &slots[i];
848
+ if (tg == GI_TYPE_TAG_ARRAY) {
849
+ unsigned int L = 0;
850
+ if (gi_type_info_get_array_length_index(ti, &L) && L < nDeclared) {
851
+ GIArgInfo* la = gi_callable_info_get_arg(ci, L);
852
+ GITypeInfo* lt = gi_arg_info_get_type_info(la);
853
+ // An INOUT length's slot is what the parent reads+writes (giArgs[1+L]
854
+ // already points at &slots[L] via the skip-branch); a plain IN length
855
+ // takes the value directly in its ffi slot.
856
+ if (dirs[L] == GI_DIRECTION_INOUT) WriteLengthValue(lt, &slots[L], ccount);
857
+ else if (dirs[L] == GI_DIRECTION_IN) WriteLengthValue(lt, &giArgs[1 + L], ccount);
858
+ gi_base_info_unref(lt);
859
+ gi_base_info_unref(la);
860
+ }
861
+ }
862
+ }
863
+ }
864
+ } else if (dir == GI_DIRECTION_INOUT) {
865
+ // INOUT scalar: marshal the JS input into the slot (like IN); the parent
866
+ // reads + writes it through &slots[i].
867
+ Napi::Value v = jsCursor < args.Length() ? args.Get(jsCursor) : env.Undefined();
868
+ jsCursor++;
869
+ GITransfer tr = gi_arg_info_get_ownership_transfer(ai);
870
+ if (JsToGIArgument(env, v, ti, &slots[i], &holds[i], tr, &ownedInStrings))
871
+ giArgs[1 + i].v_pointer = &slots[i];
872
+ else
873
+ ok = false; // JsToGIArgument already threw
874
+ } else {
875
+ // Pure OUT: the callee writes into the slot; no JS arg is consumed.
876
+ giArgs[1 + i].v_pointer = &slots[i];
877
+ }
878
+ } else if (tg == GI_TYPE_TAG_ARRAY || tg == GI_TYPE_TAG_GLIST ||
879
+ tg == GI_TYPE_TAG_GSLIST || tg == GI_TYPE_TAG_GHASH) {
880
+ // IN container: build the C container, autofill an IN length arg.
881
+ std::string why;
882
+ Napi::Value v = jsCursor < args.Length() ? args.Get(jsCursor) : env.Undefined();
883
+ jsCursor++;
884
+ if (!IsSupportedContainerType(ti, &why)) {
885
+ Napi::TypeError::New(env, "super." + vname + ": IN " + why +
886
+ " parameters are not yet supported")
887
+ .ThrowAsJavaScriptException();
888
+ ok = false;
889
+ } else {
890
+ GITransfer tr = gi_arg_info_get_ownership_transfer(ai);
891
+ gpointer cptr = nullptr;
892
+ long ccount = 0;
893
+ ok = JsToInContainer(env, v, ti, tr, &cptr, &ccount, &inContainers);
894
+ if (ok) {
895
+ giArgs[1 + i].v_pointer = cptr;
896
+ if (tg == GI_TYPE_TAG_ARRAY) {
897
+ unsigned int L = 0;
898
+ if (gi_type_info_get_array_length_index(ti, &L) && L < nDeclared &&
899
+ dirs[L] == GI_DIRECTION_IN) {
900
+ GIArgInfo* la = gi_callable_info_get_arg(ci, L);
901
+ GITypeInfo* lt = gi_arg_info_get_type_info(la);
902
+ WriteLengthValue(lt, &giArgs[1 + L], ccount);
903
+ gi_base_info_unref(lt);
904
+ gi_base_info_unref(la);
905
+ }
906
+ }
907
+ }
908
+ }
909
+ } else {
910
+ // IN scalar/object/string: marshal by value into giArgs[1+i].
911
+ Napi::Value v = jsCursor < args.Length() ? args.Get(jsCursor) : env.Undefined();
912
+ jsCursor++;
913
+ GITransfer tr = gi_arg_info_get_ownership_transfer(ai);
914
+ ok = JsToGIArgument(env, v, ti, &giArgs[1 + i], &holds[i], tr, &ownedInStrings);
915
+ }
916
+
917
+ avalue.push_back(&giArgs[1 + i]);
918
+ gi_base_info_unref(ti);
919
+ gi_base_info_unref(ai);
920
+ }
921
+ if (!ok) {
922
+ for (const InContainer& c : inContainers) FreeInContainer(c);
923
+ for (gpointer s : ownedInStrings) g_free(s); // never reached the callee (#658)
924
+ for (gpointer b : callerAllocBlob)
925
+ if (b != nullptr) g_free(b);
926
+ return env.Null();
927
+ }
928
+
929
+ // can-throw vfuncs take a trailing GError** the parent writes the error into.
930
+ // libffi reads each argument's VALUE from the location avalue[i] points at, so
931
+ // for the GError** slot avalue must point at a variable holding &error (a
932
+ // GError**) — i.e. one extra level of indirection (&errorPtr), NOT &error
933
+ // (which would pass NULL as the GError** and silently swallow the parent error).
934
+ GError* error = nullptr;
935
+ GError** errorPtr = &error;
936
+ if (canThrow) avalue.push_back(&errorPtr);
937
+
938
+ GITypeInfo* retType = gi_callable_info_get_return_type(ci);
939
+ GIFFIReturnValue ffiRet;
940
+ ffiRet.v_uint64 = 0;
941
+ ffi_call(&vf->cif, reinterpret_cast<void (*)(void)>(vf->parentPtr), &ffiRet, avalue.data());
942
+
943
+ if (canThrow && error != nullptr) {
944
+ gi_base_info_unref(retType);
945
+ for (const InContainer& c : inContainers) FreeInContainer(c);
946
+ for (gpointer s : ownedInStrings) g_free(s); // callee did not adopt them on error
947
+ for (gpointer b : callerAllocBlob)
948
+ if (b != nullptr) g_free(b);
949
+ ThrowGError(env, error, "super." + vname);
950
+ return env.Null();
951
+ }
952
+
953
+ // Assemble the JS return per the GJS convention: the (non-void) return value leads,
954
+ // followed by each OUT/INOUT value in argument order. Exactly one element → return
955
+ // it bare; many → a JS Array; none → undefined. Matches what a JS override of this
956
+ // vfunc receives as its call args and hands back.
957
+ std::vector<Napi::Value> results;
958
+ GITransfer retTransfer = gi_callable_info_get_caller_owns(ci);
959
+ if (gi_type_info_get_tag(retType) != GI_TYPE_TAG_VOID) {
960
+ // Extract the (possibly narrowed) ffi return into a normalised GIArgument, then
961
+ // marshal it to JS — the portable, endianness-safe path. ReadOutOrReturn honours
962
+ // an array-length slot / container element type, and the declared transfer so a
963
+ // transfer-full parent return is owned by JS rather than leaked.
964
+ GIArgument retArg;
965
+ retArg.v_uint64 = 0;
966
+ gi_type_info_extract_ffi_return_value(retType, &ffiRet, &retArg);
967
+ results.push_back(ReadOutOrReturn(env, ci, retType, &retArg, retTransfer, &slots));
968
+ }
969
+ gi_base_info_unref(retType);
970
+
971
+ for (unsigned int i = 0; i < nDeclared && !env.IsExceptionPending(); i++) {
972
+ if (dirs[i] != GI_DIRECTION_OUT && dirs[i] != GI_DIRECTION_INOUT) continue;
973
+ if (skip[i]) continue; // an array-length arg — surfaced via its array, not alone
974
+ GIArgInfo* ai = gi_callable_info_get_arg(ci, i);
975
+ GITypeInfo* ti = gi_arg_info_get_type_info(ai);
976
+ GITransfer transfer = gi_arg_info_get_ownership_transfer(ai);
977
+ if (callerAllocBlob[i] != nullptr) {
978
+ // Caller-allocated OUT: the callee filled the blob IN PLACE (the blob is the
979
+ // data, not a pointer to it). Wrap it, then release the blob — mirroring
980
+ // InvokeFunctionInfo / gjs CallerAllocatesOut.
981
+ gpointer blob = callerAllocBlob[i];
982
+ if (gi_type_info_get_tag(ti) == GI_TYPE_TAG_ARRAY) {
983
+ GIArgument a;
984
+ memset(&a, 0, sizeof(a));
985
+ a.v_pointer = blob;
986
+ results.push_back(ReadOutOrReturn(env, ci, ti, &a, GI_TRANSFER_NOTHING, &slots));
987
+ g_free(blob);
988
+ } else {
989
+ GType gt = callerAllocGType[i];
990
+ gpointer owned = g_boxed_copy(gt, blob);
991
+ results.push_back(MakeBoxedHandle(env, owned, gt, true));
992
+ g_boxed_free(gt, blob);
993
+ }
994
+ callerAllocBlob[i] = nullptr;
995
+ gi_base_info_unref(ti);
996
+ gi_base_info_unref(ai);
997
+ continue;
998
+ }
999
+ results.push_back(ReadOutOrReturn(env, ci, ti, &slots[i], transfer, &slots));
1000
+ gi_base_info_unref(ti);
1001
+ gi_base_info_unref(ai);
1002
+ }
1003
+
1004
+ // A transfer-none IN container may back a transfer-none OUT/return, so free the IN
1005
+ // containers only AFTER the reads above (transfer-full ones were adopted). The
1006
+ // transfer-full IN/INOUT strings were adopted by the parent on this success path, so
1007
+ // they are intentionally NOT freed here (the callee owns them now).
1008
+ for (const InContainer& c : inContainers) FreeInContainer(c);
1009
+
1010
+ if (env.IsExceptionPending()) return env.Null();
1011
+ if (results.empty()) return env.Undefined();
1012
+ if (results.size() == 1) return results[0];
1013
+ Napi::Array arr = Napi::Array::New(env, results.size());
1014
+ for (size_t k = 0; k < results.size(); k++) arr.Set(static_cast<uint32_t>(k), results[k]);
1015
+ return arr;
1016
+ }
1017
+
1018
+ // Shared registration core: subclass `name` from an ALREADY-RESOLVED parent GObject
1019
+ // GType, reading the optional { properties, signals, vfuncs, template, children, ... }
1020
+ // from `optsValue` (a non-object → no options). Returns the tagged GType handle for
1021
+ // the new type, or throws + returns env.Null(). Both entry points use it:
1022
+ // - RegisterClass — parent resolved from introspection (namespace+typename),
1023
+ // - RegisterClassFromGType — parent given directly as a #667 GType handle (the
1024
+ // multi-level registered-of-registered path; the parent has no GIR entry).
1025
+ // Everything past parent resolution is identical: g_type_register_static makes the
1026
+ // child class struct a memcpy of the parent's, so a registered ancestor's installed
1027
+ // properties/signals/vfunc slots compose for free via normal GObject inheritance.
1028
+ static Napi::Value RegisterClassImpl(Napi::Env env, const std::string& name, GType parentType,
1029
+ Napi::Value optsValue) {
1030
+ if (g_type_from_name(name.c_str()) != 0) {
1031
+ Napi::Error::New(env, "a GType named '" + name + "' is already registered")
1032
+ .ThrowAsJavaScriptException();
1033
+ return env.Null();
1034
+ }
1035
+ if (!G_TYPE_IS_OBJECT(parentType)) {
1036
+ const char* pn = g_type_name(parentType);
1037
+ Napi::TypeError::New(env, std::string(pn != nullptr ? pn : "the parent type") +
1038
+ " is not a subclassable GObject type")
1039
+ .ThrowAsJavaScriptException();
1040
+ return env.Null();
1041
+ }
1042
+
1043
+ GTypeQuery query;
1044
+ g_type_query(parentType, &query);
1045
+ if (query.type == 0) {
1046
+ const char* pn = g_type_name(parentType);
1047
+ Napi::Error::New(env, std::string("failed to query parent type ") + (pn != nullptr ? pn : "?"))
1048
+ .ThrowAsJavaScriptException();
1049
+ return env.Null();
1050
+ }
1051
+
1052
+ // Parse the optional { properties, signals } and build the class metadata.
1053
+ NodeGiClassData* cd = new NodeGiClassData();
1054
+ // Stamp the registering env so NodeGiConstructor (no user_data slot) can call
1055
+ // into JS from this class's cd for a C/GtkBuilder-instantiated instance.
1056
+ cd->env = env;
1057
+ cd->parentGet = nullptr;
1058
+ cd->parentSet = nullptr;
1059
+ if (optsValue.IsObject()) {
1060
+ Napi::Object opts = optsValue.As<Napi::Object>();
1061
+ if (opts.Has("properties") && opts.Get("properties").IsArray()) {
1062
+ Napi::Array props = opts.Get("properties").As<Napi::Array>();
1063
+ for (uint32_t i = 0; i < props.Length(); i++) {
1064
+ Napi::Value pv = props.Get(i);
1065
+ if (!pv.IsObject()) continue;
1066
+ std::string perr;
1067
+ GParamSpec* ps = BuildParamSpec(env, pv.As<Napi::Object>(), &perr);
1068
+ if (ps == nullptr) {
1069
+ for (GParamSpec* done : cd->properties) {
1070
+ g_param_spec_ref_sink(done);
1071
+ g_param_spec_unref(done);
1072
+ }
1073
+ delete cd;
1074
+ Napi::TypeError::New(env, "registerClass property: " + perr).ThrowAsJavaScriptException();
1075
+ return env.Null();
1076
+ }
1077
+ cd->properties.push_back(ps);
1078
+ }
1079
+ }
1080
+ if (opts.Has("signals") && opts.Get("signals").IsArray()) {
1081
+ Napi::Array sigs = opts.Get("signals").As<Napi::Array>();
1082
+ for (uint32_t i = 0; i < sigs.Length(); i++) {
1083
+ Napi::Value sv = sigs.Get(i);
1084
+ if (!sv.IsObject()) continue;
1085
+ Napi::Object so = sv.As<Napi::Object>();
1086
+ if (!so.Has("name") || !so.Get("name").IsString()) continue;
1087
+ NodeGiSignalDef sd;
1088
+ sd.name = so.Get("name").As<Napi::String>().Utf8Value();
1089
+ sd.returnType = G_TYPE_NONE;
1090
+ if (so.Has("returnType") && so.Get("returnType").IsString()) {
1091
+ GType rt = TypeNameToGType(so.Get("returnType").As<Napi::String>().Utf8Value());
1092
+ if (rt != G_TYPE_INVALID) sd.returnType = rt;
1093
+ }
1094
+ sd.flags = (so.Has("flags") && so.Get("flags").IsNumber())
1095
+ ? static_cast<GSignalFlags>(so.Get("flags").As<Napi::Number>().Int32Value())
1096
+ : G_SIGNAL_RUN_LAST;
1097
+ if (so.Has("paramTypes") && so.Get("paramTypes").IsArray()) {
1098
+ Napi::Array pts = so.Get("paramTypes").As<Napi::Array>();
1099
+ for (uint32_t j = 0; j < pts.Length(); j++) {
1100
+ // NodeGiToUtf8: terminate-safe (a swallowed Get/coercion failure must
1101
+ // not cascade into Error::New(nullptr) — see common.h).
1102
+ GType t = TypeNameToGType(NodeGiToUtf8(pts.Get(j)));
1103
+ if (t != G_TYPE_INVALID && t != G_TYPE_NONE) sd.paramTypes.push_back(t);
1104
+ }
1105
+ }
1106
+ cd->signals.push_back(sd);
1107
+ }
1108
+ }
1109
+ // vfuncs: an object { "<vfunc-name>": <jsFunction>, ... }. Each holds a strong
1110
+ // napi_ref for the class lifetime (resolved + hooked up in class_init).
1111
+ if (opts.Has("vfuncs") && opts.Get("vfuncs").IsObject()) {
1112
+ Napi::Object vf = opts.Get("vfuncs").As<Napi::Object>();
1113
+ Napi::Array keys = vf.GetPropertyNames();
1114
+ // Empty keys = swallowed napi failure (terminating env): keys.Length()
1115
+ // would abort via Error::New(nullptr) — skip the block cleanly.
1116
+ for (uint32_t i = 0; !keys.IsEmpty() && i < keys.Length(); i++) {
1117
+ std::string vname = NodeGiToUtf8(keys.Get(i));
1118
+ Napi::Value fnv = vf.Get(vname);
1119
+ if (!fnv.IsFunction()) continue;
1120
+ NodeGiVFunc* rec = new NodeGiVFunc();
1121
+ rec->env = env;
1122
+ rec->name = vname;
1123
+ rec->info = nullptr;
1124
+ rec->closure = nullptr;
1125
+ rec->fn = nullptr;
1126
+ rec->parentPtr = nullptr;
1127
+ napi_create_reference(env, fnv, 1, &rec->fn);
1128
+ cd->vfuncs.push_back(rec);
1129
+ }
1130
+ }
1131
+ // template: a Gtk.Widget composite template. Accepts a Uint8Array/Buffer of
1132
+ // inline UI-XML, a "resource:///…" path string (→ set_template_from_resource),
1133
+ // or a plain inline UI-XML string. Installed on the class in class_init.
1134
+ if (opts.Has("template")) {
1135
+ Napi::Value tv = opts.Get("template");
1136
+ if (tv.IsString()) {
1137
+ std::string s = tv.As<Napi::String>().Utf8Value();
1138
+ const std::string kResource = "resource://";
1139
+ if (s.rfind(kResource, 0) == 0) {
1140
+ // "resource:///path" → the resource PATH "/path" (strip the scheme +
1141
+ // authority "resource://"), matching gtk_widget_class_set_template_from_resource.
1142
+ cd->templateResource = s.substr(kResource.size());
1143
+ cd->hasTemplate = true;
1144
+ } else {
1145
+ // Inline UI-XML string → owned GBytes copy.
1146
+ cd->templateBytes = g_bytes_new(s.data(), s.size());
1147
+ cd->hasTemplate = true;
1148
+ }
1149
+ } else if (tv.IsBuffer()) {
1150
+ Napi::Buffer<uint8_t> b = tv.As<Napi::Buffer<uint8_t>>();
1151
+ cd->templateBytes = g_bytes_new(b.Data(), b.Length());
1152
+ cd->hasTemplate = true;
1153
+ } else if (tv.IsTypedArray()) {
1154
+ Napi::TypedArray ta = tv.As<Napi::TypedArray>();
1155
+ const uint8_t* data = static_cast<const uint8_t*>(ta.ArrayBuffer().Data()) + ta.ByteOffset();
1156
+ cd->templateBytes = g_bytes_new(data, ta.ByteLength());
1157
+ cd->hasTemplate = true;
1158
+ }
1159
+ }
1160
+ if (opts.Has("cssName") && opts.Get("cssName").IsString()) {
1161
+ cd->cssName = opts.Get("cssName").As<Napi::String>().Utf8Value();
1162
+ }
1163
+ if (opts.Has("children") && opts.Get("children").IsArray()) {
1164
+ Napi::Array arr = opts.Get("children").As<Napi::Array>();
1165
+ for (uint32_t i = 0; i < arr.Length(); i++) {
1166
+ if (arr.Get(i).IsString()) cd->children.push_back(arr.Get(i).As<Napi::String>().Utf8Value());
1167
+ }
1168
+ }
1169
+ if (opts.Has("internalChildren") && opts.Get("internalChildren").IsArray()) {
1170
+ Napi::Array arr = opts.Get("internalChildren").As<Napi::Array>();
1171
+ for (uint32_t i = 0; i < arr.Length(); i++) {
1172
+ if (arr.Get(i).IsString())
1173
+ cd->internalChildren.push_back(arr.Get(i).As<Napi::String>().Utf8Value());
1174
+ }
1175
+ }
1176
+ }
1177
+
1178
+ GTypeInfo typeInfo = {};
1179
+ typeInfo.class_size = static_cast<guint16>(query.class_size);
1180
+ typeInfo.instance_size = static_cast<guint16>(query.instance_size);
1181
+ // class_init installs the custom properties + signals (and records the class
1182
+ // data even when there are none, so the property vfuncs can find it).
1183
+ typeInfo.class_init = NodeGiClassInit;
1184
+ typeInfo.class_data = cd;
1185
+
1186
+ GType newType = g_type_register_static(parentType, name.c_str(), &typeInfo, (GTypeFlags)0);
1187
+ if (newType == 0) {
1188
+ for (NodeGiVFunc* vf : cd->vfuncs) {
1189
+ if (vf->fn != nullptr) napi_delete_reference(env, vf->fn);
1190
+ delete vf;
1191
+ }
1192
+ delete cd;
1193
+ Napi::Error::New(env, "g_type_register_static failed for '" + name + "'")
1194
+ .ThrowAsJavaScriptException();
1195
+ return env.Null();
1196
+ }
1197
+ // Tag the returned type handle as a GType handle so it doubles as the class's
1198
+ // `$gtype` (G4): the same External feeds constructType (UnwrapGType) AND the
1199
+ // GType marshalling (GObject.type_ensure, g_param_spec_object's value gtype, …).
1200
+ return MakeGTypeHandle(env, newType);
1201
+ }
1202
+
1203
+ // registerClass(name, parentNamespace, parentTypeName, options?) -> typeHandle
1204
+ //
1205
+ // Subclass an INTROSPECTED parent, resolved by namespace + typename. Used when the
1206
+ // parent class extends a `gi://`-introspected GObject (e.g. `class X extends Adw.Bin`).
1207
+ Napi::Value RegisterClass(const Napi::CallbackInfo& info) {
1208
+ Napi::Env env = info.Env();
1209
+ if (info.Length() < 3 || !info[0].IsString() || !info[1].IsString() || !info[2].IsString()) {
1210
+ Napi::TypeError::New(
1211
+ env,
1212
+ "registerClass(name: string, parentNamespace: string, parentTypeName: string, options?: "
1213
+ "{ properties?, signals?, vfuncs? })")
1214
+ .ThrowAsJavaScriptException();
1215
+ return env.Null();
1216
+ }
1217
+ std::string name = info[0].As<Napi::String>().Utf8Value();
1218
+ std::string pns = info[1].As<Napi::String>().Utf8Value();
1219
+ std::string ptn = info[2].As<Napi::String>().Utf8Value();
1220
+
1221
+ // Resolve the parent GType from its introspection info.
1222
+ GIRepository* repo = DupDefaultRepository();
1223
+ GIBaseInfo* base = gi_repository_find_by_name(repo, pns.c_str(), ptn.c_str());
1224
+ bool isObject = base != nullptr && GI_IS_OBJECT_INFO(base);
1225
+ GType parentType =
1226
+ isObject ? gi_registered_type_info_get_g_type(reinterpret_cast<GIRegisteredTypeInfo*>(base))
1227
+ : G_TYPE_INVALID;
1228
+ if (base != nullptr) gi_base_info_unref(base);
1229
+ g_object_unref(repo);
1230
+ if (!isObject || !G_TYPE_IS_OBJECT(parentType)) {
1231
+ Napi::TypeError::New(env, pns + "." + ptn + " is not a subclassable GObject type")
1232
+ .ThrowAsJavaScriptException();
1233
+ return env.Null();
1234
+ }
1235
+
1236
+ return RegisterClassImpl(env, name, parentType, info.Length() >= 4 ? info[3] : env.Undefined());
1237
+ }
1238
+
1239
+ // registerClassFromGType(name, parentGType, options?) -> typeHandle
1240
+ //
1241
+ // Multi-level registered subclassing (G2): subclass directly from a parent GType
1242
+ // HANDLE (the #667 kGTypeHandleTag External a previous registerClass returned),
1243
+ // instead of resolving the parent through introspection. The parent is itself a
1244
+ // registered (dynamic) type with no GIR entry, so the namespace+typename path
1245
+ // (RegisterClass) cannot find it; the L1 layer (findParentGType) passes the parent's
1246
+ // `$gtype` handle here. The shared core (RegisterClassImpl) then subclasses from the
1247
+ // resolved GType identically, so a registered ancestor's properties/signals/vfunc
1248
+ // slots compose for free via GObject inheritance.
1249
+ Napi::Value RegisterClassFromGType(const Napi::CallbackInfo& info) {
1250
+ Napi::Env env = info.Env();
1251
+ if (info.Length() < 2 || !info[0].IsString()) {
1252
+ Napi::TypeError::New(
1253
+ env, "registerClassFromGType(name: string, parentGType: handle, options?: object)")
1254
+ .ThrowAsJavaScriptException();
1255
+ return env.Null();
1256
+ }
1257
+ std::string name = info[0].As<Napi::String>().Utf8Value();
1258
+ GType parentType = UnwrapGType(env, info[1]); // throws (returns 0) on a non-GType arg
1259
+ if (parentType == 0) return env.Null();
1260
+ return RegisterClassImpl(env, name, parentType, info.Length() >= 3 ? info[2] : env.Undefined());
1261
+ }
1262
+
1263
+ // constructType(typeHandle, props?: Record<string, unknown>) -> External<GObject>
1264
+ Napi::Value ConstructType(const Napi::CallbackInfo& info) {
1265
+ Napi::Env env = info.Env();
1266
+ if (info.Length() < 1) {
1267
+ Napi::TypeError::New(env, "constructType(typeHandle, props?: object)")
1268
+ .ThrowAsJavaScriptException();
1269
+ return env.Null();
1270
+ }
1271
+ GType gtype = UnwrapGType(env, info[0]);
1272
+ if (gtype == 0) return env.Null();
1273
+ if (!G_TYPE_IS_OBJECT(gtype)) {
1274
+ Napi::TypeError::New(env, "type handle is not a constructible GObject type")
1275
+ .ThrowAsJavaScriptException();
1276
+ return env.Null();
1277
+ }
1278
+ Napi::Object props = (info.Length() >= 2 && info[1].IsObject()) ? info[1].As<Napi::Object>()
1279
+ : Napi::Object::New(env);
1280
+ return ConstructGObject(env, gtype, props, std::string(g_type_name(gtype)));
1281
+ }
1282
+
1283
+ // setConstructCallback(cb) -> void. L1 registers the callback NodeGiConstructor
1284
+ // invokes to run a registered class's JS constructor for a C/GtkBuilder-created
1285
+ // instance: (instanceHandle, gtypeName) → Reflect.construct(class) in adopt mode
1286
+ // (see gi.js runCtorForCObject). Mirrors setTemplateCallbackResolver.
1287
+ Napi::Value SetConstructCallback(const Napi::CallbackInfo& info) {
1288
+ Napi::Env env = info.Env();
1289
+ if (info.Length() < 1 || !info[0].IsFunction()) {
1290
+ Napi::TypeError::New(env, "setConstructCallback(cb: function)").ThrowAsJavaScriptException();
1291
+ return env.Undefined();
1292
+ }
1293
+ NodeGiEnvData* d = EnvData(env);
1294
+ if (d == nullptr) return env.Undefined();
1295
+ if (d->constructCallback != nullptr) {
1296
+ napi_delete_reference(env, d->constructCallback);
1297
+ d->constructCallback = nullptr;
1298
+ }
1299
+ napi_create_reference(env, info[0], 1, &d->constructCallback);
1300
+ return env.Undefined();
1301
+ }
1302
+
1303
+ } // namespace nodegi