@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/marshal.cc ADDED
@@ -0,0 +1,1738 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Value marshalling: GIArgument <-> JS, boxed/GType handles, array/GList/GSList/GHashTable containers.
3
+
4
+ #include "common.h"
5
+
6
+ #include <unordered_map>
7
+ #include <utility>
8
+
9
+ namespace nodegi {
10
+
11
+ // ---- foreign-struct registry (cairo seam) -----------------------------------
12
+ //
13
+ // Port of GJS's gi/foreign.cpp table (keyed by (namespace, type_name)). A module
14
+ // (cairo.cc) registers converters for its foreign structs; the INTERFACE
15
+ // marshalling branches below route a foreign-typed arg/return to them instead of
16
+ // the generic boxed path. Populated at addon init (before any GI call marshals a
17
+ // foreign type), then read-only — no lock needed.
18
+ using ForeignKey = std::pair<std::string, std::string>;
19
+ struct ForeignKeyHash {
20
+ size_t operator()(const ForeignKey& k) const {
21
+ return std::hash<std::string>()(k.first) ^ (std::hash<std::string>()(k.second) << 1);
22
+ }
23
+ };
24
+ static std::unordered_map<ForeignKey, const ForeignStructOps*, ForeignKeyHash> g_foreign_structs;
25
+
26
+ void RegisterForeignStruct(const char* ns, const char* type_name, const ForeignStructOps* ops) {
27
+ g_foreign_structs[{ns != nullptr ? ns : "", type_name != nullptr ? type_name : ""}] = ops;
28
+ }
29
+ const ForeignStructOps* LookupForeignStruct(const char* ns, const char* type_name) {
30
+ auto it = g_foreign_structs.find({ns != nullptr ? ns : "", type_name != nullptr ? type_name : ""});
31
+ return it == g_foreign_structs.end() ? nullptr : it->second;
32
+ }
33
+ const ForeignStructOps* ForeignOpsForInfo(GIBaseInfo* iface) {
34
+ if (iface == nullptr || !GI_IS_STRUCT_INFO(iface)) return nullptr;
35
+ if (!gi_struct_info_is_foreign(reinterpret_cast<GIStructInfo*>(iface))) return nullptr;
36
+ return LookupForeignStruct(gi_base_info_get_namespace(iface), gi_base_info_get_name(iface));
37
+ }
38
+
39
+ // ---- value marshalling (milestone 1: primitives + strings) ----
40
+ //
41
+ // The minimal GIArgument <-> JS boundary. This is the seam node-gtk's value.cc
42
+ // fills out exhaustively; here it covers the numeric + string + boolean tags so
43
+ // real GI function calls work end to end. Compound tags (ARRAY/INTERFACE/GLIST/
44
+ // GHASH/…) and OUT/INOUT directions land with the GObject + full-marshalling
45
+ // drops.
46
+
47
+ // Marshal a JS value into a GIArgument for an IN argument of `type`.
48
+ // `heldString` keeps UTF-8 storage alive for the duration of the call.
49
+
50
+ extern const napi_type_tag kBoxedHandleTag = {0x6d2f8c4b1a9e7350ULL,
51
+ 0xb7e1d3a5c9f08264ULL};
52
+
53
+ // Release a BoxedHandle record + what it owns. Shared by the External finalizer
54
+ // and the swallowed-failure bail below (which must free exactly what the never-
55
+ // registered finalizer would have).
56
+ static void FreeBoxedHandleRecord(BoxedHandle* h) {
57
+ if (h->owns && h->ptr != nullptr && h->gtype != G_TYPE_INVALID) {
58
+ // GVariant is a fundamental (NOT a boxed) GType, so it cannot go
59
+ // through g_boxed_free — it is reference-counted via g_variant_unref.
60
+ // (GLib.Variant flows through this handle so it reuses the boxed
61
+ // method-resolution + IN-arg-unwrap path; only the free differs.)
62
+ if (h->gtype == G_TYPE_VARIANT) {
63
+ g_variant_unref(static_cast<GVariant*>(h->ptr));
64
+ } else if (G_TYPE_IS_BOXED(h->gtype)) {
65
+ g_boxed_free(h->gtype, h->ptr);
66
+ }
67
+ } else if (h->rawOwned && h->ptr != nullptr) {
68
+ // A plain (non-boxed) C struct whose storage the engine g_malloc0'd itself —
69
+ // the caller-allocates OUT case. No boxed free-func exists; the handle owns
70
+ // the block (matches gjs CallerAllocatesOut::release, a plain g_free).
71
+ g_free(h->ptr);
72
+ }
73
+ if (h->info != nullptr) gi_base_info_unref(h->info);
74
+ delete h;
75
+ }
76
+
77
+ Napi::Value MakeBoxedHandle(Napi::Env env, gpointer ptr, GType gtype, bool owns,
78
+ GIBaseInfo* info, bool rawOwned) {
79
+ GIBaseInfo* heldInfo = info != nullptr ? gi_base_info_ref(info) : nullptr;
80
+ BoxedHandle* bh = new BoxedHandle{ptr, gtype, owns, heldInfo, rawOwned};
81
+ Napi::External<BoxedHandle> ext = Napi::External<BoxedHandle>::New(
82
+ env, bh, [](Napi::Env, BoxedHandle* h) { FreeBoxedHandleRecord(h); });
83
+ if (ext.IsEmpty()) {
84
+ // napi_create_external failed with the throw swallowed (terminating env /
85
+ // pending exception). The finalizer was never registered — free the record
86
+ // here and bail instead of chaining TypeTag on the empty External, which
87
+ // would abort via Error::New(nullptr)'s fatal sites (see common.h helpers).
88
+ FreeBoxedHandleRecord(bh);
89
+ return ext;
90
+ }
91
+ ext.TypeTag(&kBoxedHandleTag);
92
+ return ext;
93
+ }
94
+
95
+ // Wrap a GVariant pointer as a node-gi boxed handle tagged with G_TYPE_VARIANT.
96
+ // GVariant is a fundamental ref-counted (de)floating type, so ownership differs
97
+ // from g_boxed types: TAKE the handed ref on transfer-full (sinking a floating
98
+ // one), else add our own ref on a borrow. The finalizer (MakeBoxedHandle) drops
99
+ // exactly the one ref we end up owning via g_variant_unref. The L1 layer turns
100
+ // this handle into a GLib.Variant wrapper (deepUnpack/unpack/recursiveUnpack/…).
101
+ Napi::Value WrapVariant(Napi::Env env, GVariant* var, GITransfer transfer) {
102
+ if (var == nullptr) return env.Null();
103
+ if (transfer == GI_TRANSFER_EVERYTHING)
104
+ g_variant_take_ref(var);
105
+ else
106
+ g_variant_ref_sink(var);
107
+ return MakeBoxedHandle(env, var, G_TYPE_VARIANT, true);
108
+ }
109
+
110
+ // Wrap a returned struct/boxed pointer. `structInfo` is the GIStructInfo (or
111
+ // union info) for the static type; the runtime GType (if registered + boxed)
112
+ // drives ownership + later method resolution.
113
+ static Napi::Value WrapBoxed(Napi::Env env, gpointer ptr, GIBaseInfo* structInfo,
114
+ GITransfer transfer) {
115
+ if (ptr == nullptr) return env.Null();
116
+ GIBaseInfo* info = nullptr;
117
+ GType gt = G_TYPE_INVALID;
118
+ if (structInfo != nullptr && (GI_IS_STRUCT_INFO(structInfo) || GI_IS_UNION_INFO(structInfo))) {
119
+ info = structInfo; // stored (re-ref'd) so field/method resolution has the static info
120
+ gt = gi_registered_type_info_get_g_type(reinterpret_cast<GIRegisteredTypeInfo*>(structInfo));
121
+ }
122
+ if (gt == G_TYPE_VARIANT) return WrapVariant(env, static_cast<GVariant*>(ptr), transfer);
123
+ bool boxed = gt != G_TYPE_INVALID && gt != G_TYPE_NONE && G_TYPE_IS_BOXED(gt);
124
+ // COPY a transfer-none BOXED return so the JS handle owns an independent copy —
125
+ // matching gjs (gi/boxed.cpp: a transfer-none boxed is g_boxed_copy'd). Without
126
+ // this the handle shares the callee's (often static) instance, so a field WRITE
127
+ // (union.long_ = …) would corrupt that shared instance across calls. A
128
+ // transfer-everything boxed is already owned; a non-boxed struct has no copy
129
+ // function, so it keeps sharing (reads only — its fields aren't mutated here).
130
+ gpointer handlePtr = ptr;
131
+ bool owns = false;
132
+ if (boxed) {
133
+ handlePtr = transfer == GI_TRANSFER_EVERYTHING ? ptr : g_boxed_copy(gt, ptr);
134
+ owns = true;
135
+ }
136
+ // Keep a valid registered GType on the handle even for a NON-boxed struct (e.g.
137
+ // GIMarshalling PointerStruct): it drives method + field resolution via
138
+ // find_by_gtype. `owns` stays gated on boxed-ness, so the finalizer only ever
139
+ // g_boxed_free's a real boxed type — never a plain registered struct.
140
+ GType handleGType = (gt != G_TYPE_INVALID && gt != G_TYPE_NONE) ? gt : G_TYPE_INVALID;
141
+ return MakeBoxedHandle(env, handlePtr, handleGType, owns, info);
142
+ }
143
+
144
+ // Read a boxed handle (ptr + boxed GType) if `v` is one (tag-checked; no deref of
145
+ // ptr). Returns nullptr when `v` is not a node-gi boxed handle. The returned handle
146
+ // is owned by the External — never free it. Callers that need the GType (boxed
147
+ // type-safety checks before g_value_set_boxed / a struct IN arg) use this; the
148
+ // pointer-only TryGetBoxedPtr below is the thin wrapper for everyone else.
149
+ BoxedHandle* TryGetBoxedHandle(Napi::Value v) {
150
+ if (!v.IsExternal()) return nullptr;
151
+ Napi::External<BoxedHandle> ext = v.As<Napi::External<BoxedHandle>>();
152
+ if (!ext.CheckTypeTag(&kBoxedHandleTag)) return nullptr;
153
+ return ext.Data();
154
+ }
155
+
156
+ // Read a boxed handle's pointer if `v` is one (tag-checked; no deref of ptr).
157
+ bool TryGetBoxedPtr(Napi::Value v, gpointer* out) {
158
+ if (!v.IsExternal()) return false;
159
+ Napi::External<BoxedHandle> ext = v.As<Napi::External<BoxedHandle>>();
160
+ if (!ext.CheckTypeTag(&kBoxedHandleTag)) return false;
161
+ BoxedHandle* h = ext.Data();
162
+ *out = h != nullptr ? h->ptr : nullptr;
163
+ return true;
164
+ }
165
+
166
+ // boxedTypeName(value) -> the boxed handle's GType name (e.g. "GBytes"), or null
167
+ // when the handle carries no registered GType. Lets the L1 layer attach a
168
+ // type-specific convenience (GLib.Bytes.toArray) without a per-type wrapper.
169
+ Napi::Value BoxedTypeName(const Napi::CallbackInfo& info) {
170
+ Napi::Env env = info.Env();
171
+ if (info.Length() < 1 || !info[0].IsExternal() ||
172
+ !info[0].As<Napi::External<BoxedHandle>>().CheckTypeTag(&kBoxedHandleTag)) {
173
+ return env.Null();
174
+ }
175
+ BoxedHandle* bh = info[0].As<Napi::External<BoxedHandle>>().Data();
176
+ if (bh == nullptr || bh->gtype == G_TYPE_INVALID) return env.Null();
177
+ const char* name = g_type_name(bh->gtype);
178
+ return name != nullptr ? Napi::Value(Napi::String::New(env, name)) : env.Null();
179
+ }
180
+
181
+ // ---- GType value handle ----
182
+ //
183
+ // A GType is represented in JS as a dedicated, type-tagged External holding the
184
+ // `GType` (a `gsize`) directly in its Data() pointer slot — NOT a plain number
185
+ // (a number is indistinguishable from an enum value at a GI_TYPE_TAG_GTYPE arg)
186
+ // and NOT a GObject/boxed handle (a GType is a small integer, dereferencing it as
187
+ // a pointer would crash). The tag lets the marshaller recognise it structurally
188
+ // without touching the value. GJS's richer GType *object* (refs/gjs gi/gtype.cpp)
189
+ // carries a name slot too; node-gi's tagged External is the minimal equivalent —
190
+ // the L1 layer can layer `.name` ergonomics on top if needed. GTypes are
191
+ // process-stable (static registration, never freed), so the handle has no
192
+ // finalizer. This is also the handle registerClass returns (so a registered
193
+ // class's `$gtype` and the constructType type-handle are one and the same).
194
+ static const napi_type_tag kGTypeHandleTag = {0x3a7f1c9e5b2d4860ULL,
195
+ 0xc4e8a1b3d5f72096ULL};
196
+
197
+ Napi::Value MakeGTypeHandle(Napi::Env env, GType gtype) {
198
+ Napi::External<void> ext = Napi::External<void>::New(env, reinterpret_cast<void*>(gtype));
199
+ // Swallowed napi failure (terminating env): TypeTag on the empty External
200
+ // would abort via Error::New(nullptr) — bail with the empty value instead.
201
+ if (ext.IsEmpty()) return ext;
202
+ ext.TypeTag(&kGTypeHandleTag);
203
+ return ext;
204
+ }
205
+
206
+ // Read a GType from a kGTypeHandleTag External (the GType representation above),
207
+ // without dereferencing. Returns 0 (G_TYPE_INVALID) when `v` is not a GType handle.
208
+ GType ReadGTypeHandle(Napi::Value v) {
209
+ if (!v.IsExternal()) return 0;
210
+ Napi::External<void> ext = v.As<Napi::External<void>>();
211
+ if (!ext.CheckTypeTag(&kGTypeHandleTag)) return 0;
212
+ return reinterpret_cast<GType>(ext.Data());
213
+ }
214
+
215
+ // Marshal a JS GType argument (a GType handle, or null → 0) into a GType, throwing
216
+ // a clean TypeError otherwise. Shared by the GI_TYPE_TAG_GTYPE / G_TYPE_GTYPE
217
+ // marshalling paths.
218
+ bool UnwrapGTypeArg(Napi::Env env, Napi::Value v, GType* out) {
219
+ if (v.IsNull() || v.IsUndefined()) {
220
+ *out = 0;
221
+ return true;
222
+ }
223
+ if (v.IsExternal() && v.As<Napi::External<void>>().CheckTypeTag(&kGTypeHandleTag)) {
224
+ *out = reinterpret_cast<GType>(v.As<Napi::External<void>>().Data());
225
+ return true;
226
+ }
227
+ Napi::TypeError::New(env, "expected a GType handle (e.g. Class.$gtype)")
228
+ .ThrowAsJavaScriptException();
229
+ return false;
230
+ }
231
+
232
+ // Byte width of one element of a napi typed array.
233
+ static size_t TypedArrayElementSize(napi_typedarray_type t) {
234
+ switch (t) {
235
+ case napi_int8_array:
236
+ case napi_uint8_array:
237
+ case napi_uint8_clamped_array: return 1;
238
+ case napi_int16_array:
239
+ case napi_uint16_array: return 2;
240
+ case napi_int32_array:
241
+ case napi_uint32_array:
242
+ case napi_float32_array: return 4;
243
+ case napi_float64_array:
244
+ case napi_bigint64_array:
245
+ case napi_biguint64_array: return 8;
246
+ default: return 1;
247
+ }
248
+ }
249
+
250
+ // Extract the byte slice of a JS binary value — a TypedArray (incl. a Node
251
+ // Buffer and Uint8ClampedArray), a DataView, or a bare ArrayBuffer. Returns
252
+ // false when `v` is none of those. The slice borrows the JS backing store; it
253
+ // is only valid until control returns to JS (copy before storing).
254
+ //
255
+ // CROSS-RUNTIME: read the `data` out-param of napi_get_typedarray_info /
256
+ // napi_get_dataview_info — it already points at the VIEW's first byte on every
257
+ // runtime. Do NOT recompute `arraybuffer.Data() + byte_offset`: Bun reports a
258
+ // byte_offset inconsistent with its arraybuffer data pointer for subarray
259
+ // views, which silently marshals the wrong slice (caught by the bytes-in
260
+ // conformance program's `subarray` case on bun).
261
+ static bool JsBinaryData(Napi::Env env, Napi::Value v, const uint8_t** data, size_t* len) {
262
+ napi_value val = v;
263
+ bool is = false;
264
+ if (napi_is_typedarray(env, val, &is) == napi_ok && is) {
265
+ napi_typedarray_type type = napi_uint8_array;
266
+ size_t length = 0;
267
+ void* d = nullptr;
268
+ if (napi_get_typedarray_info(env, val, &type, &length, &d, nullptr, nullptr) != napi_ok)
269
+ return false;
270
+ *data = static_cast<const uint8_t*>(d);
271
+ *len = length * TypedArrayElementSize(type);
272
+ return true;
273
+ }
274
+ if (napi_is_dataview(env, val, &is) == napi_ok && is) {
275
+ size_t byteLength = 0;
276
+ void* d = nullptr;
277
+ if (napi_get_dataview_info(env, val, &byteLength, &d, nullptr, nullptr) != napi_ok)
278
+ return false;
279
+ *data = static_cast<const uint8_t*>(d);
280
+ *len = byteLength;
281
+ return true;
282
+ }
283
+ if (napi_is_arraybuffer(env, val, &is) == napi_ok && is) {
284
+ void* d = nullptr;
285
+ size_t byteLength = 0;
286
+ if (napi_get_arraybuffer_info(env, val, &d, &byteLength) != napi_ok) return false;
287
+ *data = static_cast<const uint8_t*>(d);
288
+ *len = byteLength;
289
+ return true;
290
+ }
291
+ return false;
292
+ }
293
+
294
+ // `ownedStrings` (optional): when a transfer-full string IN/INOUT arg is g_strdup'd
295
+ // here, the freshly-allocated pointer is appended so the caller can g_free it if the
296
+ // invoke never adopts it (an arg-marshal error before the call, or a failed invoke).
297
+ // nullptr (the default) → no tracking, for the vfunc-return / signal-arg callers.
298
+ bool JsToGIArgument(Napi::Env env, Napi::Value v, GITypeInfo* type, GIArgument* out,
299
+ std::string* heldString,
300
+ GITransfer transfer,
301
+ std::vector<gpointer>* ownedStrings,
302
+ CreatedClosures* closures,
303
+ CreatedBytes* bytes) {
304
+ if (v.IsEmpty()) {
305
+ // Residue of a swallowed napi failure (a fallible Get()/coercion upstream
306
+ // failed on a terminating env, or a throwing getter left the exception
307
+ // pending). Nothing can be marshalled from it, and coercing it would abort
308
+ // via Error::New(nullptr)'s fatal sites — fail the marshal cleanly. Gate the
309
+ // diagnostic THROW on NodeGiJsAvailable: it is false both on a dying env
310
+ // (worker.terminate mid-call — constructing a Napi::Error there runs fallible
311
+ // napi calls that abort via the SAME funnel) AND on a live env with an
312
+ // exception already pending (propagate that one). Only throw when the env can
313
+ // safely build the error.
314
+ if (NodeGiJsAvailable(env)) {
315
+ Napi::Error::New(env, "argument value unavailable (env is terminating)")
316
+ .ThrowAsJavaScriptException();
317
+ }
318
+ return false;
319
+ }
320
+ GITypeTag tag = gi_type_info_get_tag(type);
321
+ switch (tag) {
322
+ case GI_TYPE_TAG_BOOLEAN: out->v_boolean = NodeGiToBool(v); return true;
323
+ case GI_TYPE_TAG_INT8: out->v_int8 = static_cast<int8_t>(NodeGiToInt32(v)); return true;
324
+ case GI_TYPE_TAG_UINT8: out->v_uint8 = static_cast<uint8_t>(NodeGiToUint32(v)); return true;
325
+ case GI_TYPE_TAG_INT16: out->v_int16 = static_cast<int16_t>(NodeGiToInt32(v)); return true;
326
+ case GI_TYPE_TAG_UINT16: out->v_uint16 = static_cast<uint16_t>(NodeGiToUint32(v)); return true;
327
+ case GI_TYPE_TAG_INT32: out->v_int32 = NodeGiToInt32(v); return true;
328
+ case GI_TYPE_TAG_UINT32: out->v_uint32 = NodeGiToUint32(v); return true;
329
+ // 64-bit: accept a BigInt losslessly (else a Number, truncated) — never let a
330
+ // BigInt reach ToNumber() (fatal abort). See JsValueTo{Int,Uint}64 in common.h.
331
+ case GI_TYPE_TAG_INT64: out->v_int64 = JsValueToInt64(v); return true;
332
+ case GI_TYPE_TAG_UINT64: out->v_uint64 = JsValueToUint64(v); return true;
333
+ case GI_TYPE_TAG_FLOAT: out->v_float = static_cast<float>(NodeGiToDouble(v)); return true;
334
+ case GI_TYPE_TAG_DOUBLE: out->v_double = NodeGiToDouble(v); return true;
335
+ case GI_TYPE_TAG_UTF8:
336
+ case GI_TYPE_TAG_FILENAME:
337
+ if (v.IsNull() || v.IsUndefined()) {
338
+ out->v_string = nullptr;
339
+ return true;
340
+ }
341
+ if (transfer == GI_TRANSFER_EVERYTHING) {
342
+ // The callee adopts the string and g_free's it. heldString points into a
343
+ // std::string buffer (NOT g_malloc'd) → an invalid free. Hand over a
344
+ // g_strdup'd copy the callee can legally free; we keep no reference. Track
345
+ // it so a NON-adopting caller (error before / failed invoke) can free it.
346
+ out->v_string = g_strdup(NodeGiToUtf8(v).c_str());
347
+ if (ownedStrings != nullptr) ownedStrings->push_back(out->v_string);
348
+ } else {
349
+ *heldString = NodeGiToUtf8(v);
350
+ out->v_string = const_cast<char*>(heldString->c_str());
351
+ }
352
+ return true;
353
+ case GI_TYPE_TAG_INTERFACE: {
354
+ // Object/interface instances arrive as opaque External<GObject> handles;
355
+ // enums/flags as plain numbers. Other interface kinds (structs/unions/
356
+ // callbacks) follow with the full-marshalling drop.
357
+ GIBaseInfo* iface = gi_type_info_get_interface(type);
358
+ bool handled = false;
359
+ if (iface != nullptr) {
360
+ if (GI_IS_OBJECT_INFO(iface) || GI_IS_INTERFACE_INFO(iface)) {
361
+ if (v.IsNull() || v.IsUndefined()) {
362
+ out->v_pointer = nullptr;
363
+ handled = true;
364
+ } else if (v.IsExternal()) {
365
+ out->v_pointer = v.As<Napi::External<GObject>>().Data();
366
+ handled = true;
367
+ }
368
+ } else if (GI_IS_ENUM_INFO(iface) || GI_IS_FLAGS_INFO(iface)) {
369
+ out->v_int = NodeGiToInt32(v);
370
+ handled = true;
371
+ } else if (GI_IS_STRUCT_INFO(iface) || GI_IS_UNION_INFO(iface)) {
372
+ // A foreign struct (cairo Context/Surface/Pattern) marshals through its
373
+ // module's converter, not the boxed path.
374
+ const ForeignStructOps* fops = ForeignOpsForInfo(iface);
375
+ if (fops != nullptr) {
376
+ if (!fops->to(env, v, transfer, out)) {
377
+ gi_base_info_unref(iface);
378
+ return false; // to() already threw
379
+ }
380
+ handled = true;
381
+ } else if (v.IsFunction() && closures != nullptr &&
382
+ gi_registered_type_info_get_g_type(
383
+ reinterpret_cast<GIRegisteredTypeInfo*>(iface)) == G_TYPE_CLOSURE) {
384
+ // A JS function for a `GObject.Closure` IN-arg → a real marshaled
385
+ // GClosure, exactly as gjs (refs/gjs/gi/arg-cache.cpp
386
+ // GClosureInTransferNone::in: create_marshaled + g_closure_ref +
387
+ // g_closure_sink). The ref taken here is released by calls.cc after
388
+ // the invoke for transfer-none (gjs BoxedInTransferNone::release —
389
+ // the callee keeps its own ref if it stored the closure), and left
390
+ // with the callee for transfer-full (gjs GClosureIn::release skips).
391
+ // An existing GObject.Closure boxed handle still routes through the
392
+ // boxed-handle branch below.
393
+ GClosure* closure = NodeGiMakeGenericJsClosure(env, v);
394
+ g_closure_ref(closure);
395
+ g_closure_sink(closure);
396
+ out->v_pointer = closure;
397
+ if (transfer == GI_TRANSFER_NOTHING) closures->transferNone.push_back(closure);
398
+ else closures->transferFull.push_back(closure);
399
+ handled = true;
400
+ } else if (bytes != nullptr &&
401
+ (v.IsTypedArray() || v.IsDataView() || v.IsArrayBuffer()) &&
402
+ g_type_is_a(gi_registered_type_info_get_g_type(
403
+ reinterpret_cast<GIRegisteredTypeInfo*>(iface)),
404
+ G_TYPE_BYTES)) {
405
+ // JS bytes for a `GLib.Bytes` IN-arg → a fresh GBytes COPY, exactly
406
+ // as gjs (refs/gjs/gi/arg-cache.cpp GBytesIn::in Uint8Array path →
407
+ // gjs_byte_array_get_bytes → g_bytes_new). Recorded in `bytes` so
408
+ // calls.cc releases the fresh ref per transfer after the invoke
409
+ // (transfer-none: always unref — the callee ref'd it if it kept it;
410
+ // transfer-full: the callee adopts it). An existing GLib.Bytes boxed
411
+ // handle still routes through the boxed-handle branch below.
412
+ const uint8_t* data = nullptr;
413
+ size_t len = 0;
414
+ JsBinaryData(env, v, &data, &len);
415
+ GBytes* b = g_bytes_new(data, len);
416
+ out->v_pointer = b;
417
+ if (transfer == GI_TRANSFER_NOTHING) bytes->transferNone.push_back(b);
418
+ else bytes->transferFull.push_back(b);
419
+ handled = true;
420
+ } else if (v.IsNull() || v.IsUndefined()) {
421
+ // Boxed/struct IN args arrive as boxed handles; null/undefined maps to
422
+ // a NULL pointer (e.g. GLib.MainLoop.new(null, false)).
423
+ out->v_pointer = nullptr;
424
+ handled = true;
425
+ } else {
426
+ BoxedHandle* h = TryGetBoxedHandle(v);
427
+ if (h != nullptr) {
428
+ // Type-safety: handing a wrong boxed handle's raw pointer to the
429
+ // callee is undefined behaviour. Reject a mismatch with a clean
430
+ // TypeError. Only checkable when BOTH the handle and the expected
431
+ // struct carry a known registered GType (non-registered C structs are
432
+ // G_TYPE_INVALID → no GType to compare, fall through as before).
433
+ GType expected = gi_registered_type_info_get_g_type(
434
+ reinterpret_cast<GIRegisteredTypeInfo*>(iface));
435
+ if (h->gtype != G_TYPE_INVALID && expected != G_TYPE_INVALID &&
436
+ expected != G_TYPE_NONE && !g_type_is_a(h->gtype, expected)) {
437
+ gi_base_info_unref(iface);
438
+ Napi::TypeError::New(env, std::string("expected a ") + g_type_name(expected) +
439
+ " boxed handle, got " + g_type_name(h->gtype))
440
+ .ThrowAsJavaScriptException();
441
+ return false;
442
+ }
443
+ out->v_pointer = h->ptr;
444
+ handled = true;
445
+ }
446
+ }
447
+ }
448
+ gi_base_info_unref(iface);
449
+ }
450
+ if (!handled) {
451
+ Napi::TypeError::New(
452
+ env,
453
+ "Unsupported interface IN argument (expected a GObject/boxed handle, enum or flags number)")
454
+ .ThrowAsJavaScriptException();
455
+ return false;
456
+ }
457
+ return true;
458
+ }
459
+ case GI_TYPE_TAG_GTYPE: {
460
+ // A GType argument (e.g. GObject.type_ensure, g_type_name): read the GType
461
+ // from a node-gi GType handle and write it into the GType slot (gsize-wide).
462
+ GType gt = 0;
463
+ if (!UnwrapGTypeArg(env, v, &gt)) return false;
464
+ out->v_size = static_cast<gsize>(gt);
465
+ return true;
466
+ }
467
+ default:
468
+ Napi::TypeError::New(
469
+ env, "Unsupported IN argument type tag " + std::to_string(static_cast<int>(tag)) +
470
+ " (milestone 1 supports numbers, booleans and strings)")
471
+ .ThrowAsJavaScriptException();
472
+ return false;
473
+ }
474
+ }
475
+
476
+ // A process-unique type tag distinguishing node-gi's GObject-instance Externals
477
+ // from other Externals (notably registerClass's GType handle). isGObjectHandle /
478
+ // UnwrapGObject validate against it WITHOUT dereferencing the pointer — a GType
479
+ // handle holds a small integer, not a valid GObject*, so a blind G_IS_OBJECT on
480
+ // it would segfault.
481
+ extern const napi_type_tag kGObjectHandleTag = {0x9f3c1a7b5e2d4068ULL,
482
+ 0xa1b2c3d4e5f60718ULL};
483
+
484
+ // Marshal a return-value GIArgument into a JS value, honouring transfer.
485
+ Napi::Value GIArgumentToJs(Napi::Env env, GITypeInfo* type, GIArgument* arg,
486
+ GITransfer transfer) {
487
+ GITypeTag tag = gi_type_info_get_tag(type);
488
+ switch (tag) {
489
+ case GI_TYPE_TAG_VOID: return env.Undefined();
490
+ case GI_TYPE_TAG_BOOLEAN: return Napi::Boolean::New(env, arg->v_boolean);
491
+ case GI_TYPE_TAG_INT8: return Napi::Number::New(env, arg->v_int8);
492
+ case GI_TYPE_TAG_UINT8: return Napi::Number::New(env, arg->v_uint8);
493
+ case GI_TYPE_TAG_INT16: return Napi::Number::New(env, arg->v_int16);
494
+ case GI_TYPE_TAG_UINT16: return Napi::Number::New(env, arg->v_uint16);
495
+ case GI_TYPE_TAG_INT32: return Napi::Number::New(env, arg->v_int32);
496
+ case GI_TYPE_TAG_UINT32: return Napi::Number::New(env, arg->v_uint32);
497
+ // 64-bit: GJS always returns a Number, warning when the value is not exactly
498
+ // representable (|v| > 2^53-1). See WarnIfUnsafe{Int,Uint}64 in common.h.
499
+ case GI_TYPE_TAG_INT64:
500
+ WarnIfUnsafeInt64(arg->v_int64);
501
+ return Napi::Number::New(env, static_cast<double>(arg->v_int64));
502
+ case GI_TYPE_TAG_UINT64:
503
+ WarnIfUnsafeUint64(arg->v_uint64);
504
+ return Napi::Number::New(env, static_cast<double>(arg->v_uint64));
505
+ case GI_TYPE_TAG_FLOAT: return Napi::Number::New(env, arg->v_float);
506
+ case GI_TYPE_TAG_DOUBLE: return Napi::Number::New(env, arg->v_double);
507
+ case GI_TYPE_TAG_UTF8:
508
+ case GI_TYPE_TAG_FILENAME: {
509
+ if (arg->v_string == nullptr) return env.Null();
510
+ Napi::Value str = Napi::String::New(env, arg->v_string);
511
+ if (transfer == GI_TRANSFER_EVERYTHING) g_free(arg->v_string);
512
+ return str;
513
+ }
514
+ case GI_TYPE_TAG_INTERFACE: {
515
+ GIBaseInfo* iface = gi_type_info_get_interface(type);
516
+ Napi::Value result;
517
+ // A GParamSpec return (e.g. GObject.ParamSpec factory / *_returnv) is a
518
+ // GObject FUNDAMENTAL, not a GObject — introspected as an object info but
519
+ // ref-counted via g_param_spec_ref, so it must NOT go through WrapGObject
520
+ // (g_object_ref would be wrong). Route it to the paramspec handle.
521
+ GType ifaceGType =
522
+ iface != nullptr && (GI_IS_OBJECT_INFO(iface) || GI_IS_STRUCT_INFO(iface))
523
+ ? gi_registered_type_info_get_g_type(reinterpret_cast<GIRegisteredTypeInfo*>(iface))
524
+ : G_TYPE_INVALID;
525
+ if (ifaceGType != G_TYPE_INVALID && g_type_is_a(ifaceGType, G_TYPE_VALUE)) {
526
+ // A GValue return/OUT (GType G_TYPE_VALUE, or derived): GJS AUTO-UNBOXES it
527
+ // to the contained JS value (int/uint/int64/string/boolean/double/enum/
528
+ // object/boxed/GVariant/GParamSpec/GType/null), NOT a boxed handle. Verified
529
+ // against gjs 1.88: GIMarshallingTests.gvalue_return() → 42 (a number), not a
530
+ // GObject.Value box; gvalue_copy(<string GValue>) → "hi"; an object-holding
531
+ // GValue → the wrapped GObject (same identity); a NULL/unset string/object
532
+ // GValue → null. Unbox via GValueToJs — the SAME converter property/signal
533
+ // GValues use (BigInt/lossy 64-bit warning + all contained-type logic). The
534
+ // pointer slot holds the GValue* (a GValue return/OUT is always a pointer).
535
+ // Reference: refs/gjs/gi/arg.cpp gjs_value_from_gi_argument (INTERFACE branch
536
+ // tests `g_type_is_a(gtype, G_TYPE_VALUE)` BEFORE Struct) → value.cpp
537
+ // gjs_value_from_g_value. NB an EXPLICIT GObject.Value instance a user
538
+ // constructs stays a box — this only fires on a function's GValue return/OUT.
539
+ GValue* gvalue = static_cast<GValue*>(arg->v_pointer);
540
+ if (gvalue == nullptr) {
541
+ result = env.Null();
542
+ } else {
543
+ result = GValueToJs(env, gvalue);
544
+ // Transfer/free: GValueToJs COPIES/REFS every contained value into JS
545
+ // (strings are copied into V8, objects/boxeds ref'd/copied), so freeing the
546
+ // GValue container after the read is always safe. A transfer-EVERYTHING
547
+ // GValue* return (caller owns) is g_boxed_free'd — g_value_unset + free the
548
+ // GValue slice — exactly as GJS does (refs/gjs/gi/arg.cpp
549
+ // gjs_gi_argument_release G_TYPE_VALUE: pointer → g_boxed_free(gtype, …)).
550
+ // Transfer-NONE (the common case: gvalue_return/out, Gda.get_value_at) is a
551
+ // borrow → no free.
552
+ if (transfer == GI_TRANSFER_EVERYTHING) g_boxed_free(G_TYPE_VALUE, gvalue);
553
+ }
554
+ } else if (ifaceGType != G_TYPE_INVALID && g_type_is_a(ifaceGType, G_TYPE_PARAM)) {
555
+ result = MakeParamSpecHandle(env, static_cast<GParamSpec*>(arg->v_pointer), transfer);
556
+ } else if (iface != nullptr && GI_IS_OBJECT_INFO(iface) &&
557
+ gi_object_info_get_fundamental(reinterpret_cast<GIObjectInfo*>(iface))) {
558
+ // A non-GObject GObject-fundamental (GskRenderNode from Gtk.Snapshot.to_node,
559
+ // GdkEvent, …): introspected as object info but ref-counted via its OWN
560
+ // ref/unref funcs — G_IS_OBJECT is FALSE. WrapGObject would run the
561
+ // toggle-ref/qdata dance on a non-GObject → a G_IS_OBJECT critical cascade +
562
+ // a leaked ref. Wrap with the introspected ref/unref instead. (GParamSpec +
563
+ // GValue are handled by their dedicated branches above, so this is the rest.)
564
+ result = MakeFundamentalHandle(env, arg->v_pointer,
565
+ reinterpret_cast<GIObjectInfo*>(iface), transfer);
566
+ } else if (iface != nullptr && (GI_IS_OBJECT_INFO(iface) || GI_IS_INTERFACE_INFO(iface))) {
567
+ result = WrapGObject(env, static_cast<GObject*>(arg->v_pointer), transfer);
568
+ } else if (iface != nullptr && (GI_IS_ENUM_INFO(iface) || GI_IS_FLAGS_INFO(iface))) {
569
+ result = Napi::Number::New(env, arg->v_int);
570
+ } else if (iface != nullptr && (GI_IS_STRUCT_INFO(iface) || GI_IS_UNION_INFO(iface))) {
571
+ // A foreign struct (cairo) returned/handed to a callback (e.g. a draw-func's
572
+ // cairo_t) wraps via its module's converter, not the boxed path.
573
+ const ForeignStructOps* fops = ForeignOpsForInfo(iface);
574
+ result = fops != nullptr ? fops->from(env, arg->v_pointer, transfer)
575
+ : WrapBoxed(env, arg->v_pointer, iface, transfer);
576
+ } else {
577
+ Napi::TypeError::New(
578
+ env,
579
+ "Unsupported interface return type (milestone: objects, interfaces, enums, flags, boxed)")
580
+ .ThrowAsJavaScriptException();
581
+ result = env.Undefined();
582
+ }
583
+ if (iface != nullptr) gi_base_info_unref(iface);
584
+ return result;
585
+ }
586
+ case GI_TYPE_TAG_GTYPE: {
587
+ // A GType return value (e.g. g_type_from_name): wrap the GType slot
588
+ // (gsize-wide) as a node-gi GType handle so it round-trips back into the
589
+ // engine. A 0 GType (not found) becomes null.
590
+ GType gt = static_cast<GType>(arg->v_size);
591
+ return gt != 0 ? MakeGTypeHandle(env, gt) : env.Null();
592
+ }
593
+ case GI_TYPE_TAG_ERROR: {
594
+ // A GError-typed value (a `GLib.Error` return like Gtk.GLArea.get_error(),
595
+ // or a GLib.Error.new_literal construct): GJS surfaces it as a GLib.Error
596
+ // boxed (G_TYPE_ERROR) with `.domain`/`.code`/`.message` field access +
597
+ // `.matches()`/`.copy()` methods. Wrap through the GLib.Error struct info
598
+ // so the boxed handle resolves fields and methods; a NULL GError (the
599
+ // no-error case) stays null. WrapBoxed handles transfer: a transfer-none
600
+ // borrow is g_boxed_copy'd into an owned copy, transfer-full is adopted.
601
+ GError* gerr = static_cast<GError*>(arg->v_pointer);
602
+ if (gerr == nullptr) return env.Null();
603
+ GIRepository* repo = DupDefaultRepository();
604
+ // GLib is loaded by any namespace require; require defensively anyway so a
605
+ // bare engine call still resolves the struct info (idempotent, cheap).
606
+ gi_repository_require(repo, "GLib", "2.0", static_cast<GIRepositoryLoadFlags>(0), nullptr);
607
+ GIBaseInfo* errInfo = gi_repository_find_by_name(repo, "GLib", "Error");
608
+ Napi::Value result = WrapBoxed(env, gerr, errInfo, transfer);
609
+ if (errInfo != nullptr) gi_base_info_unref(errInfo);
610
+ g_object_unref(repo);
611
+ return result;
612
+ }
613
+ default:
614
+ Napi::TypeError::New(env, "Unsupported return type tag " +
615
+ std::to_string(static_cast<int>(tag)) + " (milestone 1)")
616
+ .ThrowAsJavaScriptException();
617
+ return env.Undefined();
618
+ }
619
+ }
620
+
621
+ // ====================================================================
622
+ // ---- array / GList / GSList / GHashTable / GStrv marshalling --------
623
+ // ====================================================================
624
+ //
625
+ // Compound container marshalling for IN, return, and OUT directions. Reference:
626
+ // refs/node-gtk src/{value,function}.cc (romgrk, MIT), retargeted to the
627
+ // girepository-2.0 API and the dense in_args/out_args layout this addon uses.
628
+ //
629
+ // SCOPE (the common cases real GJS code hits): C arrays + GStrv, GByteArray,
630
+ // GArray, GPtrArray (read), GList/GSList, GHashTable with string keys. Element
631
+ // types: utf8/filename (strings), the numeric fundamentals + boolean, and
632
+ // GObject/interface instances. EXOTIC combos (struct/union/enum/flags/nested-
633
+ // container elements, non-string hash keys, GArray/GPtrArray as IN, INOUT
634
+ // containers) are DEFERRED with a clear "<type> not yet supported" thrown BEFORE
635
+ // the invoke — mirroring the #652 IsSupportedOutType pattern.
636
+ //
637
+ // OWNERSHIP (the leak/UAF surface — scrutinise here):
638
+ // * Returns / OUT (caller-owns per gi_arg_info_get_ownership_transfer /
639
+ // gi_callable_info_get_caller_owns): TRANSFER_EVERYTHING frees both the
640
+ // container AND its elements after the read (strings g_free'd, object refs
641
+ // adopted by the JS wrapper); TRANSFER_CONTAINER frees only the container
642
+ // (elements stay callee-owned); TRANSFER_NOTHING frees nothing.
643
+ // * IN: TRANSFER_NOTHING means the callee borrows — we build the container,
644
+ // pass it, and free it (container + any g_strdup'd strings) AFTER the invoke.
645
+ // TRANSFER_EVERYTHING / TRANSFER_CONTAINER means the callee adopts what we
646
+ // built — we must NOT free it (that would UAF / double-free).
647
+
648
+ // Element-type support shared by every container kind. *why receives a short
649
+ // label on refusal (so the caller can throw a precise deferral message).
650
+ static bool IsSupportedElementType(GITypeInfo* elem, std::string* why) {
651
+ switch (gi_type_info_get_tag(elem)) {
652
+ case GI_TYPE_TAG_BOOLEAN:
653
+ case GI_TYPE_TAG_INT8:
654
+ case GI_TYPE_TAG_UINT8:
655
+ case GI_TYPE_TAG_INT16:
656
+ case GI_TYPE_TAG_UINT16:
657
+ case GI_TYPE_TAG_INT32:
658
+ case GI_TYPE_TAG_UINT32:
659
+ case GI_TYPE_TAG_INT64:
660
+ case GI_TYPE_TAG_UINT64:
661
+ case GI_TYPE_TAG_FLOAT:
662
+ case GI_TYPE_TAG_DOUBLE:
663
+ case GI_TYPE_TAG_UTF8:
664
+ case GI_TYPE_TAG_FILENAME:
665
+ return true;
666
+ case GI_TYPE_TAG_INTERFACE: {
667
+ GIBaseInfo* iface = gi_type_info_get_interface(elem);
668
+ bool ok = iface != nullptr && (GI_IS_OBJECT_INFO(iface) || GI_IS_INTERFACE_INFO(iface));
669
+ if (!ok && why != nullptr) *why = "struct/union/enum element";
670
+ if (iface != nullptr) gi_base_info_unref(iface);
671
+ return ok;
672
+ }
673
+ default:
674
+ if (why != nullptr) *why = "nested-container element";
675
+ return false;
676
+ }
677
+ }
678
+
679
+ // Whether a container type (ARRAY/GLIST/GSLIST/GHASH) is marshallable. Used by
680
+ // IsSupportedOutType (OUT/return) and the IN path to defer the unsupported cases
681
+ // up front. Array-type kind is intentionally NOT restricted here — the read side
682
+ // (GIArrayToJs) handles C/BYTE_ARRAY/GArray/GPtrArray; the IN side rejects
683
+ // GArray/GPtrArray separately (they're rare as IN in headless GLib).
684
+ bool IsSupportedContainerType(GITypeInfo* type, std::string* why) {
685
+ switch (gi_type_info_get_tag(type)) {
686
+ case GI_TYPE_TAG_ARRAY:
687
+ case GI_TYPE_TAG_GLIST:
688
+ case GI_TYPE_TAG_GSLIST: {
689
+ GITypeInfo* elem = gi_type_info_get_param_type(type, 0);
690
+ if (elem == nullptr) {
691
+ if (why != nullptr) *why = "untyped container";
692
+ return false;
693
+ }
694
+ bool ok = IsSupportedElementType(elem, why);
695
+ gi_base_info_unref(elem);
696
+ return ok;
697
+ }
698
+ case GI_TYPE_TAG_GHASH: {
699
+ GITypeInfo* kt = gi_type_info_get_param_type(type, 0);
700
+ GITypeInfo* vt = gi_type_info_get_param_type(type, 1);
701
+ GITypeTag ktag = kt != nullptr ? gi_type_info_get_tag(kt) : GI_TYPE_TAG_VOID;
702
+ // Supported hash key tags — a subset of GJS's is_supported_ghash_key_type
703
+ // (refs/gjs/gi/arg.cpp): strings + the pointer-fitting integers. 64-bit keys
704
+ // (which don't fit a pointer) + unichar keys are out of scope.
705
+ bool kok = ktag == GI_TYPE_TAG_UTF8 || ktag == GI_TYPE_TAG_FILENAME ||
706
+ ktag == GI_TYPE_TAG_INT8 || ktag == GI_TYPE_TAG_UINT8 ||
707
+ ktag == GI_TYPE_TAG_INT16 || ktag == GI_TYPE_TAG_UINT16 ||
708
+ ktag == GI_TYPE_TAG_INT32 || ktag == GI_TYPE_TAG_UINT32;
709
+ bool vok = vt != nullptr && IsSupportedElementType(vt, nullptr);
710
+ if (!kok && why != nullptr) *why = "unsupported GHashTable key";
711
+ else if (!vok && why != nullptr) *why = "unsupported GHashTable value";
712
+ if (kt != nullptr) gi_base_info_unref(kt);
713
+ if (vt != nullptr) gi_base_info_unref(vt);
714
+ return kok && vok;
715
+ }
716
+ default:
717
+ if (why != nullptr) *why = "container";
718
+ return false;
719
+ }
720
+ }
721
+
722
+ // In-memory size of one C-array element. 0 for an unsupported element type.
723
+ size_t CElementSize(GITypeInfo* elem) {
724
+ switch (gi_type_info_get_tag(elem)) {
725
+ case GI_TYPE_TAG_BOOLEAN: return sizeof(gboolean);
726
+ case GI_TYPE_TAG_INT8:
727
+ case GI_TYPE_TAG_UINT8: return 1;
728
+ case GI_TYPE_TAG_INT16:
729
+ case GI_TYPE_TAG_UINT16: return 2;
730
+ case GI_TYPE_TAG_INT32:
731
+ case GI_TYPE_TAG_UINT32: return 4;
732
+ case GI_TYPE_TAG_INT64:
733
+ case GI_TYPE_TAG_UINT64: return 8;
734
+ case GI_TYPE_TAG_FLOAT: return sizeof(gfloat);
735
+ case GI_TYPE_TAG_DOUBLE: return sizeof(gdouble);
736
+ case GI_TYPE_TAG_UTF8:
737
+ case GI_TYPE_TAG_FILENAME:
738
+ case GI_TYPE_TAG_INTERFACE: return sizeof(gpointer);
739
+ default: return 0;
740
+ }
741
+ }
742
+
743
+ // Write the array's element count into a length arg's GIArgument slot (IN length
744
+ // autofill). The field is selected by the length arg's own tag; the slot is
745
+ // zero-initialised so writing the matching field is correct on little-endian
746
+ // (the only targets — Fedora x86_64/aarch64).
747
+ void WriteLengthValue(GITypeInfo* lenType, GIArgument* slot, long n) {
748
+ switch (gi_type_info_get_tag(lenType)) {
749
+ case GI_TYPE_TAG_INT8: slot->v_int8 = static_cast<gint8>(n); break;
750
+ case GI_TYPE_TAG_UINT8: slot->v_uint8 = static_cast<guint8>(n); break;
751
+ case GI_TYPE_TAG_INT16: slot->v_int16 = static_cast<gint16>(n); break;
752
+ case GI_TYPE_TAG_UINT16: slot->v_uint16 = static_cast<guint16>(n); break;
753
+ case GI_TYPE_TAG_INT32: slot->v_int32 = static_cast<gint32>(n); break;
754
+ case GI_TYPE_TAG_UINT32: slot->v_uint32 = static_cast<guint32>(n); break;
755
+ case GI_TYPE_TAG_INT64: slot->v_int64 = static_cast<gint64>(n); break;
756
+ case GI_TYPE_TAG_UINT64: slot->v_uint64 = static_cast<guint64>(n); break;
757
+ default: slot->v_int64 = static_cast<gint64>(n); break; // gsize/gtype: LE-safe
758
+ }
759
+ }
760
+
761
+ // Read a length value the callee wrote into a length arg's slot (OUT length).
762
+ static long ReadLengthValue(GITypeInfo* lenType, GIArgument* slot) {
763
+ switch (gi_type_info_get_tag(lenType)) {
764
+ case GI_TYPE_TAG_INT8: return slot->v_int8;
765
+ case GI_TYPE_TAG_UINT8: return slot->v_uint8;
766
+ case GI_TYPE_TAG_INT16: return slot->v_int16;
767
+ case GI_TYPE_TAG_UINT16: return slot->v_uint16;
768
+ case GI_TYPE_TAG_INT32: return slot->v_int32;
769
+ case GI_TYPE_TAG_UINT32: return static_cast<long>(slot->v_uint32);
770
+ case GI_TYPE_TAG_INT64: return static_cast<long>(slot->v_int64);
771
+ case GI_TYPE_TAG_UINT64: return static_cast<long>(slot->v_uint64);
772
+ default: return static_cast<long>(slot->v_int64); // gsize/gtype: LE-safe
773
+ }
774
+ }
775
+
776
+ // Read one C-array element from raw storage into a JS value, honouring the
777
+ // per-element transfer (EVERYTHING frees strings / adopts object refs).
778
+ static Napi::Value ReadCElement(Napi::Env env, GITypeInfo* elem, const void* src,
779
+ GITransfer elemTransfer) {
780
+ GIArgument a;
781
+ memset(&a, 0, sizeof(a));
782
+ switch (gi_type_info_get_tag(elem)) {
783
+ case GI_TYPE_TAG_BOOLEAN: a.v_boolean = *static_cast<const gboolean*>(src); break;
784
+ case GI_TYPE_TAG_INT8: a.v_int8 = *static_cast<const gint8*>(src); break;
785
+ case GI_TYPE_TAG_UINT8: a.v_uint8 = *static_cast<const guint8*>(src); break;
786
+ case GI_TYPE_TAG_INT16: a.v_int16 = *static_cast<const gint16*>(src); break;
787
+ case GI_TYPE_TAG_UINT16: a.v_uint16 = *static_cast<const guint16*>(src); break;
788
+ case GI_TYPE_TAG_INT32: a.v_int32 = *static_cast<const gint32*>(src); break;
789
+ case GI_TYPE_TAG_UINT32: a.v_uint32 = *static_cast<const guint32*>(src); break;
790
+ case GI_TYPE_TAG_INT64: a.v_int64 = *static_cast<const gint64*>(src); break;
791
+ case GI_TYPE_TAG_UINT64: a.v_uint64 = *static_cast<const guint64*>(src); break;
792
+ case GI_TYPE_TAG_FLOAT: a.v_float = *static_cast<const gfloat*>(src); break;
793
+ case GI_TYPE_TAG_DOUBLE: a.v_double = *static_cast<const gdouble*>(src); break;
794
+ case GI_TYPE_TAG_UTF8:
795
+ case GI_TYPE_TAG_FILENAME: a.v_string = *static_cast<char* const*>(src); break;
796
+ case GI_TYPE_TAG_INTERFACE: a.v_pointer = *static_cast<gpointer const*>(src); break;
797
+ default: return env.Undefined();
798
+ }
799
+ return GIArgumentToJs(env, elem, &a, elemTransfer);
800
+ }
801
+
802
+ // Free a read-side array container after marshalling out its elements. For
803
+ // TRANSFER_EVERYTHING the elements were already released per-element during the
804
+ // read; this frees the container itself. NOTHING frees nothing (callee owns).
805
+ static void FreeReadArrayContainer(gpointer container, GIArrayType at, GITransfer transfer) {
806
+ if (container == nullptr || transfer == GI_TRANSFER_NOTHING) return;
807
+ switch (at) {
808
+ case GI_ARRAY_TYPE_C: g_free(container); break;
809
+ case GI_ARRAY_TYPE_BYTE_ARRAY:
810
+ // CONTAINER frees only the GByteArray wrapper (callee keeps the bytes);
811
+ // EVERYTHING frees the segment too. Mirrors the GArray/GPtrArray cases.
812
+ g_byte_array_free(static_cast<GByteArray*>(container), transfer == GI_TRANSFER_EVERYTHING);
813
+ break;
814
+ case GI_ARRAY_TYPE_ARRAY:
815
+ g_array_free(static_cast<GArray*>(container), transfer == GI_TRANSFER_EVERYTHING);
816
+ break;
817
+ case GI_ARRAY_TYPE_PTR_ARRAY:
818
+ g_ptr_array_free(static_cast<GPtrArray*>(container), transfer == GI_TRANSFER_EVERYTHING);
819
+ break;
820
+ }
821
+ }
822
+
823
+ // Marshal a C array / GStrv / GByteArray / GArray / GPtrArray into a JS value.
824
+ // `length` is the resolved element count (or -1 = derive from zero-terminated /
825
+ // fixed-size). guint8/gint8 arrays surface as a Node Buffer; everything else as
826
+ // a JS Array. Frees the container per `transfer`.
827
+ static Napi::Value GIArrayToJs(Napi::Env env, GITypeInfo* type, GIArgument* arg,
828
+ GITransfer transfer, long length) {
829
+ GIArrayType at = gi_type_info_get_array_type(type);
830
+ GITypeInfo* elem = gi_type_info_get_param_type(type, 0);
831
+ GITypeTag etag = elem != nullptr ? gi_type_info_get_tag(elem) : GI_TYPE_TAG_VOID;
832
+ GITransfer elemTransfer =
833
+ transfer == GI_TRANSFER_EVERYTHING ? GI_TRANSFER_EVERYTHING : GI_TRANSFER_NOTHING;
834
+ gpointer container = arg->v_pointer;
835
+ void* data = container;
836
+ size_t elemSize = CElementSize(elem);
837
+ bool isByte = etag == GI_TYPE_TAG_UINT8 || etag == GI_TYPE_TAG_INT8;
838
+ // A NULL container maps to `null` for every array kind EXCEPT a length-annotated
839
+ // C array (an explicit length was passed → an empty array, matching GJS's
840
+ // gjs_array_from_basic_c_array_internal "null pointer takes precedence over
841
+ // length" for the length path vs the fixed/zero-terminated/GArray/GPtrArray/
842
+ // GByteArray readers, which each return null on a NULL container).
843
+ bool hasExplicitLength = length >= 0;
844
+
845
+ // Resolve data + length for the boxed array kinds (their own struct carries it).
846
+ // A NULL boxed container → JS null (GJS gjs_value_from_basic_{garray,gptrarray,
847
+ // byte_array}_gi_argument each null-guard before reading).
848
+ if (at == GI_ARRAY_TYPE_BYTE_ARRAY) {
849
+ GByteArray* ba = static_cast<GByteArray*>(container);
850
+ if (ba == nullptr) {
851
+ if (elem != nullptr) gi_base_info_unref(elem);
852
+ return env.Null();
853
+ }
854
+ data = ba->data;
855
+ length = static_cast<long>(ba->len);
856
+ isByte = true;
857
+ elemSize = 1;
858
+ } else if (at == GI_ARRAY_TYPE_ARRAY) {
859
+ GArray* ga = static_cast<GArray*>(container);
860
+ if (ga == nullptr) {
861
+ if (elem != nullptr) gi_base_info_unref(elem);
862
+ return env.Null();
863
+ }
864
+ data = ga->data;
865
+ length = static_cast<long>(ga->len);
866
+ elemSize = g_array_get_element_size(ga);
867
+ } else if (at == GI_ARRAY_TYPE_PTR_ARRAY) {
868
+ GPtrArray* pa = static_cast<GPtrArray*>(container);
869
+ if (pa == nullptr) {
870
+ if (elem != nullptr) gi_base_info_unref(elem);
871
+ return env.Null();
872
+ }
873
+ data = pa->pdata;
874
+ length = static_cast<long>(pa->len);
875
+ elemSize = sizeof(gpointer);
876
+ } else if (data == nullptr) {
877
+ // NULL C-array pointer: a fixed/zero-terminated array (no length param) → null;
878
+ // a length-annotated array (explicit length given) → an empty array below.
879
+ if (elem != nullptr) gi_base_info_unref(elem);
880
+ return hasExplicitLength ? static_cast<Napi::Value>(Napi::Array::New(env, 0)) : env.Null();
881
+ } else if (length < 0) { // C array, length not given by a length arg
882
+ if (gi_type_info_is_zero_terminated(type)) {
883
+ length = 0;
884
+ if (data != nullptr) {
885
+ if (etag == GI_TYPE_TAG_UTF8 || etag == GI_TYPE_TAG_FILENAME ||
886
+ etag == GI_TYPE_TAG_INTERFACE) {
887
+ gpointer* p = static_cast<gpointer*>(data);
888
+ while (p[length] != nullptr) length++;
889
+ } else { // numeric: scan for an all-zero element
890
+ for (;; length++) {
891
+ const char* e = static_cast<const char*>(data) + length * elemSize;
892
+ bool zero = true;
893
+ for (size_t b = 0; b < elemSize; b++)
894
+ if (e[b] != 0) {
895
+ zero = false;
896
+ break;
897
+ }
898
+ if (zero) break;
899
+ }
900
+ }
901
+ }
902
+ } else {
903
+ size_t fixed = 0;
904
+ length = gi_type_info_get_array_fixed_size(type, &fixed) ? static_cast<long>(fixed) : 0;
905
+ }
906
+ }
907
+
908
+ // Byte arrays → a Node Buffer (a Uint8Array subclass).
909
+ if (isByte) {
910
+ Napi::Value buf = (data == nullptr || length <= 0)
911
+ ? static_cast<Napi::Value>(Napi::Buffer<uint8_t>::New(env, 0))
912
+ : static_cast<Napi::Value>(Napi::Buffer<uint8_t>::Copy(
913
+ env, static_cast<const uint8_t*>(data), static_cast<size_t>(length)));
914
+ FreeReadArrayContainer(container, at, transfer);
915
+ if (elem != nullptr) gi_base_info_unref(elem);
916
+ return buf;
917
+ }
918
+
919
+ Napi::Array out = Napi::Array::New(env, (data != nullptr && length > 0) ? length : 0);
920
+ for (long i = 0; data != nullptr && i < length && !env.IsExceptionPending(); i++) {
921
+ if (at == GI_ARRAY_TYPE_PTR_ARRAY) {
922
+ GIArgument a;
923
+ memset(&a, 0, sizeof(a));
924
+ a.v_pointer = static_cast<gpointer*>(data)[i];
925
+ out.Set(static_cast<uint32_t>(i), GIArgumentToJs(env, elem, &a, elemTransfer));
926
+ } else {
927
+ out.Set(static_cast<uint32_t>(i),
928
+ ReadCElement(env, elem, static_cast<const char*>(data) + i * elemSize, elemTransfer));
929
+ }
930
+ }
931
+ FreeReadArrayContainer(container, at, transfer);
932
+ if (elem != nullptr) gi_base_info_unref(elem);
933
+ return out;
934
+ }
935
+
936
+ // Marshal a GList / GSList into a JS Array. Elements are unpacked from each
937
+ // node's data pointer via the introspection helper (strings/objects keep the
938
+ // pointer, fundamentals are GPOINTER_TO_INT-style unpacked). Frees the node
939
+ // chain (not the elements — those follow `transfer` per element) for
940
+ // EVERYTHING/CONTAINER.
941
+ static Napi::Value GListToJs(Napi::Env env, GITypeInfo* type, GIArgument* arg,
942
+ GITransfer transfer, bool isSList) {
943
+ GITypeInfo* elem = gi_type_info_get_param_type(type, 0);
944
+ GITransfer elemTransfer =
945
+ transfer == GI_TRANSFER_EVERYTHING ? GI_TRANSFER_EVERYTHING : GI_TRANSFER_NOTHING;
946
+ Napi::Array out = Napi::Array::New(env);
947
+ uint32_t i = 0;
948
+ if (!isSList) {
949
+ for (GList* l = static_cast<GList*>(arg->v_pointer); l != nullptr && !env.IsExceptionPending();
950
+ l = l->next) {
951
+ GIArgument a;
952
+ gi_type_info_argument_from_hash_pointer(elem, l->data, &a);
953
+ out.Set(i++, GIArgumentToJs(env, elem, &a, elemTransfer));
954
+ }
955
+ } else {
956
+ for (GSList* l = static_cast<GSList*>(arg->v_pointer); l != nullptr && !env.IsExceptionPending();
957
+ l = l->next) {
958
+ GIArgument a;
959
+ gi_type_info_argument_from_hash_pointer(elem, l->data, &a);
960
+ out.Set(i++, GIArgumentToJs(env, elem, &a, elemTransfer));
961
+ }
962
+ }
963
+ if (transfer == GI_TRANSFER_EVERYTHING || transfer == GI_TRANSFER_CONTAINER) {
964
+ if (!isSList) g_list_free(static_cast<GList*>(arg->v_pointer));
965
+ else g_slist_free(static_cast<GSList*>(arg->v_pointer));
966
+ }
967
+ if (elem != nullptr) gi_base_info_unref(elem);
968
+ return out;
969
+ }
970
+
971
+ // Marshal a GHashTable into a plain JS object (string keys). Keys + values are
972
+ // read with TRANSFER_NOTHING (copied / ref'd) because, for an owned table, the
973
+ // table's own GDestroyNotify funcs free the originals when we g_hash_table_unref
974
+ // below — freeing here too would double-free.
975
+ static Napi::Value GHashToJs(Napi::Env env, GITypeInfo* type, GIArgument* arg,
976
+ GITransfer transfer) {
977
+ GHashTable* ht = static_cast<GHashTable*>(arg->v_pointer);
978
+ // A NULL GHashTable maps to `null` (GJS gjs_value_from_basic_ghash null-guards
979
+ // the table before reading) — NOT an empty object; e.g. an uninitialized OUT.
980
+ if (ht == nullptr) return env.Null();
981
+ Napi::Object out = Napi::Object::New(env);
982
+ if (ht != nullptr) {
983
+ GITypeInfo* kt = gi_type_info_get_param_type(type, 0);
984
+ GITypeInfo* vt = gi_type_info_get_param_type(type, 1);
985
+ GHashTableIter it;
986
+ gpointer k = nullptr;
987
+ gpointer v = nullptr;
988
+ g_hash_table_iter_init(&it, ht);
989
+ while (g_hash_table_iter_next(&it, &k, &v) && !env.IsExceptionPending()) {
990
+ GIArgument ka;
991
+ GIArgument va;
992
+ gi_type_info_argument_from_hash_pointer(kt, k, &ka);
993
+ gi_type_info_argument_from_hash_pointer(vt, v, &va);
994
+ Napi::Value key = GIArgumentToJs(env, kt, &ka, GI_TRANSFER_NOTHING);
995
+ Napi::Value value = GIArgumentToJs(env, vt, &va, GI_TRANSFER_NOTHING);
996
+ if (env.IsExceptionPending()) break;
997
+ out.Set(key, value);
998
+ }
999
+ if (kt != nullptr) gi_base_info_unref(kt);
1000
+ if (vt != nullptr) gi_base_info_unref(vt);
1001
+ }
1002
+ if (ht != nullptr && (transfer == GI_TRANSFER_EVERYTHING || transfer == GI_TRANSFER_CONTAINER)) {
1003
+ // CONTAINER transfers the table but NOT its keys/values (callee keeps them).
1004
+ // g_hash_table_unref would run the key/value destroy-notifiers → over-free,
1005
+ // so steal the entries first; the unref then frees only the table itself.
1006
+ // EVERYTHING keeps the destroy-notifiers (we own keys + values). Mirrors the
1007
+ // C-array / GList CONTAINER handling that frees only the container.
1008
+ if (transfer == GI_TRANSFER_CONTAINER) g_hash_table_steal_all(ht);
1009
+ g_hash_table_unref(ht);
1010
+ }
1011
+ return out;
1012
+ }
1013
+
1014
+ // Dispatch a return / OUT GIArgument to the right reader. `slots` lets an array
1015
+ // resolve its length from the (already-populated) length arg slot. Scalars fall
1016
+ // through to GIArgumentToJs.
1017
+ Napi::Value ReadOutOrReturn(Napi::Env env, GICallableInfo* callable, GITypeInfo* ti,
1018
+ GIArgument* arg, GITransfer transfer,
1019
+ std::vector<GIArgument>* slots) {
1020
+ switch (gi_type_info_get_tag(ti)) {
1021
+ case GI_TYPE_TAG_ARRAY: {
1022
+ long len = -1;
1023
+ unsigned int L = 0;
1024
+ if (gi_type_info_get_array_length_index(ti, &L) && slots != nullptr && L < slots->size()) {
1025
+ GIArgInfo* la = gi_callable_info_get_arg(callable, L);
1026
+ GITypeInfo* lt = gi_arg_info_get_type_info(la);
1027
+ len = ReadLengthValue(lt, &(*slots)[L]);
1028
+ gi_base_info_unref(lt);
1029
+ gi_base_info_unref(la);
1030
+ }
1031
+ return GIArrayToJs(env, ti, arg, transfer, len);
1032
+ }
1033
+ case GI_TYPE_TAG_GLIST: return GListToJs(env, ti, arg, transfer, false);
1034
+ case GI_TYPE_TAG_GSLIST: return GListToJs(env, ti, arg, transfer, true);
1035
+ case GI_TYPE_TAG_GHASH: return GHashToJs(env, ti, arg, transfer);
1036
+ default: return GIArgumentToJs(env, ti, arg, transfer);
1037
+ }
1038
+ }
1039
+
1040
+ // Fill a GIArgument for a single list/hash element from a JS value. Strings are
1041
+ // g_strdup'd (the container owns them); objects contribute their borrowed
1042
+ // handle pointer. Throws + returns false on an unsupported element.
1043
+ static bool ElementToGIArgument(Napi::Env env, GITypeInfo* elem, Napi::Value v, GIArgument* a) {
1044
+ memset(a, 0, sizeof(*a));
1045
+ switch (gi_type_info_get_tag(elem)) {
1046
+ // Scalar coercions via the terminate-safe helpers (common.h): a swallowed
1047
+ // napi failure mid worker.terminate() must degrade to a zero, not cascade
1048
+ // into Error::New(nullptr)'s fatal sites.
1049
+ case GI_TYPE_TAG_BOOLEAN: a->v_boolean = NodeGiToBool(v); return true;
1050
+ case GI_TYPE_TAG_INT8: a->v_int8 = static_cast<gint8>(NodeGiToInt32(v)); return true;
1051
+ case GI_TYPE_TAG_UINT8: a->v_uint8 = static_cast<guint8>(NodeGiToUint32(v)); return true;
1052
+ case GI_TYPE_TAG_INT16: a->v_int16 = static_cast<gint16>(NodeGiToInt32(v)); return true;
1053
+ case GI_TYPE_TAG_UINT16: a->v_uint16 = static_cast<guint16>(NodeGiToUint32(v)); return true;
1054
+ case GI_TYPE_TAG_INT32: a->v_int32 = NodeGiToInt32(v); return true;
1055
+ case GI_TYPE_TAG_UINT32: a->v_uint32 = NodeGiToUint32(v); return true;
1056
+ case GI_TYPE_TAG_INT64: a->v_int64 = JsValueToInt64(v); return true;
1057
+ case GI_TYPE_TAG_UINT64: a->v_uint64 = JsValueToUint64(v); return true;
1058
+ case GI_TYPE_TAG_FLOAT: a->v_float = static_cast<gfloat>(NodeGiToDouble(v)); return true;
1059
+ case GI_TYPE_TAG_DOUBLE: a->v_double = NodeGiToDouble(v); return true;
1060
+ case GI_TYPE_TAG_UTF8:
1061
+ case GI_TYPE_TAG_FILENAME:
1062
+ a->v_string = (v.IsEmpty() || v.IsNull() || v.IsUndefined())
1063
+ ? nullptr
1064
+ : g_strdup(NodeGiToUtf8(v).c_str());
1065
+ return true;
1066
+ case GI_TYPE_TAG_INTERFACE: {
1067
+ if (v.IsNull() || v.IsUndefined()) {
1068
+ a->v_pointer = nullptr;
1069
+ return true;
1070
+ }
1071
+ if (v.IsExternal() && v.As<Napi::External<GObject>>().CheckTypeTag(&kGObjectHandleTag)) {
1072
+ a->v_pointer = v.As<Napi::External<GObject>>().Data();
1073
+ return true;
1074
+ }
1075
+ Napi::TypeError::New(env, "expected a GObject handle as a container element")
1076
+ .ThrowAsJavaScriptException();
1077
+ return false;
1078
+ }
1079
+ default:
1080
+ Napi::TypeError::New(env, "unsupported container element type")
1081
+ .ThrowAsJavaScriptException();
1082
+ return false;
1083
+ }
1084
+ }
1085
+
1086
+ // Build a C array / GStrv / GByteArray / GArray / GPtrArray from a JS value.
1087
+ // *outCount = element count (for the IN length autofill + later free). Throws +
1088
+ // returns false on refusal (BEFORE the invoke).
1089
+ static bool JsToCArray(Napi::Env env, Napi::Value v, GITypeInfo* type, gpointer* outPtr,
1090
+ long* outCount) {
1091
+ GIArrayType at = gi_type_info_get_array_type(type);
1092
+ GITypeInfo* elem = gi_type_info_get_param_type(type, 0);
1093
+ GITypeTag etag = gi_type_info_get_tag(elem);
1094
+ bool zt = gi_type_info_is_zero_terminated(type);
1095
+ size_t elemSize = CElementSize(elem);
1096
+ bool isByte = etag == GI_TYPE_TAG_UINT8 || etag == GI_TYPE_TAG_INT8;
1097
+ *outPtr = nullptr;
1098
+ *outCount = 0;
1099
+
1100
+ // null/undefined → a NULL array (count 0), as GJS marshals a null array arg
1101
+ // (refs/gjs/gi/arg.cpp gjs_array_to_explicit_array: null in → NULL out).
1102
+ // Exposing call: `Gst.init(null)` — a NULLABLE (inout) argv array every GJS
1103
+ // GStreamer consumer passes null for (`@gjsify/webaudio`'s ensureGstInit on
1104
+ // the jelly-jumper-on-node path).
1105
+ if (v.IsNull() || v.IsUndefined()) {
1106
+ gi_base_info_unref(elem);
1107
+ return true;
1108
+ }
1109
+
1110
+ // Raw bytes from a TypedArray / Buffer (Uint8Array round-trips, etc.).
1111
+ // `hasRawBytes` (NOT `rawBytes != nullptr`) marks a TypedArray/Buffer SOURCE: an
1112
+ // EMPTY Uint8Array/Buffer has a NULL backing-store pointer (V8 hands out nullptr
1113
+ // for a zero-length ArrayBuffer), yet it is still a valid 0-length byte source.
1114
+ // gjs marshals `new Uint8Array([])` to an empty C container (count 0); it must
1115
+ // NOT fall through to the `!v.IsArray()` throw below (a TypedArray isn't a JS
1116
+ // Array). Verified vs gjs 1.88: GLib.base64_encode(new Uint8Array([])) === ''.
1117
+ const uint8_t* rawBytes = nullptr;
1118
+ size_t rawLen = 0;
1119
+ bool hasRawBytes = false;
1120
+ if (v.IsBuffer()) {
1121
+ Napi::Buffer<uint8_t> b = v.As<Napi::Buffer<uint8_t>>();
1122
+ rawBytes = b.Data();
1123
+ rawLen = b.Length();
1124
+ hasRawBytes = true;
1125
+ } else if (v.IsTypedArray()) {
1126
+ Napi::TypedArray ta = v.As<Napi::TypedArray>();
1127
+ rawBytes = static_cast<const uint8_t*>(ta.ArrayBuffer().Data()) + ta.ByteOffset();
1128
+ rawLen = ta.ByteLength();
1129
+ hasRawBytes = true;
1130
+ }
1131
+
1132
+ if (at == GI_ARRAY_TYPE_BYTE_ARRAY) {
1133
+ GByteArray* ba = g_byte_array_new();
1134
+ if (hasRawBytes) {
1135
+ // append is a no-op for len 0; guard so a NULL data ptr (empty typed array)
1136
+ // is never handed to g_byte_array_append's memcpy.
1137
+ if (rawLen > 0) g_byte_array_append(ba, rawBytes, static_cast<guint>(rawLen));
1138
+ *outCount = static_cast<long>(rawLen);
1139
+ } else if (v.IsArray()) {
1140
+ Napi::Array arr = v.As<Napi::Array>();
1141
+ for (uint32_t i = 0; i < arr.Length(); i++) {
1142
+ guint8 byte = static_cast<guint8>(NodeGiToUint32(arr.Get(i)));
1143
+ g_byte_array_append(ba, &byte, 1);
1144
+ }
1145
+ *outCount = static_cast<long>(arr.Length());
1146
+ } else {
1147
+ g_byte_array_unref(ba);
1148
+ gi_base_info_unref(elem);
1149
+ Napi::TypeError::New(env, "expected an array or Uint8Array for the GByteArray argument")
1150
+ .ThrowAsJavaScriptException();
1151
+ return false;
1152
+ }
1153
+ *outPtr = ba;
1154
+ gi_base_info_unref(elem);
1155
+ return true;
1156
+ }
1157
+
1158
+ // C / GArray / GPtrArray share the element-marshalling loop below; only their
1159
+ // final container wrapping differs. The byte-specific fast paths (TypedArray /
1160
+ // string → bytes) are C-array-only ergonomics (a GArray/GPtrArray of bytes is
1161
+ // not something GJS accepts a TypedArray for), so gate them on the C kind.
1162
+
1163
+ // Byte C array from a TypedArray / Buffer. `hasRawBytes` (not `rawBytes !=
1164
+ // nullptr`) so an empty typed array (NULL data, len 0) still lands here: it
1165
+ // allocates a 0-length buffer (g_malloc0(0) → NULL, i.e. an empty C array,
1166
+ // count 0 — the same shape an empty JS array `[]` already produced) instead of
1167
+ // falling through to the `!v.IsArray()` throw. memcpy is guarded on rawLen>0.
1168
+ if (at == GI_ARRAY_TYPE_C && isByte && hasRawBytes) {
1169
+ void* buf = g_malloc0(elemSize * (rawLen + (zt ? 1 : 0)));
1170
+ if (rawLen > 0) memcpy(buf, rawBytes, rawLen);
1171
+ *outPtr = buf;
1172
+ *outCount = static_cast<long>(rawLen);
1173
+ gi_base_info_unref(elem);
1174
+ return true;
1175
+ }
1176
+
1177
+ // A JS string as an int8/uint8 C array → its UTF-8 bytes, exactly as GJS does
1178
+ // (refs/gjs/gi/arg.cpp: "Allow strings as int8/uint8/int16/uint16 arrays" →
1179
+ // gjs_string_to_intarray → gjs_string_to_utf8_n for the int8/uint8 element tags).
1180
+ // The length is the UTF-8 BYTE count (not the JS string length, not NUL-inclusive);
1181
+ // a zero-terminated array still gets its trailing NUL slot from g_malloc0.
1182
+ // Verified against gjs 1.88: GIMarshallingTests.utf8_as_uint8array_in('const ♥ utf8')
1183
+ // is accepted (no throw). A real Uint8Array/Array still works via the paths around this.
1184
+ if (at == GI_ARRAY_TYPE_C && isByte && v.IsString()) {
1185
+ std::string s = v.As<Napi::String>().Utf8Value();
1186
+ size_t n = s.size();
1187
+ void* buf = g_malloc0(elemSize * (n + (zt ? 1 : 0)));
1188
+ memcpy(buf, s.data(), n);
1189
+ *outPtr = buf;
1190
+ *outCount = static_cast<long>(n);
1191
+ gi_base_info_unref(elem);
1192
+ return true;
1193
+ }
1194
+
1195
+ if (!v.IsArray()) {
1196
+ gi_base_info_unref(elem);
1197
+ Napi::TypeError::New(env, "expected an array for the array argument")
1198
+ .ThrowAsJavaScriptException();
1199
+ return false;
1200
+ }
1201
+ Napi::Array arr = v.As<Napi::Array>();
1202
+ long count = static_cast<long>(arr.Length());
1203
+ // Build the flat element buffer first (the storage a C array uses directly).
1204
+ // GArray / GPtrArray then wrap this buffer — mirroring gjs, which marshals the
1205
+ // JS array into a C buffer and hands it to g_array_append_vals / the GPtrArray
1206
+ // pdata memcpy (refs/gjs/gi/arg.cpp gjs_value_to_basic_array_gi_argument).
1207
+ void* buf = g_malloc0(elemSize * (count + (zt ? 1 : 0)));
1208
+ bool ok = true;
1209
+ for (long i = 0; i < count && ok; i++) {
1210
+ GIArgument a;
1211
+ ok = ElementToGIArgument(env, elem, arr.Get(static_cast<uint32_t>(i)), &a);
1212
+ if (!ok) break;
1213
+ void* dst = static_cast<char*>(buf) + i * elemSize;
1214
+ // Copy the element's storage bytes (LE: the low elemSize bytes of the union
1215
+ // alias the active field — v_string/v_pointer for pointers, the scalar
1216
+ // otherwise).
1217
+ memcpy(dst, &a, elemSize);
1218
+ }
1219
+ if (!ok) {
1220
+ g_free(buf); // partial g_strdup'd strings leak on this error path (pre-checked, rare)
1221
+ gi_base_info_unref(elem);
1222
+ return false;
1223
+ }
1224
+
1225
+ if (at == GI_ARRAY_TYPE_ARRAY) {
1226
+ // GArray: a zero-terminated, sized GArray over `elemSize` elements
1227
+ // (garray_new_for_basic_type in gjs uses zero_terminated=true). append_vals
1228
+ // COPIES the buffer, so free our scratch buffer afterwards. String elements
1229
+ // (g_strdup'd pointers) now live in the GArray data; freed per-transfer in
1230
+ // FreeInContainer.
1231
+ GArray* ga = g_array_sized_new(TRUE, FALSE, static_cast<guint>(elemSize),
1232
+ static_cast<guint>(count));
1233
+ if (count > 0) g_array_append_vals(ga, buf, static_cast<guint>(count));
1234
+ g_free(buf);
1235
+ *outPtr = ga;
1236
+ } else if (at == GI_ARRAY_TYPE_PTR_ARRAY) {
1237
+ // GPtrArray: element pointers copied into pdata (gjs memcpy's the same way).
1238
+ GPtrArray* pa = g_ptr_array_sized_new(static_cast<guint>(count));
1239
+ g_ptr_array_set_size(pa, static_cast<int>(count));
1240
+ if (count > 0) memcpy(pa->pdata, buf, sizeof(gpointer) * static_cast<size_t>(count));
1241
+ g_free(buf);
1242
+ *outPtr = pa;
1243
+ } else {
1244
+ *outPtr = buf; // GI_ARRAY_TYPE_C
1245
+ }
1246
+ *outCount = count;
1247
+ gi_base_info_unref(elem);
1248
+ return true;
1249
+ }
1250
+
1251
+ // Build a GList / GSList from a JS array.
1252
+ static bool JsToGListLike(Napi::Env env, Napi::Value v, GITypeInfo* type, bool isSList,
1253
+ gpointer* outPtr) {
1254
+ *outPtr = nullptr;
1255
+ if (!v.IsArray()) {
1256
+ Napi::TypeError::New(env, "expected an array for the list argument").ThrowAsJavaScriptException();
1257
+ return false;
1258
+ }
1259
+ Napi::Array arr = v.As<Napi::Array>();
1260
+ GITypeInfo* elem = gi_type_info_get_param_type(type, 0);
1261
+ GList* glist = nullptr;
1262
+ GSList* gslist = nullptr;
1263
+ bool ok = true;
1264
+ for (uint32_t i = 0; i < arr.Length() && ok; i++) {
1265
+ GIArgument a;
1266
+ ok = ElementToGIArgument(env, elem, arr.Get(i), &a);
1267
+ if (!ok) break;
1268
+ gpointer p = gi_type_info_hash_pointer_from_argument(elem, &a);
1269
+ if (!isSList) glist = g_list_prepend(glist, p);
1270
+ else gslist = g_slist_prepend(gslist, p);
1271
+ }
1272
+ if (!isSList) *outPtr = g_list_reverse(glist);
1273
+ else *outPtr = g_slist_reverse(gslist);
1274
+ gi_base_info_unref(elem);
1275
+ return ok;
1276
+ }
1277
+
1278
+ // Build a GHashTable from a JS object. Keys ∈ {string, pointer-fitting int};
1279
+ // values ∈ {string, object, int, and the heap-boxed int64/uint64/float/double}.
1280
+ // Mirrors GJS (refs/gjs/gi/arg.cpp gjs_object_to_g_hash + value_to_ghashtable_key):
1281
+ // * string keys → g_strdup + g_str_hash/equal; integer keys → GINT_TO_POINTER
1282
+ // via gi_type_info_hash_pointer_from_argument + g_direct_hash/equal.
1283
+ // * int/int32/string/object values fit a pointer (hash_pointer_from_argument);
1284
+ // int64/uint64/float/double do NOT, so they are heap-allocated (g_new) and a
1285
+ // pointer to the heap value is stored (the hash owns + g_free's it).
1286
+ // Key/value GDestroyNotify funcs are attached so a transfer-none teardown
1287
+ // (g_hash_table_unref in FreeInContainer) releases everything; a transfer-full
1288
+ // hash is adopted by the callee, which frees it (and thus runs the notifies).
1289
+ static bool JsToGHashIn(Napi::Env env, Napi::Value v, GITypeInfo* type, gpointer* outPtr) {
1290
+ *outPtr = nullptr;
1291
+ if (!v.IsObject() || v.IsArray()) {
1292
+ Napi::TypeError::New(env, "expected an object for the GHashTable argument")
1293
+ .ThrowAsJavaScriptException();
1294
+ return false;
1295
+ }
1296
+ GITypeInfo* kt = gi_type_info_get_param_type(type, 0);
1297
+ GITypeInfo* vt = gi_type_info_get_param_type(type, 1);
1298
+ GITypeTag ktag = gi_type_info_get_tag(kt);
1299
+ GITypeTag vtag = gi_type_info_get_tag(vt);
1300
+ bool keyIsString = ktag == GI_TYPE_TAG_UTF8 || ktag == GI_TYPE_TAG_FILENAME;
1301
+ bool valueIsString = vtag == GI_TYPE_TAG_UTF8 || vtag == GI_TYPE_TAG_FILENAME;
1302
+ bool valueHeap = vtag == GI_TYPE_TAG_INT64 || vtag == GI_TYPE_TAG_UINT64 ||
1303
+ vtag == GI_TYPE_TAG_FLOAT || vtag == GI_TYPE_TAG_DOUBLE;
1304
+ GDestroyNotify keyFree = keyIsString ? g_free : nullptr;
1305
+ GDestroyNotify valFree = (valueIsString || valueHeap) ? g_free : nullptr;
1306
+ GHashTable* ht = g_hash_table_new_full(keyIsString ? g_str_hash : g_direct_hash,
1307
+ keyIsString ? g_str_equal : g_direct_equal,
1308
+ keyFree, valFree);
1309
+ Napi::Object obj = v.As<Napi::Object>();
1310
+ Napi::Array keys = obj.GetPropertyNames();
1311
+ if (keys.IsEmpty()) {
1312
+ // napi_get_property_names failed with the throw swallowed (terminating env /
1313
+ // a throwing proxy trap left the exception pending). keys.Length() on the
1314
+ // empty Array would abort via Error::New(nullptr) — fail the marshal.
1315
+ g_hash_table_unref(ht);
1316
+ gi_base_info_unref(kt);
1317
+ gi_base_info_unref(vt);
1318
+ return false;
1319
+ }
1320
+ bool ok = true;
1321
+ for (uint32_t i = 0; i < keys.Length() && ok; i++) {
1322
+ Napi::Value key = keys.Get(i);
1323
+ std::string ks = NodeGiToUtf8(key); // JS property keys are strings
1324
+
1325
+ // Key pointer: g_strdup for strings, else the integer key parsed from the
1326
+ // (string) property name and GINT_TO_POINTER-encoded per its tag.
1327
+ gpointer kp = nullptr;
1328
+ if (keyIsString) {
1329
+ kp = g_strdup(ks.c_str());
1330
+ } else {
1331
+ GIArgument ka;
1332
+ Napi::Value keyNum = Napi::Number::New(env, static_cast<double>(g_ascii_strtoll(ks.c_str(), nullptr, 10)));
1333
+ ok = ElementToGIArgument(env, kt, keyNum, &ka);
1334
+ if (!ok) break;
1335
+ kp = gi_type_info_hash_pointer_from_argument(kt, &ka);
1336
+ }
1337
+
1338
+ // Value pointer: heap-box the wide/float values (they don't fit a pointer),
1339
+ // else pointer-encode via hash_pointer_from_argument (strings g_strdup'd by
1340
+ // ElementToGIArgument, ints GINT_TO_POINTER, objects the borrowed handle).
1341
+ Napi::Value val = obj.Get(key);
1342
+ gpointer vp = nullptr;
1343
+ GIArgument va;
1344
+ ok = ElementToGIArgument(env, vt, val, &va);
1345
+ if (!ok) {
1346
+ g_free(kp);
1347
+ break;
1348
+ }
1349
+ if (vtag == GI_TYPE_TAG_INT64) {
1350
+ gint64* p = g_new(gint64, 1);
1351
+ *p = va.v_int64;
1352
+ vp = p;
1353
+ } else if (vtag == GI_TYPE_TAG_UINT64) {
1354
+ guint64* p = g_new(guint64, 1);
1355
+ *p = va.v_uint64;
1356
+ vp = p;
1357
+ } else if (vtag == GI_TYPE_TAG_FLOAT) {
1358
+ gfloat* p = g_new(gfloat, 1);
1359
+ *p = va.v_float;
1360
+ vp = p;
1361
+ } else if (vtag == GI_TYPE_TAG_DOUBLE) {
1362
+ gdouble* p = g_new(gdouble, 1);
1363
+ *p = va.v_double;
1364
+ vp = p;
1365
+ } else {
1366
+ vp = gi_type_info_hash_pointer_from_argument(vt, &va);
1367
+ }
1368
+ g_hash_table_insert(ht, kp, vp);
1369
+ }
1370
+ *outPtr = ht;
1371
+ gi_base_info_unref(kt);
1372
+ gi_base_info_unref(vt);
1373
+ return ok;
1374
+ }
1375
+
1376
+ // Free an IN container after the invoke. Only TRANSFER_NOTHING is freed here —
1377
+ // EVERYTHING/CONTAINER were adopted by the callee.
1378
+ void FreeInContainer(const InContainer& c) {
1379
+ if (c.ptr == nullptr || c.transfer != GI_TRANSFER_NOTHING) {
1380
+ gi_base_info_unref(c.type);
1381
+ return;
1382
+ }
1383
+ GITypeTag tag = gi_type_info_get_tag(c.type);
1384
+ if (tag == GI_TYPE_TAG_ARRAY) {
1385
+ GIArrayType at = gi_type_info_get_array_type(c.type);
1386
+ GITypeInfo* elem = gi_type_info_get_param_type(c.type, 0);
1387
+ GITypeTag etag = elem != nullptr ? gi_type_info_get_tag(elem) : GI_TYPE_TAG_VOID;
1388
+ bool freeStrings = etag == GI_TYPE_TAG_UTF8 || etag == GI_TYPE_TAG_FILENAME;
1389
+ if (at == GI_ARRAY_TYPE_BYTE_ARRAY) {
1390
+ g_byte_array_unref(static_cast<GByteArray*>(c.ptr));
1391
+ } else if (at == GI_ARRAY_TYPE_ARRAY) {
1392
+ // GArray: free the g_strdup'd string elements first (g_array_free's
1393
+ // free_segment=TRUE releases the data block but not what its pointers
1394
+ // point at), then the GArray + its data segment.
1395
+ GArray* ga = static_cast<GArray*>(c.ptr);
1396
+ if (freeStrings)
1397
+ for (guint i = 0; i < ga->len; i++) g_free(g_array_index(ga, char*, i));
1398
+ g_array_free(ga, TRUE);
1399
+ } else if (at == GI_ARRAY_TYPE_PTR_ARRAY) {
1400
+ // GPtrArray: same — free owned string elements, then the array. Object
1401
+ // elements are borrowed handle pointers (never freed here).
1402
+ GPtrArray* pa = static_cast<GPtrArray*>(c.ptr);
1403
+ if (freeStrings)
1404
+ for (guint i = 0; i < pa->len; i++) g_free(g_ptr_array_index(pa, i));
1405
+ g_ptr_array_free(pa, TRUE);
1406
+ } else { // C array
1407
+ if (freeStrings) {
1408
+ char** s = static_cast<char**>(c.ptr);
1409
+ if (gi_type_info_is_zero_terminated(c.type)) {
1410
+ g_strfreev(s);
1411
+ } else {
1412
+ for (long i = 0; i < c.count; i++) g_free(s[i]);
1413
+ g_free(s);
1414
+ }
1415
+ } else {
1416
+ g_free(c.ptr);
1417
+ }
1418
+ }
1419
+ if (elem != nullptr) gi_base_info_unref(elem);
1420
+ } else if (tag == GI_TYPE_TAG_GLIST || tag == GI_TYPE_TAG_GSLIST) {
1421
+ GITypeInfo* elem = gi_type_info_get_param_type(c.type, 0);
1422
+ GITypeTag etag = elem != nullptr ? gi_type_info_get_tag(elem) : GI_TYPE_TAG_VOID;
1423
+ bool freeStrings = etag == GI_TYPE_TAG_UTF8 || etag == GI_TYPE_TAG_FILENAME;
1424
+ if (tag == GI_TYPE_TAG_GLIST) {
1425
+ if (freeStrings)
1426
+ for (GList* l = static_cast<GList*>(c.ptr); l != nullptr; l = l->next) g_free(l->data);
1427
+ g_list_free(static_cast<GList*>(c.ptr));
1428
+ } else {
1429
+ if (freeStrings)
1430
+ for (GSList* l = static_cast<GSList*>(c.ptr); l != nullptr; l = l->next) g_free(l->data);
1431
+ g_slist_free(static_cast<GSList*>(c.ptr));
1432
+ }
1433
+ if (elem != nullptr) gi_base_info_unref(elem);
1434
+ } else if (tag == GI_TYPE_TAG_GHASH) {
1435
+ g_hash_table_unref(static_cast<GHashTable*>(c.ptr)); // destroy funcs free keys/values
1436
+ }
1437
+ gi_base_info_unref(c.type);
1438
+ }
1439
+
1440
+ // Build any supported IN container from a JS value, recording cleanup. The
1441
+ // container type must already have passed IsSupportedContainerType. Throws +
1442
+ // returns false on refusal (BEFORE the invoke).
1443
+ bool JsToInContainer(Napi::Env env, Napi::Value v, GITypeInfo* type, GITransfer transfer,
1444
+ gpointer* outPtr, long* outCount,
1445
+ std::vector<InContainer>* containers) {
1446
+ GITypeTag tag = gi_type_info_get_tag(type);
1447
+ *outCount = 0;
1448
+ bool ok = false;
1449
+ if (tag == GI_TYPE_TAG_ARRAY) {
1450
+ ok = JsToCArray(env, v, type, outPtr, outCount);
1451
+ } else if (tag == GI_TYPE_TAG_GLIST || tag == GI_TYPE_TAG_GSLIST) {
1452
+ ok = JsToGListLike(env, v, type, tag == GI_TYPE_TAG_GSLIST, outPtr);
1453
+ } else if (tag == GI_TYPE_TAG_GHASH) {
1454
+ ok = JsToGHashIn(env, v, type, outPtr);
1455
+ }
1456
+ if (ok && *outPtr != nullptr) {
1457
+ containers->push_back(InContainer{
1458
+ reinterpret_cast<GITypeInfo*>(gi_base_info_ref(type)), *outPtr, transfer, *outCount});
1459
+ } else if (!ok && *outPtr != nullptr) {
1460
+ // An element conversion threw mid-build (JsToGListLike / JsToGHashIn still
1461
+ // published the partial container). It was never recorded for cleanup, so
1462
+ // the nodes (and any g_strdup'd keys/string values) would leak. Free it now
1463
+ // with NOTHING semantics — we still own all of it; the callee never saw it.
1464
+ InContainer partial{reinterpret_cast<GITypeInfo*>(gi_base_info_ref(type)), *outPtr,
1465
+ GI_TRANSFER_NOTHING, *outCount};
1466
+ FreeInContainer(partial); // unrefs the type ref taken above
1467
+ *outPtr = nullptr;
1468
+ }
1469
+ return ok;
1470
+ }
1471
+
1472
+ // ---- struct / boxed / union FIELD access (phase 2.6) -----------------------
1473
+ //
1474
+ // Read/write a named field of a boxed/struct/union handle, mirroring GJS's
1475
+ // gi/boxed.cpp field_getter_impl / field_setter_impl. Simple-typed fields
1476
+ // (ints/floats/bool/enum) go through gi_field_info_get/set_field; pointer-backed
1477
+ // fields (strings, arrays, pointer-to-boxed) are read via the raw offset because
1478
+ // gi_field_info_get_field refuses non-simple fields on some libgirepository
1479
+ // versions; a nested BY-VALUE struct/union field returns a borrowing sub-handle.
1480
+ // Resolution rule (VERIFIED against gjs 1.88, find_unique_js_field_name): a name
1481
+ // that is BOTH a method and a field resolves to the METHOD (gjs renames the field
1482
+ // to `_name`); so boxedMemberKind checks methods first.
1483
+
1484
+ // Resolve the struct/union GIBaseInfo backing a boxed handle. Prefers the stored
1485
+ // static info (required for an unregistered struct whose GType is G_TYPE_NONE),
1486
+ // else looks it up by the registered GType. Returns a NEW ref (unref by caller) or
1487
+ // nullptr when neither source resolves a struct/union.
1488
+ static GIBaseInfo* DupBoxedTypeInfo(BoxedHandle* bh) {
1489
+ if (bh->info != nullptr && (GI_IS_STRUCT_INFO(bh->info) || GI_IS_UNION_INFO(bh->info))) {
1490
+ return gi_base_info_ref(bh->info);
1491
+ }
1492
+ if (bh->gtype != G_TYPE_INVALID && bh->gtype != G_TYPE_NONE) {
1493
+ GIRepository* repo = DupDefaultRepository();
1494
+ GIBaseInfo* bi = gi_repository_find_by_gtype(repo, bh->gtype);
1495
+ g_object_unref(repo);
1496
+ if (bi != nullptr && (GI_IS_STRUCT_INFO(bi) || GI_IS_UNION_INFO(bi))) return bi;
1497
+ if (bi != nullptr) gi_base_info_unref(bi);
1498
+ }
1499
+ return nullptr;
1500
+ }
1501
+
1502
+ // Find a field by name on a struct or union info. GIUnionInfo has no find_field,
1503
+ // so iterate. Returns a NEW ref (unref by caller) or nullptr.
1504
+ static GIFieldInfo* FindBoxedField(GIBaseInfo* info, const char* name) {
1505
+ if (GI_IS_STRUCT_INFO(info)) {
1506
+ return gi_struct_info_find_field(reinterpret_cast<GIStructInfo*>(info), name);
1507
+ }
1508
+ if (GI_IS_UNION_INFO(info)) {
1509
+ GIUnionInfo* u = reinterpret_cast<GIUnionInfo*>(info);
1510
+ unsigned n = gi_union_info_get_n_fields(u);
1511
+ for (unsigned i = 0; i < n; i++) {
1512
+ GIFieldInfo* f = gi_union_info_get_field(u, i);
1513
+ if (f != nullptr &&
1514
+ g_strcmp0(gi_base_info_get_name(reinterpret_cast<GIBaseInfo*>(f)), name) == 0) {
1515
+ return f;
1516
+ }
1517
+ if (f != nullptr) gi_base_info_unref(f);
1518
+ }
1519
+ }
1520
+ return nullptr;
1521
+ }
1522
+
1523
+ // Whether `name` is a method on the struct/union (methods win over fields).
1524
+ static bool BoxedHasMethod(GIBaseInfo* info, const char* name) {
1525
+ GIFunctionInfo* m = nullptr;
1526
+ if (GI_IS_STRUCT_INFO(info)) {
1527
+ m = gi_struct_info_find_method(reinterpret_cast<GIStructInfo*>(info), name);
1528
+ } else if (GI_IS_UNION_INFO(info)) {
1529
+ m = gi_union_info_find_method(reinterpret_cast<GIUnionInfo*>(info), name);
1530
+ }
1531
+ if (m != nullptr) {
1532
+ gi_base_info_unref(m);
1533
+ return true;
1534
+ }
1535
+ return false;
1536
+ }
1537
+
1538
+ // Read the field's raw GIArgument. Uses gi_field_info_get_field for simple types;
1539
+ // falls back to a raw pointer read at the field offset for pointer-typed fields
1540
+ // (strings/arrays/pointer-to-interface) that the info API declines. Returns false
1541
+ // only for a genuinely unsupported field (a non-pointer composite the caller must
1542
+ // have already special-cased). BORROWS: the container owns the pointed-to memory.
1543
+ static bool ReadFieldArg(GIFieldInfo* field, gpointer base, GITypeInfo* ftype,
1544
+ GIArgument* arg) {
1545
+ memset(arg, 0, sizeof(*arg));
1546
+ if (gi_field_info_get_field(field, base, arg)) return true;
1547
+ GITypeTag tag = gi_type_info_get_tag(ftype);
1548
+ bool pointerish = gi_type_info_is_pointer(ftype) || tag == GI_TYPE_TAG_UTF8 ||
1549
+ tag == GI_TYPE_TAG_FILENAME || tag == GI_TYPE_TAG_ARRAY;
1550
+ if (pointerish) {
1551
+ size_t off = gi_field_info_get_offset(field);
1552
+ arg->v_pointer = *reinterpret_cast<gpointer*>(static_cast<char*>(base) + off);
1553
+ return true;
1554
+ }
1555
+ return false;
1556
+ }
1557
+
1558
+ // Marshal a field GIArgument to JS. ARRAY tags dispatch to GIArrayToJs (length
1559
+ // derived from zero-terminated / fixed-size — the common GStrv field case);
1560
+ // everything else through GIArgumentToJs. TRANSFER NOTHING throughout — a field
1561
+ // read is a borrow (the container keeps ownership), so the JS value is a copy and
1562
+ // no container/element memory is freed.
1563
+ static Napi::Value FieldArgToJs(Napi::Env env, GITypeInfo* ftype, GIArgument* arg) {
1564
+ if (gi_type_info_get_tag(ftype) == GI_TYPE_TAG_ARRAY) {
1565
+ return GIArrayToJs(env, ftype, arg, GI_TRANSFER_NOTHING, /*length=*/-1);
1566
+ }
1567
+ return GIArgumentToJs(env, ftype, arg, GI_TRANSFER_NOTHING);
1568
+ }
1569
+
1570
+ // Validate arg 0 as a boxed handle and return its record (throwing on mismatch).
1571
+ static BoxedHandle* UnwrapBoxedArg(const Napi::CallbackInfo& info) {
1572
+ Napi::Env env = info.Env();
1573
+ if (!info[0].IsExternal() ||
1574
+ !info[0].As<Napi::External<BoxedHandle>>().CheckTypeTag(&kBoxedHandleTag)) {
1575
+ Napi::TypeError::New(env, "expected a node-gi boxed handle").ThrowAsJavaScriptException();
1576
+ return nullptr;
1577
+ }
1578
+ BoxedHandle* bh = info[0].As<Napi::External<BoxedHandle>>().Data();
1579
+ if (bh == nullptr || bh->ptr == nullptr) {
1580
+ Napi::TypeError::New(env, "invalid boxed handle").ThrowAsJavaScriptException();
1581
+ return nullptr;
1582
+ }
1583
+ return bh;
1584
+ }
1585
+
1586
+ // boxedMemberKind(handle, name) → 0 (neither) | 1 (method) | 2 (field) | 3 (type
1587
+ // info unavailable — membership undecidable). Methods take priority (gjs
1588
+ // find_unique_js_field_name renames a colliding field to `_name`), so L1 wrapBoxed
1589
+ // checks this to route method-dispatch vs field access. The 3 case only arises for
1590
+ // a boxed handle whose GType is unregistered AND carries no static info (neither
1591
+ // gtype nor stored info resolves a struct/union): L1 keeps its method-dispatch
1592
+ // fallback there. When info IS resolvable, 0 is AUTHORITATIVE ("not a member"), so
1593
+ // L1 returns `undefined` for it — matching gjs, where `typeof boxed.noSuchName` is
1594
+ // `'undefined'` (a fabricated dispatcher would make it `'function'`, breaking JS
1595
+ // duck-typing like `typeof x.toArray === 'function'`).
1596
+ Napi::Value BoxedMemberKind(const Napi::CallbackInfo& info) {
1597
+ Napi::Env env = info.Env();
1598
+ if (info.Length() < 2 || !info[1].IsString()) {
1599
+ Napi::TypeError::New(env, "boxedMemberKind(handle, name: string)")
1600
+ .ThrowAsJavaScriptException();
1601
+ return env.Null();
1602
+ }
1603
+ BoxedHandle* bh = UnwrapBoxedArg(info);
1604
+ if (bh == nullptr) return env.Null();
1605
+ std::string name = info[1].As<Napi::String>().Utf8Value();
1606
+ GIBaseInfo* ti = DupBoxedTypeInfo(bh);
1607
+ int kind = 3; // no resolvable type info → membership undecidable (L1 keeps its fallback)
1608
+ if (ti != nullptr) {
1609
+ kind = 0; // info resolvable → 0 is authoritative ("not a member")
1610
+ if (BoxedHasMethod(ti, name.c_str())) {
1611
+ kind = 1;
1612
+ } else {
1613
+ GIFieldInfo* f = FindBoxedField(ti, name.c_str());
1614
+ if (f != nullptr) {
1615
+ kind = 2;
1616
+ gi_base_info_unref(f);
1617
+ }
1618
+ }
1619
+ gi_base_info_unref(ti);
1620
+ }
1621
+ return Napi::Number::New(env, kind);
1622
+ }
1623
+
1624
+ // getBoxedField(handle, name) → the field value (throws if not a field, or the
1625
+ // field is unreadable / unsupported — matching gjs's error-message shape).
1626
+ Napi::Value GetBoxedField(const Napi::CallbackInfo& info) {
1627
+ Napi::Env env = info.Env();
1628
+ if (info.Length() < 2 || !info[1].IsString()) {
1629
+ Napi::TypeError::New(env, "getBoxedField(handle, name: string)")
1630
+ .ThrowAsJavaScriptException();
1631
+ return env.Null();
1632
+ }
1633
+ BoxedHandle* bh = UnwrapBoxedArg(info);
1634
+ if (bh == nullptr) return env.Null();
1635
+ std::string name = info[1].As<Napi::String>().Utf8Value();
1636
+ GIBaseInfo* ti = DupBoxedTypeInfo(bh);
1637
+ if (ti == nullptr) {
1638
+ Napi::Error::New(env, "boxed handle has no introspection info for field access")
1639
+ .ThrowAsJavaScriptException();
1640
+ return env.Null();
1641
+ }
1642
+ std::string typeName = gi_base_info_get_name(ti) != nullptr ? gi_base_info_get_name(ti) : "?";
1643
+ GIFieldInfo* field = FindBoxedField(ti, name.c_str());
1644
+ if (field == nullptr) {
1645
+ gi_base_info_unref(ti);
1646
+ Napi::Error::New(env, std::string("no field '") + name + "' on " + typeName)
1647
+ .ThrowAsJavaScriptException();
1648
+ return env.Null();
1649
+ }
1650
+ Napi::Value result = env.Undefined();
1651
+ if (!(gi_field_info_get_flags(field) & GI_FIELD_IS_READABLE)) {
1652
+ Napi::Error::New(env, std::string("Reading field ") + typeName + "." + name + " is not supported")
1653
+ .ThrowAsJavaScriptException();
1654
+ } else {
1655
+ GITypeInfo* ftype = gi_field_info_get_type_info(field);
1656
+ // A nested BY-VALUE struct/union field: return a borrowing sub-handle into the
1657
+ // parent's memory at the field offset (mirrors gjs get_nested_interface_object).
1658
+ if (!gi_type_info_is_pointer(ftype) && gi_type_info_get_tag(ftype) == GI_TYPE_TAG_INTERFACE) {
1659
+ GIBaseInfo* iface = gi_type_info_get_interface(ftype);
1660
+ if (iface != nullptr && (GI_IS_STRUCT_INFO(iface) || GI_IS_UNION_INFO(iface))) {
1661
+ size_t off = gi_field_info_get_offset(field);
1662
+ GType nt = gi_registered_type_info_get_g_type(reinterpret_cast<GIRegisteredTypeInfo*>(iface));
1663
+ GType nested = (nt != G_TYPE_INVALID && nt != G_TYPE_NONE) ? nt : G_TYPE_INVALID;
1664
+ result = MakeBoxedHandle(env, static_cast<char*>(bh->ptr) + off, nested,
1665
+ /*owns=*/false, iface);
1666
+ gi_base_info_unref(iface);
1667
+ gi_base_info_unref(ftype);
1668
+ gi_base_info_unref(field);
1669
+ gi_base_info_unref(ti);
1670
+ return result;
1671
+ }
1672
+ if (iface != nullptr) gi_base_info_unref(iface);
1673
+ }
1674
+ GIArgument arg;
1675
+ if (ReadFieldArg(field, bh->ptr, ftype, &arg)) {
1676
+ result = FieldArgToJs(env, ftype, &arg);
1677
+ } else {
1678
+ Napi::Error::New(env, std::string("Reading field ") + typeName + "." + name + " is not supported")
1679
+ .ThrowAsJavaScriptException();
1680
+ }
1681
+ gi_base_info_unref(ftype);
1682
+ }
1683
+ gi_base_info_unref(field);
1684
+ gi_base_info_unref(ti);
1685
+ return result;
1686
+ }
1687
+
1688
+ // setBoxedField(handle, name, value) — writes a simple-typed field (throws if not
1689
+ // a field, the field is unwritable, or the type is unsupported — gjs message shape).
1690
+ Napi::Value SetBoxedField(const Napi::CallbackInfo& info) {
1691
+ Napi::Env env = info.Env();
1692
+ if (info.Length() < 3 || !info[1].IsString()) {
1693
+ Napi::TypeError::New(env, "setBoxedField(handle, name: string, value)")
1694
+ .ThrowAsJavaScriptException();
1695
+ return env.Null();
1696
+ }
1697
+ BoxedHandle* bh = UnwrapBoxedArg(info);
1698
+ if (bh == nullptr) return env.Null();
1699
+ std::string name = info[1].As<Napi::String>().Utf8Value();
1700
+ Napi::Value value = info[2];
1701
+ GIBaseInfo* ti = DupBoxedTypeInfo(bh);
1702
+ if (ti == nullptr) {
1703
+ Napi::Error::New(env, "boxed handle has no introspection info for field access")
1704
+ .ThrowAsJavaScriptException();
1705
+ return env.Null();
1706
+ }
1707
+ std::string typeName = gi_base_info_get_name(ti) != nullptr ? gi_base_info_get_name(ti) : "?";
1708
+ GIFieldInfo* field = FindBoxedField(ti, name.c_str());
1709
+ if (field == nullptr) {
1710
+ gi_base_info_unref(ti);
1711
+ Napi::Error::New(env, std::string("no field '") + name + "' on " + typeName)
1712
+ .ThrowAsJavaScriptException();
1713
+ return env.Null();
1714
+ }
1715
+ if (!(gi_field_info_get_flags(field) & GI_FIELD_IS_WRITABLE)) {
1716
+ gi_base_info_unref(field);
1717
+ gi_base_info_unref(ti);
1718
+ Napi::Error::New(env, std::string("Writing field ") + typeName + "." + name + " is not supported")
1719
+ .ThrowAsJavaScriptException();
1720
+ return env.Null();
1721
+ }
1722
+ GITypeInfo* ftype = gi_field_info_get_type_info(field);
1723
+ GIArgument arg;
1724
+ memset(&arg, 0, sizeof(arg));
1725
+ std::string heldString;
1726
+ if (JsToGIArgument(env, value, ftype, &arg, &heldString, GI_TRANSFER_NOTHING, nullptr)) {
1727
+ if (!gi_field_info_set_field(field, bh->ptr, &arg)) {
1728
+ Napi::Error::New(env, std::string("Writing field ") + typeName + "." + name + " is not supported")
1729
+ .ThrowAsJavaScriptException();
1730
+ }
1731
+ }
1732
+ gi_base_info_unref(ftype);
1733
+ gi_base_info_unref(field);
1734
+ gi_base_info_unref(ti);
1735
+ return env.Undefined();
1736
+ }
1737
+
1738
+ } // namespace nodegi