@gjsify/node-gi 0.13.0 → 0.20.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,1170 @@
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
+ static void NodeGiClassInit(gpointer g_class, gpointer class_data) {
387
+ NodeGiClassData* cd = static_cast<NodeGiClassData*>(class_data);
388
+ GObjectClass* oc = G_OBJECT_CLASS(g_class);
389
+ g_type_set_qdata(G_TYPE_FROM_CLASS(g_class), NodeGiClassDataQuark(), cd);
390
+
391
+ if (!cd->properties.empty()) {
392
+ cd->parentGet = oc->get_property; // capture before override (chain target)
393
+ cd->parentSet = oc->set_property;
394
+ oc->get_property = NodeGiGetProperty;
395
+ oc->set_property = NodeGiSetProperty;
396
+ guint id = 1;
397
+ for (GParamSpec* p : cd->properties) {
398
+ g_object_class_install_property(oc, id++, p);
399
+ }
400
+ }
401
+ for (const NodeGiSignalDef& s : cd->signals) {
402
+ g_signal_newv(s.name.c_str(), G_TYPE_FROM_CLASS(g_class), s.flags, nullptr, nullptr, nullptr,
403
+ nullptr, s.returnType, static_cast<guint>(s.paramTypes.size()),
404
+ s.paramTypes.empty() ? nullptr : const_cast<GType*>(s.paramTypes.data()));
405
+ }
406
+
407
+ if (!cd->vfuncs.empty()) {
408
+ GType newType = G_TYPE_FROM_CLASS(g_class);
409
+ GType parentType = g_type_parent(newType);
410
+ GIRepository* repo = gi_repository_dup_default();
411
+ for (NodeGiVFunc* vf : cd->vfuncs) {
412
+ // Resolve the vfunc info by walking the parent object-info chain; the
413
+ // declarer's class struct holds the vtable slot we write into.
414
+ GIVFuncInfo* vi = nullptr;
415
+ GIObjectInfo* declarer = nullptr;
416
+ for (GType t = parentType; t != 0 && vi == nullptr; t = g_type_parent(t)) {
417
+ GIBaseInfo* bi = gi_repository_find_by_gtype(repo, t);
418
+ if (bi != nullptr) {
419
+ if (GI_IS_OBJECT_INFO(bi)) {
420
+ vi = gi_object_info_find_vfunc(reinterpret_cast<GIObjectInfo*>(bi), vf->name.c_str());
421
+ if (vi != nullptr)
422
+ declarer = reinterpret_cast<GIObjectInfo*>(gi_base_info_ref(bi));
423
+ }
424
+ gi_base_info_unref(bi);
425
+ }
426
+ }
427
+ if (vi == nullptr) {
428
+ g_warning("node-gi: registerClass vfunc '%s' not found on any ancestor of %s",
429
+ vf->name.c_str(), g_type_name(newType));
430
+ continue;
431
+ }
432
+
433
+ // Locate the vtable slot. gi_vfunc_info_get_offset is GI_UNKNOWN (0xFFFF)
434
+ // for GObject's own vfuncs, so match the vfunc name to a class-struct field
435
+ // and use that field's offset (the GJS approach); fall back to the recorded
436
+ // offset only when the field lookup fails.
437
+ int offset = -1;
438
+ GIStructInfo* cs = gi_object_info_get_class_struct(declarer);
439
+ if (cs != nullptr) {
440
+ unsigned int nf = gi_struct_info_get_n_fields(cs);
441
+ for (unsigned int fi = 0; fi < nf && offset < 0; fi++) {
442
+ GIFieldInfo* f = gi_struct_info_get_field(cs, fi);
443
+ const char* fn = gi_base_info_get_name(reinterpret_cast<GIBaseInfo*>(f));
444
+ if (fn != nullptr && vf->name == fn) offset = gi_field_info_get_offset(f);
445
+ gi_base_info_unref(reinterpret_cast<GIBaseInfo*>(f));
446
+ }
447
+ gi_base_info_unref(reinterpret_cast<GIBaseInfo*>(cs));
448
+ }
449
+ if (offset < 0) {
450
+ size_t off = gi_vfunc_info_get_offset(vi);
451
+ if (off != 0 && off != 0xFFFF) offset = static_cast<int>(off);
452
+ }
453
+ if (offset < 0) {
454
+ g_warning("node-gi: could not resolve a vtable slot for vfunc '%s' on %s",
455
+ vf->name.c_str(), g_type_name(newType));
456
+ gi_base_info_unref(reinterpret_cast<GIBaseInfo*>(vi));
457
+ gi_base_info_unref(reinterpret_cast<GIBaseInfo*>(declarer));
458
+ continue;
459
+ }
460
+
461
+ // Create the ffi closure and write its native address into the class vtable.
462
+ // The vfunc info + closure + cif are kept alive for the class lifetime.
463
+ vf->info = vi; // retained (kept alive); never unref'd — class is permanent
464
+ vf->closure = gi_callable_info_create_closure(reinterpret_cast<GICallableInfo*>(vi), &vf->cif,
465
+ NodeGiVFuncTrampoline, vf);
466
+ gpointer native =
467
+ gi_callable_info_get_closure_native_address(reinterpret_cast<GICallableInfo*>(vi),
468
+ vf->closure);
469
+ if (native == nullptr) native = vf->closure;
470
+ // Capture the parent implementation BEFORE overwriting the slot: at this
471
+ // point g_class is a memcpy of the parent class struct, so the slot holds
472
+ // the parent's vfunc pointer (the C default, or a JS override further up).
473
+ // That is the super.vfunc_<name>() chain-up target (see callParentVfunc).
474
+ gpointer* slotAddr = reinterpret_cast<gpointer*>(reinterpret_cast<guint8*>(g_class) + offset);
475
+ vf->parentPtr = *slotAddr;
476
+ *slotAddr = native;
477
+ gi_base_info_unref(reinterpret_cast<GIBaseInfo*>(declarer));
478
+ }
479
+ g_object_unref(repo);
480
+ }
481
+
482
+ // ---- Gtk.Widget composite template ----
483
+ // Install the template + bind the declared children on the new GtkWidgetClass.
484
+ // g_class is the new type's class struct, which derives from GtkWidgetClass for
485
+ // any Gtk.Widget subtype — exactly the pointer gtk_widget_class_set_template*
486
+ // expects. Done in class_init, the idiomatic GTK lifecycle point (C widgets
487
+ // call set_template in their class_init too). get_template_child / init_template
488
+ // run later, per instance.
489
+ if (cd->hasTemplate) {
490
+ const GtkTemplateApi* gtk = GetGtkTemplateApi();
491
+ GType widgetType = g_type_from_name("GtkWidget");
492
+ bool isWidget = widgetType != 0 && g_type_is_a(G_TYPE_FROM_CLASS(g_class), widgetType);
493
+ if (gtk->ok && isWidget) {
494
+ if (!cd->cssName.empty()) gtk->set_css_name(g_class, cd->cssName.c_str());
495
+ if (cd->templateBytes != nullptr) {
496
+ gtk->set_template(g_class, cd->templateBytes);
497
+ } else if (!cd->templateResource.empty()) {
498
+ gtk->set_template_from_resource(g_class, cd->templateResource.c_str());
499
+ }
500
+ for (const std::string& c : cd->children)
501
+ gtk->bind_template_child_full(g_class, c.c_str(), FALSE, 0);
502
+ for (const std::string& c : cd->internalChildren)
503
+ gtk->bind_template_child_full(g_class, c.c_str(), TRUE, 0);
504
+ // Install a generic template-callback scope so any `<signal handler="…">` in
505
+ // the template resolves to the instance's JS method (mirrors GJS's
506
+ // set_template_scope path). Must follow set_template; used during
507
+ // init_template. Independent of the child binding above — Children /
508
+ // InternalChildren behaviour is unchanged.
509
+ NodeGiInstallTemplateScopeOnClass(cd, g_class);
510
+ } else if (!isWidget) {
511
+ g_warning(
512
+ "node-gi: a Template was set on %s, which is not a Gtk.Widget subclass — "
513
+ "composite templates require a Gtk.Widget ancestor; ignoring the template",
514
+ g_type_name(G_TYPE_FROM_CLASS(g_class)));
515
+ } else {
516
+ g_warning(
517
+ "node-gi: a Gtk.Widget Template was requested but the libgtk-4 template "
518
+ "API could not be resolved (is GTK 4 installed?)");
519
+ }
520
+ }
521
+ }
522
+
523
+ // callParentVfunc(handle, vfuncName, args?) -> unknown
524
+ //
525
+ // Chain up to the parent implementation of an overridden vfunc — the engine half
526
+ // of `super.vfunc_<name>(...)`. Resolves the NodeGiVFunc record for `vfuncName`
527
+ // nearest the instance's type (the record whose trampoline currently owns the
528
+ // vtable slot) and ffi_call's its captured parentPtr — the function that was in
529
+ // the slot BEFORE the override was installed (the C default, or a JS override
530
+ // further up the chain). The same `cif` the override's closure was built from
531
+ // describes the call signature (instance + declared args → return), so it is
532
+ // reused to call out. `this` (args[0]) goes back in as the instance, keeping the
533
+ // canonical toggle-ref wrapper identity. Marshals IN args (JsToGIArgument) +
534
+ // the return (gi_type_info_extract_ffi_return_value → GIArgumentToJs); throws a
535
+ // GLib.Error for a can-throw vfunc whose parent set the GError.
536
+ //
537
+ // OUT / INOUT args: routed through per-arg storage slots exactly like the
538
+ // function-invoke path (calls.cc InvokeFunctionInfo). The parent's ffi signature
539
+ // takes a POINTER for each OUT/INOUT param, so giArgs[1+i] carries &slots[i] (the
540
+ // stable storage the C parent writes THROUGH) — INOUT slots are pre-marshalled from
541
+ // the JS input, OUT slots start zeroed; a caller-allocates OUT (fixed C array /
542
+ // boxed struct) gets a g_malloc0 blob instead. The JS caller passes only IN + INOUT
543
+ // args positionally (OUT params are engine-managed), and the result follows the GJS
544
+ // return-tuple convention `[returnValue?, ...outArgs]` — one value bare, several as
545
+ // an Array, matching exactly what a JS override of that vfunc receives as its call
546
+ // args. Read-back reuses ReadOutOrReturn (array-length slots, containers, boxed).
547
+ // INOUT containers stay deferred (the same rare, ownership-tricky case the function
548
+ // path defers) with a clear, catchable throw BEFORE the ffi_call.
549
+ //
550
+ // MULTI-LEVEL chain-up (registered chains, G2): chains to the DEEPEST registered
551
+ // override's captured parent (the C-side default below the whole registered chain),
552
+ // NOT the nearest. On a multi-level chain the level-to-level `super.vfunc_<name>()`
553
+ // hops are resolved by the JS PROTOTYPE chain (each registered ancestor's
554
+ // `vfunc_<name>` is a real JS method, invoked directly), so every level's JS impl
555
+ // has already run by the time the chain bottoms out at the introspected base's
556
+ // prototype and reaches this thunk — at which point the only thing left to call is
557
+ // the C default. Using the nearest record would re-enter an intermediate level's
558
+ // trampoline (a double-run / infinite loop). See FindDeepestVFuncRecord.
559
+ Napi::Value CallParentVfunc(const Napi::CallbackInfo& info) {
560
+ Napi::Env env = info.Env();
561
+ if (info.Length() < 2 || !info[1].IsString()) {
562
+ Napi::TypeError::New(env, "callParentVfunc(handle, vfuncName: string, args?: unknown[])")
563
+ .ThrowAsJavaScriptException();
564
+ return env.Null();
565
+ }
566
+ GObject* obj = UnwrapGObject(env, info[0]);
567
+ if (obj == nullptr) return env.Null(); // UnwrapGObject threw or it was null
568
+ std::string vname = info[1].As<Napi::String>().Utf8Value();
569
+ Napi::Array args = (info.Length() >= 3 && info[2].IsArray()) ? info[2].As<Napi::Array>()
570
+ : Napi::Array::New(env, 0);
571
+
572
+ NodeGiVFunc* vf = FindDeepestVFuncRecord(G_OBJECT_TYPE(obj), vname);
573
+ if (vf == nullptr || vf->parentPtr == nullptr || vf->info == nullptr) {
574
+ Napi::Error::New(env, "no parent vfunc '" + vname + "' to chain up to on " +
575
+ g_type_name(G_OBJECT_TYPE(obj)) +
576
+ " (is it overridden by a registerClass subclass?)")
577
+ .ThrowAsJavaScriptException();
578
+ return env.Null();
579
+ }
580
+
581
+ GICallableInfo* ci = reinterpret_cast<GICallableInfo*>(vf->info);
582
+ unsigned int nDeclared = gi_callable_info_get_n_args(ci);
583
+ bool canThrow = gi_callable_info_can_throw_gerror(ci);
584
+
585
+ // Per-arg direction (used both to marshal IN/INOUT input and to read OUT/INOUT
586
+ // back after the call).
587
+ std::vector<GIDirection> dirs(nDeclared);
588
+ for (unsigned int i = 0; i < nDeclared; i++) {
589
+ GIArgInfo* ai = gi_callable_info_get_arg(ci, i);
590
+ dirs[i] = gi_arg_info_get_direction(ai);
591
+ gi_base_info_unref(ai);
592
+ }
593
+
594
+ // Array-length args (of an arg array OR of the return array) are engine-managed:
595
+ // never JS-consumed, never surfaced on their own. An OUT/INOUT length slot is
596
+ // wired so the callee writes the count we then size the array with; an IN length
597
+ // is autofilled from the array's element count. Mirrors InvokeFunctionInfo.
598
+ std::vector<bool> skip(nDeclared, false);
599
+ std::vector<bool> isLenArg(nDeclared, false);
600
+ for (unsigned int i = 0; i < nDeclared; i++) {
601
+ GIArgInfo* ai = gi_callable_info_get_arg(ci, i);
602
+ GITypeInfo* ti = gi_arg_info_get_type_info(ai);
603
+ if (gi_type_info_get_tag(ti) == GI_TYPE_TAG_ARRAY) {
604
+ unsigned int L = 0;
605
+ if (gi_type_info_get_array_length_index(ti, &L) && L < nDeclared) {
606
+ skip[L] = true;
607
+ isLenArg[L] = true;
608
+ }
609
+ }
610
+ gi_base_info_unref(ti);
611
+ gi_base_info_unref(ai);
612
+ }
613
+ {
614
+ GITypeInfo* rt = gi_callable_info_get_return_type(ci);
615
+ if (gi_type_info_get_tag(rt) == GI_TYPE_TAG_ARRAY) {
616
+ unsigned int L = 0;
617
+ if (gi_type_info_get_array_length_index(rt, &L) && L < nDeclared) {
618
+ skip[L] = true;
619
+ isLenArg[L] = true;
620
+ }
621
+ }
622
+ gi_base_info_unref(rt);
623
+ }
624
+
625
+ // ffi argument value array: [instance, declared-arg-0 .., (GError** if can-throw)].
626
+ // Each entry points at the value's storage; for the GIArgument union, &arg is the
627
+ // address of every member (they overlap at offset 0), so &giArgs[i] works for any
628
+ // primitive/pointer-typed argument. An OUT/INOUT arg's ffi param is a POINTER, so
629
+ // its giArgs entry holds &slots[i] — the stable per-arg storage (arg-indexed, as
630
+ // ReadOutOrReturn expects) the C parent writes THROUGH.
631
+ std::vector<GIArgument> giArgs(1 + nDeclared);
632
+ std::vector<GIArgument> slots(nDeclared); // OUT/INOUT storage (zero-initialised)
633
+ std::vector<std::string> holds(nDeclared);
634
+ std::vector<gpointer> callerAllocBlob(nDeclared, nullptr);
635
+ std::vector<GType> callerAllocGType(nDeclared, 0);
636
+ std::vector<InContainer> inContainers; // IN containers to free after the reads
637
+ std::vector<gpointer> ownedInStrings; // transfer-full IN/INOUT strings (#658 model)
638
+ std::vector<void*> avalue;
639
+ avalue.reserve(1 + nDeclared + (canThrow ? 1 : 0));
640
+ giArgs[0].v_pointer = obj;
641
+ avalue.push_back(&giArgs[0]);
642
+
643
+ // The JS caller passes IN + INOUT args positionally; OUT params are engine-managed
644
+ // (jsCursor bridges the declared-vs-positional gap once an OUT precedes an IN). For
645
+ // an all-IN vfunc jsCursor == i, so the IN-only path is byte-identical to before.
646
+ bool ok = true;
647
+ size_t jsCursor = 0;
648
+ for (unsigned int i = 0; i < nDeclared && ok; i++) {
649
+ if (skip[i]) {
650
+ // An OUT/INOUT array-length arg: wire its slot so the callee writes the count.
651
+ if (isLenArg[i] && (dirs[i] == GI_DIRECTION_OUT || dirs[i] == GI_DIRECTION_INOUT))
652
+ giArgs[1 + i].v_pointer = &slots[i];
653
+ avalue.push_back(&giArgs[1 + i]);
654
+ continue;
655
+ }
656
+ GIArgInfo* ai = gi_callable_info_get_arg(ci, i);
657
+ GIDirection dir = dirs[i];
658
+ GITypeInfo* ti = gi_arg_info_get_type_info(ai);
659
+ GITypeTag tg = gi_type_info_get_tag(ti);
660
+
661
+ if (dir == GI_DIRECTION_OUT || dir == GI_DIRECTION_INOUT) {
662
+ std::string why;
663
+ if (gi_arg_info_is_caller_allocates(ai)) {
664
+ // Caller-allocates: the callee fills storage WE provide (a single pointer,
665
+ // not a **) — the ffi param is that blob pointer directly. Supported: a
666
+ // fixed-size C array (fundamental elements) and a boxed struct/union.
667
+ // Mirrors InvokeFunctionInfo / refs/gjs arg-cache CALLER_ALLOCATES.
668
+ size_t size = 0;
669
+ GType boxedGType = 0;
670
+ if (tg == GI_TYPE_TAG_ARRAY && gi_type_info_get_array_type(ti) == GI_ARRAY_TYPE_C) {
671
+ size_t fixed = 0;
672
+ if (gi_type_info_get_array_fixed_size(ti, &fixed) && fixed > 0) {
673
+ GITypeInfo* el = gi_type_info_get_param_type(ti, 0);
674
+ if (el != nullptr && gi_type_info_get_tag(el) != GI_TYPE_TAG_INTERFACE)
675
+ size = CElementSize(el) * fixed;
676
+ if (el != nullptr) gi_base_info_unref(el);
677
+ }
678
+ } else if (tg == GI_TYPE_TAG_INTERFACE) {
679
+ GIBaseInfo* si = gi_type_info_get_interface(ti);
680
+ if (si != nullptr && GI_IS_STRUCT_INFO(si)) {
681
+ size = gi_struct_info_get_size(reinterpret_cast<GIStructInfo*>(si));
682
+ } else if (si != nullptr && GI_IS_UNION_INFO(si)) {
683
+ size = gi_union_info_get_size(reinterpret_cast<GIUnionInfo*>(si));
684
+ }
685
+ if (si != nullptr) {
686
+ GType gt = gi_registered_type_info_get_g_type(
687
+ reinterpret_cast<GIRegisteredTypeInfo*>(si));
688
+ if (gt != G_TYPE_INVALID && G_TYPE_IS_BOXED(gt)) boxedGType = gt;
689
+ gi_base_info_unref(si);
690
+ }
691
+ if (boxedGType == 0) size = 0; // non-boxed struct OUT needs field access
692
+ }
693
+ if (size == 0) {
694
+ Napi::TypeError::New(
695
+ env, "super." + vname + ": caller-allocates OUT parameter type is not yet supported")
696
+ .ThrowAsJavaScriptException();
697
+ ok = false;
698
+ } else {
699
+ gpointer blob = g_malloc0(size);
700
+ callerAllocBlob[i] = blob;
701
+ callerAllocGType[i] = boxedGType;
702
+ giArgs[1 + i].v_pointer = blob;
703
+ }
704
+ } else if (!IsSupportedOutType(ti, &why)) {
705
+ Napi::TypeError::New(env, "super." + vname + ": OUT " + why +
706
+ " parameters are not yet supported")
707
+ .ThrowAsJavaScriptException();
708
+ ok = false;
709
+ } else if (dir == GI_DIRECTION_INOUT &&
710
+ (tg == GI_TYPE_TAG_ARRAY || tg == GI_TYPE_TAG_GLIST ||
711
+ tg == GI_TYPE_TAG_GSLIST || tg == GI_TYPE_TAG_GHASH)) {
712
+ // INOUT container: same read-modify-write model as the function path. The ffi
713
+ // param is a single `container**` the parent reads (in) then reassigns (out);
714
+ // we stash the in-container in slots[i] and point giArgs[1+i] at &slots[i].
715
+ // OWNERSHIP: JsToInContainer records the ORIGINAL in-container in inContainers,
716
+ // so FreeInContainer (after the reads) releases it per the IN transfer; the
717
+ // out-container the parent wrote into slots[i] is read + freed per the OUT
718
+ // transfer by ReadOutOrReturn below.
719
+ Napi::Value v = jsCursor < args.Length() ? args.Get(jsCursor) : env.Undefined();
720
+ jsCursor++;
721
+ if (!IsSupportedContainerType(ti, &why)) {
722
+ Napi::TypeError::New(env, "super." + vname + ": INOUT " + why +
723
+ " parameters are not yet supported")
724
+ .ThrowAsJavaScriptException();
725
+ ok = false;
726
+ } else {
727
+ GITransfer tr = gi_arg_info_get_ownership_transfer(ai);
728
+ gpointer cptr = nullptr;
729
+ long ccount = 0;
730
+ if ((v.IsNull() || v.IsUndefined()) && gi_arg_info_may_be_null(ai)) {
731
+ ok = true; // nullable INOUT container → NULL in-container (count 0)
732
+ } else {
733
+ ok = JsToInContainer(env, v, ti, tr, &cptr, &ccount, &inContainers);
734
+ }
735
+ if (ok) {
736
+ slots[i].v_pointer = cptr;
737
+ giArgs[1 + i].v_pointer = &slots[i];
738
+ if (tg == GI_TYPE_TAG_ARRAY) {
739
+ unsigned int L = 0;
740
+ if (gi_type_info_get_array_length_index(ti, &L) && L < nDeclared) {
741
+ GIArgInfo* la = gi_callable_info_get_arg(ci, L);
742
+ GITypeInfo* lt = gi_arg_info_get_type_info(la);
743
+ // An INOUT length's slot is what the parent reads+writes (giArgs[1+L]
744
+ // already points at &slots[L] via the skip-branch); a plain IN length
745
+ // takes the value directly in its ffi slot.
746
+ if (dirs[L] == GI_DIRECTION_INOUT) WriteLengthValue(lt, &slots[L], ccount);
747
+ else if (dirs[L] == GI_DIRECTION_IN) WriteLengthValue(lt, &giArgs[1 + L], ccount);
748
+ gi_base_info_unref(lt);
749
+ gi_base_info_unref(la);
750
+ }
751
+ }
752
+ }
753
+ }
754
+ } else if (dir == GI_DIRECTION_INOUT) {
755
+ // INOUT scalar: marshal the JS input into the slot (like IN); the parent
756
+ // reads + writes it through &slots[i].
757
+ Napi::Value v = jsCursor < args.Length() ? args.Get(jsCursor) : env.Undefined();
758
+ jsCursor++;
759
+ GITransfer tr = gi_arg_info_get_ownership_transfer(ai);
760
+ if (JsToGIArgument(env, v, ti, &slots[i], &holds[i], tr, &ownedInStrings))
761
+ giArgs[1 + i].v_pointer = &slots[i];
762
+ else
763
+ ok = false; // JsToGIArgument already threw
764
+ } else {
765
+ // Pure OUT: the callee writes into the slot; no JS arg is consumed.
766
+ giArgs[1 + i].v_pointer = &slots[i];
767
+ }
768
+ } else if (tg == GI_TYPE_TAG_ARRAY || tg == GI_TYPE_TAG_GLIST ||
769
+ tg == GI_TYPE_TAG_GSLIST || tg == GI_TYPE_TAG_GHASH) {
770
+ // IN container: build the C container, autofill an IN length arg.
771
+ std::string why;
772
+ Napi::Value v = jsCursor < args.Length() ? args.Get(jsCursor) : env.Undefined();
773
+ jsCursor++;
774
+ if (!IsSupportedContainerType(ti, &why)) {
775
+ Napi::TypeError::New(env, "super." + vname + ": IN " + why +
776
+ " parameters are not yet supported")
777
+ .ThrowAsJavaScriptException();
778
+ ok = false;
779
+ } else {
780
+ GITransfer tr = gi_arg_info_get_ownership_transfer(ai);
781
+ gpointer cptr = nullptr;
782
+ long ccount = 0;
783
+ ok = JsToInContainer(env, v, ti, tr, &cptr, &ccount, &inContainers);
784
+ if (ok) {
785
+ giArgs[1 + i].v_pointer = cptr;
786
+ if (tg == GI_TYPE_TAG_ARRAY) {
787
+ unsigned int L = 0;
788
+ if (gi_type_info_get_array_length_index(ti, &L) && L < nDeclared &&
789
+ dirs[L] == GI_DIRECTION_IN) {
790
+ GIArgInfo* la = gi_callable_info_get_arg(ci, L);
791
+ GITypeInfo* lt = gi_arg_info_get_type_info(la);
792
+ WriteLengthValue(lt, &giArgs[1 + L], ccount);
793
+ gi_base_info_unref(lt);
794
+ gi_base_info_unref(la);
795
+ }
796
+ }
797
+ }
798
+ }
799
+ } else {
800
+ // IN scalar/object/string: marshal by value into giArgs[1+i].
801
+ Napi::Value v = jsCursor < args.Length() ? args.Get(jsCursor) : env.Undefined();
802
+ jsCursor++;
803
+ GITransfer tr = gi_arg_info_get_ownership_transfer(ai);
804
+ ok = JsToGIArgument(env, v, ti, &giArgs[1 + i], &holds[i], tr, &ownedInStrings);
805
+ }
806
+
807
+ avalue.push_back(&giArgs[1 + i]);
808
+ gi_base_info_unref(ti);
809
+ gi_base_info_unref(ai);
810
+ }
811
+ if (!ok) {
812
+ for (const InContainer& c : inContainers) FreeInContainer(c);
813
+ for (gpointer s : ownedInStrings) g_free(s); // never reached the callee (#658)
814
+ for (gpointer b : callerAllocBlob)
815
+ if (b != nullptr) g_free(b);
816
+ return env.Null();
817
+ }
818
+
819
+ // can-throw vfuncs take a trailing GError** the parent writes the error into.
820
+ // libffi reads each argument's VALUE from the location avalue[i] points at, so
821
+ // for the GError** slot avalue must point at a variable holding &error (a
822
+ // GError**) — i.e. one extra level of indirection (&errorPtr), NOT &error
823
+ // (which would pass NULL as the GError** and silently swallow the parent error).
824
+ GError* error = nullptr;
825
+ GError** errorPtr = &error;
826
+ if (canThrow) avalue.push_back(&errorPtr);
827
+
828
+ GITypeInfo* retType = gi_callable_info_get_return_type(ci);
829
+ GIFFIReturnValue ffiRet;
830
+ ffiRet.v_uint64 = 0;
831
+ ffi_call(&vf->cif, reinterpret_cast<void (*)(void)>(vf->parentPtr), &ffiRet, avalue.data());
832
+
833
+ if (canThrow && error != nullptr) {
834
+ gi_base_info_unref(retType);
835
+ for (const InContainer& c : inContainers) FreeInContainer(c);
836
+ for (gpointer s : ownedInStrings) g_free(s); // callee did not adopt them on error
837
+ for (gpointer b : callerAllocBlob)
838
+ if (b != nullptr) g_free(b);
839
+ ThrowGError(env, error, "super." + vname);
840
+ return env.Null();
841
+ }
842
+
843
+ // Assemble the JS return per the GJS convention: the (non-void) return value leads,
844
+ // followed by each OUT/INOUT value in argument order. Exactly one element → return
845
+ // it bare; many → a JS Array; none → undefined. Matches what a JS override of this
846
+ // vfunc receives as its call args and hands back.
847
+ std::vector<Napi::Value> results;
848
+ GITransfer retTransfer = gi_callable_info_get_caller_owns(ci);
849
+ if (gi_type_info_get_tag(retType) != GI_TYPE_TAG_VOID) {
850
+ // Extract the (possibly narrowed) ffi return into a normalised GIArgument, then
851
+ // marshal it to JS — the portable, endianness-safe path. ReadOutOrReturn honours
852
+ // an array-length slot / container element type, and the declared transfer so a
853
+ // transfer-full parent return is owned by JS rather than leaked.
854
+ GIArgument retArg;
855
+ retArg.v_uint64 = 0;
856
+ gi_type_info_extract_ffi_return_value(retType, &ffiRet, &retArg);
857
+ results.push_back(ReadOutOrReturn(env, ci, retType, &retArg, retTransfer, &slots));
858
+ }
859
+ gi_base_info_unref(retType);
860
+
861
+ for (unsigned int i = 0; i < nDeclared && !env.IsExceptionPending(); i++) {
862
+ if (dirs[i] != GI_DIRECTION_OUT && dirs[i] != GI_DIRECTION_INOUT) continue;
863
+ if (skip[i]) continue; // an array-length arg — surfaced via its array, not alone
864
+ GIArgInfo* ai = gi_callable_info_get_arg(ci, i);
865
+ GITypeInfo* ti = gi_arg_info_get_type_info(ai);
866
+ GITransfer transfer = gi_arg_info_get_ownership_transfer(ai);
867
+ if (callerAllocBlob[i] != nullptr) {
868
+ // Caller-allocated OUT: the callee filled the blob IN PLACE (the blob is the
869
+ // data, not a pointer to it). Wrap it, then release the blob — mirroring
870
+ // InvokeFunctionInfo / gjs CallerAllocatesOut.
871
+ gpointer blob = callerAllocBlob[i];
872
+ if (gi_type_info_get_tag(ti) == GI_TYPE_TAG_ARRAY) {
873
+ GIArgument a;
874
+ memset(&a, 0, sizeof(a));
875
+ a.v_pointer = blob;
876
+ results.push_back(ReadOutOrReturn(env, ci, ti, &a, GI_TRANSFER_NOTHING, &slots));
877
+ g_free(blob);
878
+ } else {
879
+ GType gt = callerAllocGType[i];
880
+ gpointer owned = g_boxed_copy(gt, blob);
881
+ results.push_back(MakeBoxedHandle(env, owned, gt, true));
882
+ g_boxed_free(gt, blob);
883
+ }
884
+ callerAllocBlob[i] = nullptr;
885
+ gi_base_info_unref(ti);
886
+ gi_base_info_unref(ai);
887
+ continue;
888
+ }
889
+ results.push_back(ReadOutOrReturn(env, ci, ti, &slots[i], transfer, &slots));
890
+ gi_base_info_unref(ti);
891
+ gi_base_info_unref(ai);
892
+ }
893
+
894
+ // A transfer-none IN container may back a transfer-none OUT/return, so free the IN
895
+ // containers only AFTER the reads above (transfer-full ones were adopted). The
896
+ // transfer-full IN/INOUT strings were adopted by the parent on this success path, so
897
+ // they are intentionally NOT freed here (the callee owns them now).
898
+ for (const InContainer& c : inContainers) FreeInContainer(c);
899
+
900
+ if (env.IsExceptionPending()) return env.Null();
901
+ if (results.empty()) return env.Undefined();
902
+ if (results.size() == 1) return results[0];
903
+ Napi::Array arr = Napi::Array::New(env, results.size());
904
+ for (size_t k = 0; k < results.size(); k++) arr.Set(static_cast<uint32_t>(k), results[k]);
905
+ return arr;
906
+ }
907
+
908
+ // Shared registration core: subclass `name` from an ALREADY-RESOLVED parent GObject
909
+ // GType, reading the optional { properties, signals, vfuncs, template, children, ... }
910
+ // from `optsValue` (a non-object → no options). Returns the tagged GType handle for
911
+ // the new type, or throws + returns env.Null(). Both entry points use it:
912
+ // - RegisterClass — parent resolved from introspection (namespace+typename),
913
+ // - RegisterClassFromGType — parent given directly as a #667 GType handle (the
914
+ // multi-level registered-of-registered path; the parent has no GIR entry).
915
+ // Everything past parent resolution is identical: g_type_register_static makes the
916
+ // child class struct a memcpy of the parent's, so a registered ancestor's installed
917
+ // properties/signals/vfunc slots compose for free via normal GObject inheritance.
918
+ static Napi::Value RegisterClassImpl(Napi::Env env, const std::string& name, GType parentType,
919
+ Napi::Value optsValue) {
920
+ if (g_type_from_name(name.c_str()) != 0) {
921
+ Napi::Error::New(env, "a GType named '" + name + "' is already registered")
922
+ .ThrowAsJavaScriptException();
923
+ return env.Null();
924
+ }
925
+ if (!G_TYPE_IS_OBJECT(parentType)) {
926
+ const char* pn = g_type_name(parentType);
927
+ Napi::TypeError::New(env, std::string(pn != nullptr ? pn : "the parent type") +
928
+ " is not a subclassable GObject type")
929
+ .ThrowAsJavaScriptException();
930
+ return env.Null();
931
+ }
932
+
933
+ GTypeQuery query;
934
+ g_type_query(parentType, &query);
935
+ if (query.type == 0) {
936
+ const char* pn = g_type_name(parentType);
937
+ Napi::Error::New(env, std::string("failed to query parent type ") + (pn != nullptr ? pn : "?"))
938
+ .ThrowAsJavaScriptException();
939
+ return env.Null();
940
+ }
941
+
942
+ // Parse the optional { properties, signals } and build the class metadata.
943
+ NodeGiClassData* cd = new NodeGiClassData();
944
+ cd->parentGet = nullptr;
945
+ cd->parentSet = nullptr;
946
+ if (optsValue.IsObject()) {
947
+ Napi::Object opts = optsValue.As<Napi::Object>();
948
+ if (opts.Has("properties") && opts.Get("properties").IsArray()) {
949
+ Napi::Array props = opts.Get("properties").As<Napi::Array>();
950
+ for (uint32_t i = 0; i < props.Length(); i++) {
951
+ Napi::Value pv = props.Get(i);
952
+ if (!pv.IsObject()) continue;
953
+ std::string perr;
954
+ GParamSpec* ps = BuildParamSpec(env, pv.As<Napi::Object>(), &perr);
955
+ if (ps == nullptr) {
956
+ for (GParamSpec* done : cd->properties) {
957
+ g_param_spec_ref_sink(done);
958
+ g_param_spec_unref(done);
959
+ }
960
+ delete cd;
961
+ Napi::TypeError::New(env, "registerClass property: " + perr).ThrowAsJavaScriptException();
962
+ return env.Null();
963
+ }
964
+ cd->properties.push_back(ps);
965
+ }
966
+ }
967
+ if (opts.Has("signals") && opts.Get("signals").IsArray()) {
968
+ Napi::Array sigs = opts.Get("signals").As<Napi::Array>();
969
+ for (uint32_t i = 0; i < sigs.Length(); i++) {
970
+ Napi::Value sv = sigs.Get(i);
971
+ if (!sv.IsObject()) continue;
972
+ Napi::Object so = sv.As<Napi::Object>();
973
+ if (!so.Has("name") || !so.Get("name").IsString()) continue;
974
+ NodeGiSignalDef sd;
975
+ sd.name = so.Get("name").As<Napi::String>().Utf8Value();
976
+ sd.returnType = G_TYPE_NONE;
977
+ if (so.Has("returnType") && so.Get("returnType").IsString()) {
978
+ GType rt = TypeNameToGType(so.Get("returnType").As<Napi::String>().Utf8Value());
979
+ if (rt != G_TYPE_INVALID) sd.returnType = rt;
980
+ }
981
+ sd.flags = (so.Has("flags") && so.Get("flags").IsNumber())
982
+ ? static_cast<GSignalFlags>(so.Get("flags").As<Napi::Number>().Int32Value())
983
+ : G_SIGNAL_RUN_LAST;
984
+ if (so.Has("paramTypes") && so.Get("paramTypes").IsArray()) {
985
+ Napi::Array pts = so.Get("paramTypes").As<Napi::Array>();
986
+ for (uint32_t j = 0; j < pts.Length(); j++) {
987
+ // NodeGiToUtf8: terminate-safe (a swallowed Get/coercion failure must
988
+ // not cascade into Error::New(nullptr) — see common.h).
989
+ GType t = TypeNameToGType(NodeGiToUtf8(pts.Get(j)));
990
+ if (t != G_TYPE_INVALID && t != G_TYPE_NONE) sd.paramTypes.push_back(t);
991
+ }
992
+ }
993
+ cd->signals.push_back(sd);
994
+ }
995
+ }
996
+ // vfuncs: an object { "<vfunc-name>": <jsFunction>, ... }. Each holds a strong
997
+ // napi_ref for the class lifetime (resolved + hooked up in class_init).
998
+ if (opts.Has("vfuncs") && opts.Get("vfuncs").IsObject()) {
999
+ Napi::Object vf = opts.Get("vfuncs").As<Napi::Object>();
1000
+ Napi::Array keys = vf.GetPropertyNames();
1001
+ // Empty keys = swallowed napi failure (terminating env): keys.Length()
1002
+ // would abort via Error::New(nullptr) — skip the block cleanly.
1003
+ for (uint32_t i = 0; !keys.IsEmpty() && i < keys.Length(); i++) {
1004
+ std::string vname = NodeGiToUtf8(keys.Get(i));
1005
+ Napi::Value fnv = vf.Get(vname);
1006
+ if (!fnv.IsFunction()) continue;
1007
+ NodeGiVFunc* rec = new NodeGiVFunc();
1008
+ rec->env = env;
1009
+ rec->name = vname;
1010
+ rec->info = nullptr;
1011
+ rec->closure = nullptr;
1012
+ rec->fn = nullptr;
1013
+ rec->parentPtr = nullptr;
1014
+ napi_create_reference(env, fnv, 1, &rec->fn);
1015
+ cd->vfuncs.push_back(rec);
1016
+ }
1017
+ }
1018
+ // template: a Gtk.Widget composite template. Accepts a Uint8Array/Buffer of
1019
+ // inline UI-XML, a "resource:///…" path string (→ set_template_from_resource),
1020
+ // or a plain inline UI-XML string. Installed on the class in class_init.
1021
+ if (opts.Has("template")) {
1022
+ Napi::Value tv = opts.Get("template");
1023
+ if (tv.IsString()) {
1024
+ std::string s = tv.As<Napi::String>().Utf8Value();
1025
+ const std::string kResource = "resource://";
1026
+ if (s.rfind(kResource, 0) == 0) {
1027
+ // "resource:///path" → the resource PATH "/path" (strip the scheme +
1028
+ // authority "resource://"), matching gtk_widget_class_set_template_from_resource.
1029
+ cd->templateResource = s.substr(kResource.size());
1030
+ cd->hasTemplate = true;
1031
+ } else {
1032
+ // Inline UI-XML string → owned GBytes copy.
1033
+ cd->templateBytes = g_bytes_new(s.data(), s.size());
1034
+ cd->hasTemplate = true;
1035
+ }
1036
+ } else if (tv.IsBuffer()) {
1037
+ Napi::Buffer<uint8_t> b = tv.As<Napi::Buffer<uint8_t>>();
1038
+ cd->templateBytes = g_bytes_new(b.Data(), b.Length());
1039
+ cd->hasTemplate = true;
1040
+ } else if (tv.IsTypedArray()) {
1041
+ Napi::TypedArray ta = tv.As<Napi::TypedArray>();
1042
+ const uint8_t* data = static_cast<const uint8_t*>(ta.ArrayBuffer().Data()) + ta.ByteOffset();
1043
+ cd->templateBytes = g_bytes_new(data, ta.ByteLength());
1044
+ cd->hasTemplate = true;
1045
+ }
1046
+ }
1047
+ if (opts.Has("cssName") && opts.Get("cssName").IsString()) {
1048
+ cd->cssName = opts.Get("cssName").As<Napi::String>().Utf8Value();
1049
+ }
1050
+ if (opts.Has("children") && opts.Get("children").IsArray()) {
1051
+ Napi::Array arr = opts.Get("children").As<Napi::Array>();
1052
+ for (uint32_t i = 0; i < arr.Length(); i++) {
1053
+ if (arr.Get(i).IsString()) cd->children.push_back(arr.Get(i).As<Napi::String>().Utf8Value());
1054
+ }
1055
+ }
1056
+ if (opts.Has("internalChildren") && opts.Get("internalChildren").IsArray()) {
1057
+ Napi::Array arr = opts.Get("internalChildren").As<Napi::Array>();
1058
+ for (uint32_t i = 0; i < arr.Length(); i++) {
1059
+ if (arr.Get(i).IsString())
1060
+ cd->internalChildren.push_back(arr.Get(i).As<Napi::String>().Utf8Value());
1061
+ }
1062
+ }
1063
+ }
1064
+
1065
+ GTypeInfo typeInfo = {};
1066
+ typeInfo.class_size = static_cast<guint16>(query.class_size);
1067
+ typeInfo.instance_size = static_cast<guint16>(query.instance_size);
1068
+ // class_init installs the custom properties + signals (and records the class
1069
+ // data even when there are none, so the property vfuncs can find it).
1070
+ typeInfo.class_init = NodeGiClassInit;
1071
+ typeInfo.class_data = cd;
1072
+
1073
+ GType newType = g_type_register_static(parentType, name.c_str(), &typeInfo, (GTypeFlags)0);
1074
+ if (newType == 0) {
1075
+ for (NodeGiVFunc* vf : cd->vfuncs) {
1076
+ if (vf->fn != nullptr) napi_delete_reference(env, vf->fn);
1077
+ delete vf;
1078
+ }
1079
+ delete cd;
1080
+ Napi::Error::New(env, "g_type_register_static failed for '" + name + "'")
1081
+ .ThrowAsJavaScriptException();
1082
+ return env.Null();
1083
+ }
1084
+ // Tag the returned type handle as a GType handle so it doubles as the class's
1085
+ // `$gtype` (G4): the same External feeds constructType (UnwrapGType) AND the
1086
+ // GType marshalling (GObject.type_ensure, g_param_spec_object's value gtype, …).
1087
+ return MakeGTypeHandle(env, newType);
1088
+ }
1089
+
1090
+ // registerClass(name, parentNamespace, parentTypeName, options?) -> typeHandle
1091
+ //
1092
+ // Subclass an INTROSPECTED parent, resolved by namespace + typename. Used when the
1093
+ // parent class extends a `gi://`-introspected GObject (e.g. `class X extends Adw.Bin`).
1094
+ Napi::Value RegisterClass(const Napi::CallbackInfo& info) {
1095
+ Napi::Env env = info.Env();
1096
+ if (info.Length() < 3 || !info[0].IsString() || !info[1].IsString() || !info[2].IsString()) {
1097
+ Napi::TypeError::New(
1098
+ env,
1099
+ "registerClass(name: string, parentNamespace: string, parentTypeName: string, options?: "
1100
+ "{ properties?, signals?, vfuncs? })")
1101
+ .ThrowAsJavaScriptException();
1102
+ return env.Null();
1103
+ }
1104
+ std::string name = info[0].As<Napi::String>().Utf8Value();
1105
+ std::string pns = info[1].As<Napi::String>().Utf8Value();
1106
+ std::string ptn = info[2].As<Napi::String>().Utf8Value();
1107
+
1108
+ // Resolve the parent GType from its introspection info.
1109
+ GIRepository* repo = DupDefaultRepository();
1110
+ GIBaseInfo* base = gi_repository_find_by_name(repo, pns.c_str(), ptn.c_str());
1111
+ bool isObject = base != nullptr && GI_IS_OBJECT_INFO(base);
1112
+ GType parentType =
1113
+ isObject ? gi_registered_type_info_get_g_type(reinterpret_cast<GIRegisteredTypeInfo*>(base))
1114
+ : G_TYPE_INVALID;
1115
+ if (base != nullptr) gi_base_info_unref(base);
1116
+ g_object_unref(repo);
1117
+ if (!isObject || !G_TYPE_IS_OBJECT(parentType)) {
1118
+ Napi::TypeError::New(env, pns + "." + ptn + " is not a subclassable GObject type")
1119
+ .ThrowAsJavaScriptException();
1120
+ return env.Null();
1121
+ }
1122
+
1123
+ return RegisterClassImpl(env, name, parentType, info.Length() >= 4 ? info[3] : env.Undefined());
1124
+ }
1125
+
1126
+ // registerClassFromGType(name, parentGType, options?) -> typeHandle
1127
+ //
1128
+ // Multi-level registered subclassing (G2): subclass directly from a parent GType
1129
+ // HANDLE (the #667 kGTypeHandleTag External a previous registerClass returned),
1130
+ // instead of resolving the parent through introspection. The parent is itself a
1131
+ // registered (dynamic) type with no GIR entry, so the namespace+typename path
1132
+ // (RegisterClass) cannot find it; the L1 layer (findParentGType) passes the parent's
1133
+ // `$gtype` handle here. The shared core (RegisterClassImpl) then subclasses from the
1134
+ // resolved GType identically, so a registered ancestor's properties/signals/vfunc
1135
+ // slots compose for free via GObject inheritance.
1136
+ Napi::Value RegisterClassFromGType(const Napi::CallbackInfo& info) {
1137
+ Napi::Env env = info.Env();
1138
+ if (info.Length() < 2 || !info[0].IsString()) {
1139
+ Napi::TypeError::New(
1140
+ env, "registerClassFromGType(name: string, parentGType: handle, options?: object)")
1141
+ .ThrowAsJavaScriptException();
1142
+ return env.Null();
1143
+ }
1144
+ std::string name = info[0].As<Napi::String>().Utf8Value();
1145
+ GType parentType = UnwrapGType(env, info[1]); // throws (returns 0) on a non-GType arg
1146
+ if (parentType == 0) return env.Null();
1147
+ return RegisterClassImpl(env, name, parentType, info.Length() >= 3 ? info[2] : env.Undefined());
1148
+ }
1149
+
1150
+ // constructType(typeHandle, props?: Record<string, unknown>) -> External<GObject>
1151
+ Napi::Value ConstructType(const Napi::CallbackInfo& info) {
1152
+ Napi::Env env = info.Env();
1153
+ if (info.Length() < 1) {
1154
+ Napi::TypeError::New(env, "constructType(typeHandle, props?: object)")
1155
+ .ThrowAsJavaScriptException();
1156
+ return env.Null();
1157
+ }
1158
+ GType gtype = UnwrapGType(env, info[0]);
1159
+ if (gtype == 0) return env.Null();
1160
+ if (!G_TYPE_IS_OBJECT(gtype)) {
1161
+ Napi::TypeError::New(env, "type handle is not a constructible GObject type")
1162
+ .ThrowAsJavaScriptException();
1163
+ return env.Null();
1164
+ }
1165
+ Napi::Object props = (info.Length() >= 2 && info[1].IsObject()) ? info[1].As<Napi::Object>()
1166
+ : Napi::Object::New(env);
1167
+ return ConstructGObject(env, gtype, props, std::string(g_type_name(gtype)));
1168
+ }
1169
+
1170
+ } // namespace nodegi