@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/signals.cc ADDED
@@ -0,0 +1,535 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Signals: JS closures, connect/emit/disconnect + the Gtk.Widget composite-template callback scope.
3
+
4
+ #include "common.h"
5
+
6
+ namespace nodegi {
7
+
8
+ // ---- signals (milestone 1) ----
9
+ //
10
+ // A GClosure that wraps a JS callback. The callback is held by a strong
11
+ // napi_ref; the closure's finalize notifier drops it. The generic marshal
12
+ // converts the signal's GValue params to JS — the EMITTER instance (param 0)
13
+ // first, then the signal's own params (1..n), matching GJS — and the JS return
14
+ // into the signal return GValue.
15
+ //
16
+ // Narrowed-leak semantics (with the toggle-ref bridge): a handler that does NOT
17
+ // close over its own object is now collectable once C is the sole owner
18
+ // (toggle-down → weak → GC). A handler that DOES close over its object forms a
19
+ // GObject -> GClosure -> napi_ref -> callback -> wrapper -> handle cycle the GC
20
+ // cannot break across C, so it keeps the object alive until disconnect — which is
21
+ // the GJS-faithful contract (a connected self-referential handler is a reason to
22
+ // stay alive). disconnect drops the closure's napi_ref (JsClosureFinalize),
23
+ // breaking the cycle so the next GC collects the object. The callback ref stays
24
+ // STRONG while connected (a connected handler must fire). Verified by the
25
+ // signal-cycle case in test/gc-identity.test.mjs.
26
+
27
+ struct JsClosureData {
28
+ napi_env env;
29
+ napi_ref callback;
30
+ };
31
+
32
+ static void JsClosureFinalize(gpointer data, GClosure* /*closure*/) {
33
+ JsClosureData* jc = static_cast<JsClosureData*>(data);
34
+ if (jc == nullptr) return;
35
+ if (jc->callback != nullptr) napi_delete_reference(jc->env, jc->callback);
36
+ g_free(jc);
37
+ }
38
+
39
+ static void JsClosureMarshalImpl(GClosure* closure, GValue* return_value, guint n_param_values,
40
+ const GValue* param_values, bool unboxNestedValues) {
41
+ JsClosureData* jc = static_cast<JsClosureData*>(closure->data);
42
+ if (jc == nullptr || jc->callback == nullptr) return;
43
+ // ENV-TEARDOWN GATE: a signal emitted by a dispose cascade at env teardown (or
44
+ // on a terminating worker) reaches this marshal when the env may no longer
45
+ // enter JS; the GValue marshalling / napi_call_function would then abort via
46
+ // node-addon-api's noexcept throw path. Skip the handler — GJS likewise never
47
+ // dispatches JS during GC/context teardown (see toggle.cc NodeGiJsAvailable).
48
+ if (!NodeGiJsAvailable(jc->env)) {
49
+ if (NodeGiToggleDebugEnabled())
50
+ NodeGiToggleDebugLog("signal closure skipped: JS unavailable on env %p (teardown/terminate)",
51
+ static_cast<void*>(jc->env));
52
+ // The probe is also false when a JS exception is PENDING on a live env. Keep
53
+ // the pre-existing depth semantics: a loop-dispatched closure (depth 0)
54
+ // surfaces + clears so the pump stays alive; a synchronous emit leaves it
55
+ // pending for the JS emit() caller. At real teardown Surface... is a no-op
56
+ // (nothing pending / napi reads fail gracefully).
57
+ if (g_syncEmitDepth == 0) SurfacePendingException(jc->env, "signal handler");
58
+ return;
59
+ }
60
+ Napi::Env env(jc->env);
61
+ Napi::HandleScope scope(env);
62
+ // JS dispatched from a pump-driven context iteration must not inherit the
63
+ // pump's in-iteration flag (see NodeGiPumpJsDispatchScope in common.h).
64
+ NodeGiPumpJsDispatchScope pumpWindow;
65
+
66
+ napi_value cbv = nullptr;
67
+ if (napi_get_reference_value(jc->env, jc->callback, &cbv) != napi_ok || cbv == nullptr) return;
68
+
69
+ // GJS parity: the JS handler receives the EMITTER (the object the signal was
70
+ // emitted on, param_values[0]) as its first argument, then the signal's own
71
+ // parameters (param_values[1..n]). GJS passes the emitter first (refs/gjs
72
+ // gi/value.cpp / object signal invocation), so a positional handler such as
73
+ // `(listbox, row) => …` / `_onRowSelected(_listbox, row)` binds correctly. For
74
+ // a `notify::x` emission the args are therefore (object, pspec), as in GJS.
75
+ std::vector<napi_value> args;
76
+ args.reserve(n_param_values);
77
+ for (guint i = 0; i < n_param_values; i++) {
78
+ // Generic (IN-arg) closures mirror gjs's closure marshal (refs/gjs/gi/
79
+ // value.cpp gjs_value_from_g_value): a G_TYPE_VALUE-boxed param is UNBOXED to
80
+ // the contained value (recursively), so e.g. GIMarshallingTests-style
81
+ // `g_closure_invoke` consumers hand the JS function plain values, exactly as
82
+ // GJS does. Signal closures keep the existing param conversion untouched.
83
+ const GValue* pv = &param_values[i];
84
+ if (unboxNestedValues) {
85
+ while (G_VALUE_HOLDS(pv, G_TYPE_VALUE)) {
86
+ const GValue* inner = static_cast<const GValue*>(g_value_get_boxed(pv));
87
+ if (inner == nullptr) break;
88
+ pv = inner;
89
+ }
90
+ }
91
+ Napi::Value v = GValueToJs(env, pv);
92
+ if (env.IsExceptionPending()) {
93
+ // An arg-marshal failure: propagate to a synchronous emit() caller, or
94
+ // surface + clear when dispatched from the loop (see g_syncEmitDepth).
95
+ if (g_syncEmitDepth == 0) SurfacePendingException(jc->env, "signal handler");
96
+ return;
97
+ }
98
+ args.push_back(v);
99
+ }
100
+
101
+ napi_value result = nullptr;
102
+ napi_status st;
103
+ if (g_syncEmitDepth > 0) {
104
+ // Synchronous JS emit() — JS frames sit below, so the microtask checkpoint
105
+ // is skipped at this depth ANYWAY (make_callback nesting semantics), and
106
+ // plain napi_call_function is therefore checkpoint-equivalent here. It is
107
+ // also load-bearing for cross-runtime parity: Bun's napi_make_callback
108
+ // SWALLOWS a synchronous handler throw (returns napi_ok with nothing
109
+ // pending), which silently broke the "a handler exception propagates out
110
+ // of emit()" contract on Bun (conformance-subset signal tests, PR #766 CI)
111
+ // while Node kept propagating. call_function leaves the pending exception
112
+ // on the env on all three runtimes, so the JS emit() caller rethrows it.
113
+ st = napi_call_function(jc->env, env.Undefined(), cbv, args.size(), args.data(), &result);
114
+ } else {
115
+ // Loop-dispatched (no JS emit() caller below): napi_make_callback runs the
116
+ // nextTick + microtask checkpoint at the callback boundary — the same
117
+ // contract the GI-callback (calls.cc) and vfunc (class.cc) trampolines
118
+ // already follow, and GJS semantics exactly (SpiderMonkey drains the
119
+ // promise-job queue when the last JS frame exits). Without it, promise
120
+ // continuations resolved by a loop-dispatched signal handler linger until
121
+ // the libuv↔GLib bridge's prepare-phase drain — and running GTK-touching
122
+ // JS from a GSource PREPARE phase mid-iteration breaks the GDK frame clock
123
+ // (the Excalibur-on-node-gi stall: `engine.start()`'s load chain, queued
124
+ // inside the GLArea 'render' dispatch, ran from prepare and froze all
125
+ // further ticks/renders). Receiver: `global`, matching the other
126
+ // trampolines (make_callback requires an object receiver; handler `this`
127
+ // is not part of the signal contract).
128
+ napi_value global = nullptr;
129
+ napi_get_global(jc->env, &global);
130
+ g_loopDispatchDepth++;
131
+ st = napi_make_callback(jc->env, nullptr, global, cbv, args.size(), args.data(), &result);
132
+ g_loopDispatchDepth--;
133
+ }
134
+ if (st != napi_ok) {
135
+ // The handler threw. A synchronous emitSignal() (g_syncEmitDepth > 0) must
136
+ // propagate it to the JS emit() caller (node-gi contract). At depth 0 there
137
+ // is no JS caller to catch it, so it is surfaced + cleared (GJS-style) to keep
138
+ // the GLib/libuv loop alive instead of wedging on the never-cleared pending
139
+ // exception.
140
+ //
141
+ // BY DESIGN (GJS-aligned): depth 0 is NOT only the GLib loop's own dispatch (a
142
+ // GTK click, a GtkBuilder template handler) — it ALSO covers synchronous-but-
143
+ // not-emitSignal triggers: a notify:: from g_object_set / a property set, or an
144
+ // inline C emit such as action.activate(). Those reach this marshal with no
145
+ // emitSignal() frame on the stack (depth 0), so a handler throw is logged +
146
+ // swallowed here rather than rethrown to the JS code that triggered the emit —
147
+ // exactly as GJS reports an uncaught signal-handler exception (gjs_log_exception)
148
+ // instead of surfacing it to the trigger site.
149
+ if (g_syncEmitDepth == 0) SurfacePendingException(jc->env, "signal handler");
150
+ return;
151
+ }
152
+
153
+ if (return_value != nullptr && (G_VALUE_TYPE(return_value) != G_TYPE_INVALID)) {
154
+ JsToGValue(env, Napi::Value(jc->env, result), return_value);
155
+ // The return marshal must never leave a pending JS exception across the
156
+ // C boundary of a loop-dispatched invocation (same contract as the arg/call
157
+ // paths above): surface + clear it so the GLib/libuv pump stays alive.
158
+ if (env.IsExceptionPending() && g_syncEmitDepth == 0) {
159
+ SurfacePendingException(jc->env, "signal handler");
160
+ }
161
+ }
162
+
163
+ // Cross-runtime microtask checkpoint (Bun/Deno — no-op on Node, see loop.cc):
164
+ // an outermost loop-dispatched closure (a DBus method-call/property vtable
165
+ // closure, a GTK signal, a notify::) is where promise continuations it queued
166
+ // must drain — the async-DBus-reply `retval.then(...)` chain in gio-dbus.js
167
+ // never ran on Bun/Deno while a blocking run() owned the thread (their
168
+ // napi_make_callback performs no checkpoint), timing out the client. Only the
169
+ // depth-0 path drains: a synchronous emit() (depth > 0) keeps its
170
+ // call_function + pending-exception contract untouched, and JS frames below
171
+ // it drain when that stack unwinds.
172
+ if (g_syncEmitDepth == 0 && g_loopDispatchDepth == 0) NodeGiMaybeDrainMicrotasks(jc->env);
173
+ }
174
+
175
+ // Signal-closure marshal (`.connect()`, template callbacks): params as-is.
176
+ static void JsClosureMarshal(GClosure* closure, GValue* return_value, guint n_param_values,
177
+ const GValue* param_values, gpointer /*invocation_hint*/,
178
+ gpointer /*marshal_data*/) {
179
+ JsClosureMarshalImpl(closure, return_value, n_param_values, param_values, false);
180
+ }
181
+
182
+ // Generic-closure marshal (a JS fn marshalled as a GClosure IN-arg, invoked by
183
+ // arbitrary C via g_closure_invoke): identical to the signal marshal except that
184
+ // G_TYPE_VALUE-boxed params are unboxed to their contained value, mirroring gjs's
185
+ // gjs_value_from_g_value (see JsClosureMarshalImpl).
186
+ static void JsGenericClosureMarshal(GClosure* closure, GValue* return_value, guint n_param_values,
187
+ const GValue* param_values, gpointer /*invocation_hint*/,
188
+ gpointer /*marshal_data*/) {
189
+ JsClosureMarshalImpl(closure, return_value, n_param_values, param_values, true);
190
+ }
191
+
192
+ // JS function → a floating marshaled GClosure (the engine's twin of GJS's
193
+ // Gjs::Closure::create_marshaled for "boxed" closure args — refs/gjs/gi/
194
+ // arg-cache.cpp GClosureInTransferNone::in). The closure holds a strong ref to
195
+ // the function; JsClosureFinalize drops it when the closure is invalidated /
196
+ // finalized (the callee released its last ref, or the invoke-site released the
197
+ // only ref because the callee never kept one). Ref/sink + release policy lives at
198
+ // the marshalling site (marshal.cc / calls.cc CreatedClosures).
199
+ GClosure* NodeGiMakeGenericJsClosure(Napi::Env env, Napi::Value fn) {
200
+ JsClosureData* jc = g_new0(JsClosureData, 1);
201
+ jc->env = env;
202
+ napi_create_reference(env, fn, 1, &jc->callback);
203
+ GClosure* closure = g_closure_new_simple(sizeof(GClosure), jc);
204
+ g_closure_set_marshal(closure, JsGenericClosureMarshal);
205
+ g_closure_add_finalize_notifier(closure, jc, JsClosureFinalize);
206
+ return closure;
207
+ }
208
+
209
+ // connectSignal(handle, signalName, callback, after?) -> handlerId
210
+ Napi::Value ConnectSignal(const Napi::CallbackInfo& info) {
211
+ Napi::Env env = info.Env();
212
+ if (info.Length() < 3 || !info[1].IsString() || !info[2].IsFunction()) {
213
+ Napi::TypeError::New(env, "connectSignal(handle, signalName: string, callback: function, after?: boolean)")
214
+ .ThrowAsJavaScriptException();
215
+ return env.Null();
216
+ }
217
+ GObject* obj = UnwrapGObject(env, info[0]);
218
+ if (obj == nullptr) return env.Null();
219
+ std::string name = info[1].As<Napi::String>().Utf8Value();
220
+ // NodeGiToBool: terminate-safe (a swallowed coercion failure must not cascade
221
+ // into Error::New(nullptr) — see common.h).
222
+ bool after = info.Length() >= 4 && NodeGiToBool(info[3]);
223
+
224
+ // Parse a possibly-detailed signal name ("notify::prop") into its signal id +
225
+ // detail quark, so GJS-style detailed connects work (common for notify::).
226
+ guint sigid = 0;
227
+ GQuark detail = 0;
228
+ if (!g_signal_parse_name(name.c_str(), G_OBJECT_TYPE(obj), &sigid, &detail, TRUE)) {
229
+ Napi::Error::New(env, std::string("no signal '") + name + "' on " + G_OBJECT_TYPE_NAME(obj))
230
+ .ThrowAsJavaScriptException();
231
+ return env.Null();
232
+ }
233
+
234
+ JsClosureData* jc = g_new0(JsClosureData, 1);
235
+ jc->env = env;
236
+ napi_create_reference(env, info[2], 1, &jc->callback);
237
+
238
+ GClosure* closure = g_closure_new_simple(sizeof(GClosure), jc);
239
+ g_closure_set_marshal(closure, JsClosureMarshal);
240
+ g_closure_add_finalize_notifier(closure, jc, JsClosureFinalize);
241
+
242
+ // g_signal_connect_closure_by_id sinks the floating closure ref + owns it,
243
+ // and honours the detail quark (the by-name variant cannot take a detail).
244
+ gulong id = g_signal_connect_closure_by_id(obj, sigid, detail, closure, after);
245
+ return Napi::Number::New(env, static_cast<double>(id));
246
+ }
247
+
248
+ // emitSignal(handle, signalName, args?) -> returnValue
249
+ Napi::Value EmitSignal(const Napi::CallbackInfo& info) {
250
+ Napi::Env env = info.Env();
251
+ if (info.Length() < 2 || !info[1].IsString()) {
252
+ Napi::TypeError::New(env, "emitSignal(handle, signalName: string, args?: unknown[])")
253
+ .ThrowAsJavaScriptException();
254
+ return env.Null();
255
+ }
256
+ GObject* obj = UnwrapGObject(env, info[0]);
257
+ if (obj == nullptr) return env.Null();
258
+ std::string name = info[1].As<Napi::String>().Utf8Value();
259
+ Napi::Array args = (info.Length() >= 3 && info[2].IsArray()) ? info[2].As<Napi::Array>()
260
+ : Napi::Array::New(env, 0);
261
+
262
+ GType gtype = G_OBJECT_TYPE(obj);
263
+ guint sigid = g_signal_lookup(name.c_str(), gtype);
264
+ if (sigid == 0) {
265
+ Napi::Error::New(env, std::string("no signal '") + name + "' on " + G_OBJECT_TYPE_NAME(obj))
266
+ .ThrowAsJavaScriptException();
267
+ return env.Null();
268
+ }
269
+ GSignalQuery query;
270
+ g_signal_query(sigid, &query);
271
+
272
+ guint n = query.n_params;
273
+ std::vector<GValue> params(n + 1); // [0] = instance
274
+ g_value_init(&params[0], gtype);
275
+ g_value_set_object(&params[0], obj);
276
+ guint initialised = 1;
277
+ bool ok = true;
278
+ for (guint i = 0; i < n; i++) {
279
+ GType pt = query.param_types[i] & ~G_SIGNAL_TYPE_STATIC_SCOPE;
280
+ g_value_init(&params[i + 1], pt);
281
+ initialised = i + 2;
282
+ Napi::Value v = i < args.Length() ? args.Get(i) : env.Undefined();
283
+ // An EMPTY v is the residue of a swallowed args.Get() failure (terminating
284
+ // env / throwing getter): JsToGValue's coercions on it would abort via
285
+ // Error::New(nullptr)'s fatal sites — bail before marshalling.
286
+ if (v.IsEmpty() || !JsToGValue(env, v, &params[i + 1])) {
287
+ ok = false;
288
+ break;
289
+ }
290
+ }
291
+
292
+ GType rt = query.return_type & ~G_SIGNAL_TYPE_STATIC_SCOPE;
293
+ bool hasReturn = rt != G_TYPE_NONE && rt != G_TYPE_INVALID;
294
+ GValue ret = G_VALUE_INIT;
295
+ Napi::Value result = env.Undefined();
296
+ if (ok) {
297
+ if (hasReturn) g_value_init(&ret, rt);
298
+ // Mark this as a SYNCHRONOUS emit so a handler exception propagates back to
299
+ // this JS caller (JsClosureMarshal checks g_syncEmitDepth) rather than being
300
+ // surfaced + swallowed like a loop-dispatched signal.
301
+ g_syncEmitDepth++;
302
+ g_signal_emitv(params.data(), sigid, 0, hasReturn ? &ret : nullptr);
303
+ g_syncEmitDepth--;
304
+ if (hasReturn && !env.IsExceptionPending()) {
305
+ result = GValueToJs(env, &ret);
306
+ g_value_unset(&ret);
307
+ } else if (hasReturn) {
308
+ g_value_unset(&ret);
309
+ }
310
+ }
311
+ for (guint j = 0; j < initialised; j++) g_value_unset(&params[j]);
312
+ return ok ? result : env.Null();
313
+ }
314
+
315
+ // disconnectSignal(handle, handlerId) -> void
316
+ Napi::Value DisconnectSignal(const Napi::CallbackInfo& info) {
317
+ Napi::Env env = info.Env();
318
+ if (info.Length() < 2 || !info[1].IsNumber()) {
319
+ Napi::TypeError::New(env, "disconnectSignal(handle, handlerId: number)").ThrowAsJavaScriptException();
320
+ return env.Undefined();
321
+ }
322
+ GObject* obj = UnwrapGObject(env, info[0]);
323
+ if (obj == nullptr) return env.Undefined();
324
+ gulong id = static_cast<gulong>(info[1].As<Napi::Number>().Int64Value());
325
+ if (id != 0 && g_signal_handler_is_connected(obj, id)) {
326
+ g_signal_handler_disconnect(obj, id);
327
+ }
328
+ return env.Undefined();
329
+ }
330
+
331
+ // GError domain for template-callback resolution failures surfaced to GtkBuilder.
332
+ static GQuark NodeGiBuilderErrorQuark() {
333
+ static GQuark q = g_quark_from_static_string("node-gi-builder-error");
334
+ return q;
335
+ }
336
+
337
+ // ---- Gtk.Widget composite-template callback dispatch -----------------------
338
+ //
339
+ // Mirrors GJS's TemplateBuilderScope (refs/gjs/modules/core/overrides/Gtk.js): a
340
+ // custom GtkBuilderScope is set on a templated widget class via
341
+ // gtk_widget_class_set_template_scope, so when GtkBuilder (run by init_template)
342
+ // hits a `<signal name="clicked" handler="on_click"/>` it asks the scope to
343
+ // create a closure for the handler NAME. We resolve that name to the instance's
344
+ // JS method and connect a closure that dispatches to it.
345
+ //
346
+ // Why a scope (generic resolver) and not gtk_widget_class_bind_template_callback
347
+ // (per-name C symbol): a GtkBuilderScope's create_closure returns a *GClosure*,
348
+ // which is signature-agnostic — GObject marshals the signal's GValue params into
349
+ // it at emit time, so one mechanism handles ANY signal shape without a per-signal
350
+ // libffi cif. A bound C-symbol callback would instead be invoked with the live
351
+ // signal ABI, which we cannot know at bind time. The returned closure reuses the
352
+ // existing JsClosure machinery (JsClosureMarshal/JsClosureFinalize), so the marshal
353
+ // + GValue→JS arg conversion + lifetime are identical to a normal `.connect()`.
354
+ //
355
+ // Handler name → JS method + `this`: create_closure calls the L1 resolver
356
+ // (gi.js resolveTemplateCallback) with the canonical wrapper of the widget being
357
+ // built (gtk_builder_get_current_object) and the handler name; L1 returns the
358
+ // user-prototype method already bound to that instance proxy (so `this` is the
359
+ // template widget — the same cached, toggle-ref-canonical L1 proxy the user holds)
360
+ // and wrapping each native signal arg into a chainable wrapper. The returned JS
361
+ // function is wrapped in a JsClosure here. arg marshalling: JsClosureMarshal skips
362
+ // the emitter (param 0, node-gi's signal convention) and passes the rest.
363
+
364
+ // The GtkBuilderScopeInterface vtable layout (stable public ABI — replicated so the
365
+ // addon needs no GTK headers; it only links girepository-2.0 and dlsym's GTK).
366
+ typedef GType (*NodeGiScopeTypeFromNameFn)(void*, void*, const char*);
367
+ typedef GType (*NodeGiScopeTypeFromFuncFn)(void*, void*, const char*);
368
+ typedef GClosure* (*NodeGiScopeCreateClosureFn)(void*, void*, const char*, guint, GObject*,
369
+ GError**);
370
+ struct NodeGiBuilderScopeIface {
371
+ GTypeInterface g_iface;
372
+ NodeGiScopeTypeFromNameFn get_type_from_name;
373
+ NodeGiScopeTypeFromFuncFn get_type_from_function;
374
+ NodeGiScopeCreateClosureFn create_closure;
375
+ };
376
+
377
+ // create_closure: resolve `function_name` to the widget instance's JS method and
378
+ // return a JsClosure dispatching to it. Returns NULL + sets *error on any failure
379
+ // (handler not found, swapped flag, no env/resolver) so GtkBuilder aborts the build
380
+ // with a clear message instead of silently dropping the handler.
381
+ static GClosure* NodeGiScopeCreateClosure(void* selfScope, void* builder,
382
+ const char* function_name, guint flags,
383
+ GObject* object, GError** error) {
384
+ const char* name = function_name != nullptr ? function_name : "?";
385
+ // SWAPPED is unsupported (matches GJS's "_createClosure" guard).
386
+ if (flags & 1u /* GTK_BUILDER_CLOSURE_SWAPPED */) {
387
+ g_set_error(error, NodeGiBuilderErrorQuark(), 0,
388
+ "node-gi: template signal flag 'swapped' is not supported (handler '%s')", name);
389
+ return nullptr;
390
+ }
391
+ napi_env rawEnv = static_cast<napi_env>(
392
+ g_object_get_qdata(G_OBJECT(selfScope), NodeGiScopeEnvQuark()));
393
+ if (rawEnv == nullptr) {
394
+ g_set_error(error, NodeGiBuilderErrorQuark(), 0,
395
+ "node-gi: template scope has no live JS env to resolve handler '%s'", name);
396
+ return nullptr;
397
+ }
398
+ const GtkTemplateApi* gtk = GetGtkTemplateApi();
399
+ GObject* thisObj = object;
400
+ if (thisObj == nullptr && gtk->builder_get_current_object != nullptr)
401
+ thisObj = gtk->builder_get_current_object(builder);
402
+ if (thisObj == nullptr) {
403
+ g_set_error(error, NodeGiBuilderErrorQuark(), 0,
404
+ "node-gi: no current object to bind template handler '%s'", name);
405
+ return nullptr;
406
+ }
407
+
408
+ Napi::Env env(rawEnv);
409
+ Napi::HandleScope hscope(env);
410
+
411
+ NodeGiEnvData* d = EnvData(rawEnv);
412
+ napi_value resolver = nullptr;
413
+ if (d == nullptr || d->templateCallbackResolver == nullptr ||
414
+ napi_get_reference_value(rawEnv, d->templateCallbackResolver, &resolver) != napi_ok ||
415
+ resolver == nullptr) {
416
+ g_set_error(error, NodeGiBuilderErrorQuark(), 0,
417
+ "node-gi: no template-callback resolver registered (handler '%s')", name);
418
+ return nullptr;
419
+ }
420
+
421
+ Napi::Value handleVal = WrapGObject(env, thisObj, GI_TRANSFER_NOTHING);
422
+ napi_value args[2] = {handleVal, nullptr};
423
+ napi_create_string_utf8(rawEnv, name, NAPI_AUTO_LENGTH, &args[1]);
424
+ napi_value undef = nullptr;
425
+ napi_get_undefined(rawEnv, &undef);
426
+ napi_value resolved = nullptr;
427
+ napi_status st = napi_call_function(rawEnv, undef, resolver, 2, args, &resolved);
428
+ if (st != napi_ok) {
429
+ // The resolver threw — never leave a pending JS exception across the return to
430
+ // GTK (C). Clear it and fold its message into the GError so the build fails
431
+ // cleanly.
432
+ napi_value ex = nullptr;
433
+ std::string detail;
434
+ if (napi_get_and_clear_last_exception(rawEnv, &ex) == napi_ok && ex != nullptr) {
435
+ napi_value msg = nullptr;
436
+ if (napi_get_named_property(rawEnv, ex, "message", &msg) == napi_ok) {
437
+ size_t len = 0;
438
+ if (napi_get_value_string_utf8(rawEnv, msg, nullptr, 0, &len) == napi_ok) {
439
+ detail.resize(len);
440
+ napi_get_value_string_utf8(rawEnv, msg, detail.data(), len + 1, &len);
441
+ }
442
+ }
443
+ }
444
+ g_set_error(error, NodeGiBuilderErrorQuark(), 0,
445
+ "node-gi: template handler '%s' resolver threw%s%s", name,
446
+ detail.empty() ? "" : ": ", detail.c_str());
447
+ return nullptr;
448
+ }
449
+ napi_valuetype rt = napi_undefined;
450
+ napi_typeof(rawEnv, resolved, &rt);
451
+ if (rt != napi_function) {
452
+ g_set_error(error, NodeGiBuilderErrorQuark(), 0,
453
+ "node-gi: no template handler '%s' defined on the instance", name);
454
+ return nullptr;
455
+ }
456
+
457
+ // Wrap the resolved (instance-bound, arg-wrapping) JS function in a JsClosure —
458
+ // the same closure type a `.connect()` produces. The closure owns a strong ref to
459
+ // the function; g_object_watch_closure ties its lifetime to the widget (the
460
+ // handler `this`) and invalidates it when the widget is finalized, exactly like
461
+ // GtkBuilderCScope's own create_closure.
462
+ JsClosureData* jc = g_new0(JsClosureData, 1);
463
+ jc->env = rawEnv;
464
+ napi_create_reference(rawEnv, resolved, 1, &jc->callback);
465
+ GClosure* closure = g_closure_new_simple(sizeof(GClosure), jc);
466
+ g_closure_set_marshal(closure, JsClosureMarshal);
467
+ g_closure_add_finalize_notifier(closure, jc, JsClosureFinalize);
468
+ g_object_watch_closure(thisObj, closure);
469
+ return closure;
470
+ }
471
+
472
+ static void NodeGiBuilderScopeIfaceInit(gpointer g_iface, gpointer /*data*/) {
473
+ // The per-type interface vtable is pre-filled with the GtkBuilderScope interface
474
+ // defaults (get_type_from_name = g_type_from_name, etc.) before this runs, so we
475
+ // only override create_closure — type resolution keeps GTK's default behaviour
476
+ // (sufficient for templates whose object types are already registered).
477
+ NodeGiBuilderScopeIface* iface = static_cast<NodeGiBuilderScopeIface*>(g_iface);
478
+ iface->create_closure = NodeGiScopeCreateClosure;
479
+ }
480
+
481
+ // The GObject subtype implementing GtkBuilderScope (registered once). Returns 0 if
482
+ // the GTK scope API is unavailable (old/missing GTK) → callers skip the scope.
483
+ static GType NodeGiBuilderScopeGetType() {
484
+ static GType type = 0;
485
+ if (type != 0) return type;
486
+ const GtkTemplateApi* gtk = GetGtkTemplateApi();
487
+ if (gtk->builder_scope_get_type == nullptr || gtk->set_template_scope == nullptr) return 0;
488
+ GType ifaceType = gtk->builder_scope_get_type();
489
+ if (ifaceType == 0) return 0;
490
+
491
+ GTypeInfo info = {};
492
+ info.class_size = sizeof(GObjectClass);
493
+ info.instance_size = sizeof(GObject);
494
+ type = g_type_register_static(G_TYPE_OBJECT, "NodeGiBuilderScope", &info,
495
+ static_cast<GTypeFlags>(0));
496
+ if (type == 0) return 0;
497
+ GInterfaceInfo ifaceInfo = {NodeGiBuilderScopeIfaceInit, nullptr, nullptr};
498
+ g_type_add_interface_static(type, ifaceType, &ifaceInfo);
499
+ return type;
500
+ }
501
+
502
+ // Create the class's template-callback scope and install it (class_init). Holds the
503
+ // scope for the class lifetime (process-permanent, like NodeGiClassData) so its env
504
+ // qdata can be refreshed per construction. A no-op when the GTK scope API is absent.
505
+ void NodeGiInstallTemplateScopeOnClass(NodeGiClassData* cd, gpointer g_class) {
506
+ const GtkTemplateApi* gtk = GetGtkTemplateApi();
507
+ if (gtk->set_template_scope == nullptr) return; // older GTK: callbacks unsupported
508
+ GType scopeType = NodeGiBuilderScopeGetType();
509
+ if (scopeType == 0) return;
510
+ GObject* scope = static_cast<GObject*>(g_object_new(scopeType, nullptr));
511
+ if (scope == nullptr) return;
512
+ gtk->set_template_scope(g_class, scope); // the class takes its own ref
513
+ cd->templateScope = scope; // our class-lifetime ref (env-refresh target)
514
+ }
515
+
516
+ // setTemplateCallbackResolver(fn) -> void. L1 registers the resolver that maps a
517
+ // (instanceHandle, handlerName) to the instance's bound JS method (see gi.js).
518
+ Napi::Value SetTemplateCallbackResolver(const Napi::CallbackInfo& info) {
519
+ Napi::Env env = info.Env();
520
+ if (info.Length() < 1 || !info[0].IsFunction()) {
521
+ Napi::TypeError::New(env, "setTemplateCallbackResolver(resolver: function)")
522
+ .ThrowAsJavaScriptException();
523
+ return env.Undefined();
524
+ }
525
+ NodeGiEnvData* d = EnvData(env);
526
+ if (d == nullptr) return env.Undefined();
527
+ if (d->templateCallbackResolver != nullptr) {
528
+ napi_delete_reference(env, d->templateCallbackResolver);
529
+ d->templateCallbackResolver = nullptr;
530
+ }
531
+ napi_create_reference(env, info[0], 1, &d->templateCallbackResolver);
532
+ return env.Undefined();
533
+ }
534
+
535
+ } // namespace nodegi
@@ -0,0 +1,116 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Gtk.Widget composite-template API via dlsym: template init + template children.
3
+
4
+ #include "common.h"
5
+
6
+ namespace nodegi {
7
+
8
+ // Resolve the GTK template API once (C++11 function-local static init is
9
+ // thread-safe; node-gi calls these only on the main thread). dlopen with
10
+ // RTLD_NOLOAD first — requireGi('Gtk','4.0') already dlopened libgtk-4 via the
11
+ // typelib, so this just bumps the refcount; fall back to a plain dlopen so a
12
+ // caller that never required Gtk through the typelib still resolves the symbols.
13
+ const GtkTemplateApi* GetGtkTemplateApi() {
14
+ static GtkTemplateApi api = {};
15
+ static bool initialised = false;
16
+ if (initialised) return &api;
17
+ initialised = true;
18
+ #ifdef _WIN32
19
+ // The Gtk.Widget composite-template API is resolved lazily via dlsym on POSIX
20
+ // only. GTK on Windows is Phase 2; the Phase-1 Windows CI proves the display-
21
+ // free core, which never exercises templates. api.ok stays false → template
22
+ // callers warn + no-op / throw a clear "template API unavailable" error.
23
+ return &api;
24
+ #else
25
+ void* lib = dlopen("libgtk-4.so.1", RTLD_LAZY | RTLD_NOLOAD);
26
+ if (lib == nullptr) lib = dlopen("libgtk-4.so.1", RTLD_LAZY);
27
+ if (lib == nullptr) return &api; // api.ok stays false → callers warn + no-op
28
+ api.set_template = reinterpret_cast<decltype(api.set_template)>(
29
+ dlsym(lib, "gtk_widget_class_set_template"));
30
+ api.set_template_from_resource = reinterpret_cast<decltype(api.set_template_from_resource)>(
31
+ dlsym(lib, "gtk_widget_class_set_template_from_resource"));
32
+ api.bind_template_child_full = reinterpret_cast<decltype(api.bind_template_child_full)>(
33
+ dlsym(lib, "gtk_widget_class_bind_template_child_full"));
34
+ api.set_css_name =
35
+ reinterpret_cast<decltype(api.set_css_name)>(dlsym(lib, "gtk_widget_class_set_css_name"));
36
+ api.init_template =
37
+ reinterpret_cast<decltype(api.init_template)>(dlsym(lib, "gtk_widget_init_template"));
38
+ api.get_template_child = reinterpret_cast<decltype(api.get_template_child)>(
39
+ dlsym(lib, "gtk_widget_get_template_child"));
40
+ api.set_template_scope = reinterpret_cast<decltype(api.set_template_scope)>(
41
+ dlsym(lib, "gtk_widget_class_set_template_scope"));
42
+ api.builder_get_current_object = reinterpret_cast<decltype(api.builder_get_current_object)>(
43
+ dlsym(lib, "gtk_builder_get_current_object"));
44
+ api.builder_scope_get_type = reinterpret_cast<decltype(api.builder_scope_get_type)>(
45
+ dlsym(lib, "gtk_builder_scope_get_type"));
46
+ api.ok = api.set_template != nullptr && api.set_template_from_resource != nullptr &&
47
+ api.bind_template_child_full != nullptr && api.set_css_name != nullptr &&
48
+ api.init_template != nullptr && api.get_template_child != nullptr;
49
+ return &api;
50
+ #endif // _WIN32
51
+ }
52
+
53
+ // The live JS env stamped on a template-callback scope (refreshed each construct).
54
+ GQuark NodeGiScopeEnvQuark() {
55
+ static GQuark q = g_quark_from_static_string("node-gi-scope-env");
56
+ return q;
57
+ }
58
+
59
+ // Instantiate the Gtk.Widget template on a freshly-constructed instance (see the
60
+ // forward declaration above ConstructGObject). A no-op unless the instance's
61
+ // registered type carries node-gi template data, so it is safe on every GObject
62
+ // construction. Uses the instance's actual type's class data (single-level
63
+ // registered templated type — the construct() case).
64
+ void MaybeInitTemplate(Napi::Env env, GObject* obj) {
65
+ NodeGiClassData* cd = FindClassData(G_OBJECT_TYPE(obj));
66
+ if (cd == nullptr || !cd->hasTemplate) return;
67
+ const GtkTemplateApi* gtk = GetGtkTemplateApi();
68
+ if (!gtk->ok) return;
69
+ // Only a Gtk.Widget can be init_template'd (class_init also skips a non-widget
70
+ // template). g_type_from_name is 0 if GTK never loaded → guard is false → skip.
71
+ GType widgetType = g_type_from_name("GtkWidget");
72
+ if (widgetType == 0 || !g_type_is_a(G_OBJECT_TYPE(obj), widgetType)) return;
73
+ // Stamp the live JS env on the template-callback scope BEFORE init_template runs:
74
+ // GtkBuilder resolves every `<signal handler="…">` synchronously inside
75
+ // init_template by calling the scope's create_closure, which needs this env to
76
+ // call back into the L1 resolver. Refreshed each construct so the scope always
77
+ // dispatches in the env that built this instance (GTK template construction is
78
+ // main-thread/single-env; a stale capture would only matter for a cross-env
79
+ // build, which GTK does not do).
80
+ if (cd->templateScope != nullptr) {
81
+ g_object_set_qdata(cd->templateScope, NodeGiScopeEnvQuark(),
82
+ reinterpret_cast<void*>(static_cast<napi_env>(env)));
83
+ }
84
+ gtk->init_template(obj);
85
+ }
86
+
87
+ // getTemplateChild(handle, name) -> wrapped child GObject | null
88
+ //
89
+ // Resolve a composite-template child bound on the instance's type (declared via
90
+ // registerClass Children/InternalChildren) by name. Returns the child wrapped
91
+ // through the canonical toggle-ref bridge (GI_TRANSFER_NOTHING — the child is
92
+ // owned by the parent widget via the template, a borrowed pointer). The L1 layer
93
+ // assigns the result onto the instance (public `this.name`, internal `this._name`).
94
+ Napi::Value GetTemplateChild(const Napi::CallbackInfo& info) {
95
+ Napi::Env env = info.Env();
96
+ if (info.Length() < 2 || !info[1].IsString()) {
97
+ Napi::TypeError::New(env, "getTemplateChild(handle, name: string)").ThrowAsJavaScriptException();
98
+ return env.Null();
99
+ }
100
+ GObject* obj = UnwrapGObject(env, info[0]);
101
+ if (obj == nullptr) return env.Null();
102
+ std::string name = info[1].As<Napi::String>().Utf8Value();
103
+ const GtkTemplateApi* gtk = GetGtkTemplateApi();
104
+ if (!gtk->ok) {
105
+ Napi::Error::New(env, "node-gi: the libgtk-4 template API is unavailable")
106
+ .ThrowAsJavaScriptException();
107
+ return env.Null();
108
+ }
109
+ // Look the child up against the instance's actual type (the registered
110
+ // templated type for the single-level case the decorator constructs).
111
+ GObject* child = gtk->get_template_child(obj, G_OBJECT_TYPE(obj), name.c_str());
112
+ if (child == nullptr) return env.Null();
113
+ return WrapGObject(env, child, GI_TRANSFER_NOTHING);
114
+ }
115
+
116
+ } // namespace nodegi