@gjsify/node-gi 0.49.0 → 0.51.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/node-gi",
3
- "version": "0.49.0",
3
+ "version": "0.51.0",
4
4
  "description": "GObject-Introspection runtime for Node.js — load gi:// namespaces (GLib/GObject/Gio/…) with GJS-compatible semantics. Vendored + rewritten from node-gtk under MIT.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -62,6 +62,7 @@
62
62
  "binding.gyp",
63
63
  "scripts/install.mjs",
64
64
  "scripts/win-gi-gyp-flags.mjs",
65
+ "scripts/mac-intl-gyp-flags.mjs",
65
66
  "prebuilds"
66
67
  ],
67
68
  "gjsify": {
@@ -87,23 +88,23 @@
87
88
  "build:prebuild": "node-gyp rebuild && node scripts/stage-prebuild.mjs",
88
89
  "rebuild": "node-gyp rebuild",
89
90
  "clean": "node-gyp clean",
90
- "test": "NODE_GI_NATIVE=build node --test",
91
- "test:node": "NODE_GI_NATIVE=build node --test",
92
- "test:gc": "NODE_GI_NATIVE=build node --test --expose-gc",
93
- "test:dbus": "dbus-run-session --config-file=test/session.conf -- env NODE_GI_NATIVE=build node --test --expose-gc test/dbus.test.mjs test/dbus-async.test.mjs",
91
+ "test": "LC_ALL=C NODE_GI_NATIVE=build node --test",
92
+ "test:node": "LC_ALL=C NODE_GI_NATIVE=build node --test",
93
+ "test:gc": "LC_ALL=C NODE_GI_NATIVE=build node --test --expose-gc",
94
+ "test:dbus": "dbus-run-session --config-file=test/session.conf -- env LC_ALL=C NODE_GI_NATIVE=build node --test --expose-gc test/dbus.test.mjs test/dbus-async.test.mjs",
94
95
  "test:bun": "node scripts/cross-runtime.mjs bun",
95
96
  "test:conformance": "node scripts/conformance.mjs",
96
97
  "test:conformance:gjs-napi": "node scripts/conformance.mjs --runtimes=gjs-napi",
97
98
  "test:gimarshalling": "node scripts/gimarshalling.mjs",
98
99
  "test:deno": "node scripts/cross-runtime.mjs deno",
99
- "test:cairo": "NODE_GI_NATIVE=build node --test test/cairo.test.mjs",
100
- "test:cairo-canvas2d": "NODE_GI_NATIVE=build node --test test/cairo-canvas2d.test.mjs",
101
- "test:gtk": "NODE_GI_NATIVE=build node --test test/gtk-smoke.test.mjs",
102
- "test:gtk-cairo": "NODE_GI_NATIVE=build node --test test/cairo-drawfunc.test.mjs",
103
- "test:adw": "NODE_GI_NATIVE=build node --test test/adw-smoke.test.mjs",
104
- "test:gtk-template": "NODE_GI_NATIVE=build node --test test/gtk-template.test.mjs",
105
- "test:gtk-template-callbacks": "NODE_GI_NATIVE=build node --test test/gtk-template-callbacks.test.mjs",
106
- "test:gtk-template-signals": "NODE_GI_NATIVE=build node --test test/gtk-template-signals.test.mjs"
100
+ "test:cairo": "LC_ALL=C NODE_GI_NATIVE=build node --test test/cairo.test.mjs",
101
+ "test:cairo-canvas2d": "LC_ALL=C NODE_GI_NATIVE=build node --test test/cairo-canvas2d.test.mjs",
102
+ "test:gtk": "LC_ALL=C NODE_GI_NATIVE=build node --test test/gtk-smoke.test.mjs",
103
+ "test:gtk-cairo": "LC_ALL=C NODE_GI_NATIVE=build node --test test/cairo-drawfunc.test.mjs",
104
+ "test:adw": "LC_ALL=C NODE_GI_NATIVE=build node --test test/adw-smoke.test.mjs",
105
+ "test:gtk-template": "LC_ALL=C NODE_GI_NATIVE=build node --test test/gtk-template.test.mjs",
106
+ "test:gtk-template-callbacks": "LC_ALL=C NODE_GI_NATIVE=build node --test test/gtk-template-callbacks.test.mjs",
107
+ "test:gtk-template-signals": "LC_ALL=C NODE_GI_NATIVE=build node --test test/gtk-template-signals.test.mjs"
107
108
  },
108
109
  "dependencies": {
109
110
  "node-addon-api": "^8.0.0"
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,70 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Emit the link flags the darwin addon needs for libintl, for binding.gyp's
3
+ // `OS=="mac"` branch.
4
+ //
5
+ // node scripts/mac-intl-gyp-flags.mjs -> "-L<dir> -lintl", or nothing
6
+ //
7
+ // WHY ONLY DARWIN. `bindtextdomain` / `textdomain` / `bind_textdomain_codeset` are
8
+ // GNU gettext. glibc absorbs them into libc, so Linux needs no flag at all
9
+ // (`nm -D libc.so.6` lists both as weak symbols); Windows resolves them out of
10
+ // gvsbuild's `intl.dll` through the `--static` pkg-config closure the Windows
11
+ // branch already walks. macOS's libSystem has NO gettext — Homebrew's `gettext`
12
+ // formula supplies `libintl.dylib`, and GLib links it, which is how `g_dgettext`
13
+ // works there. But a Mach-O is two-level-namespaced: the addon must name the
14
+ // library it takes symbols from, so "GLib already loaded it" is not enough at LINK
15
+ // time, only at run time.
16
+ //
17
+ // Emitting NOTHING when no libintl is found is deliberate: the failure then is a
18
+ // named undefined-symbol error at link, which says more than a guessed `-L` that
19
+ // silently points somewhere wrong.
20
+ import { existsSync } from 'node:fs';
21
+ import { execFileSync } from 'node:child_process';
22
+ import { join } from 'node:path';
23
+
24
+ if (process.platform !== 'darwin') process.exit(0);
25
+
26
+ function brewPrefix() {
27
+ try {
28
+ return execFileSync('brew', ['--prefix'], {
29
+ encoding: 'utf8',
30
+ stdio: ['ignore', 'pipe', 'ignore'],
31
+ }).trim();
32
+ } catch {
33
+ // No Homebrew on this host — the fixed paths below still cover the two
34
+ // standard prefixes, and MacPorts users get the honest link error.
35
+ return '';
36
+ }
37
+ }
38
+
39
+ function pkgConfigLibDirs() {
40
+ const pkgConfig = process.env.PKG_CONFIG || 'pkg-config';
41
+ try {
42
+ const out = execFileSync(pkgConfig, ['--libs-only-L', 'glib-2.0'], {
43
+ encoding: 'utf8',
44
+ stdio: ['ignore', 'pipe', 'ignore'],
45
+ });
46
+ return out
47
+ .split(/\s+/)
48
+ .filter((t) => t.startsWith('-L'))
49
+ .map((t) => t.slice(2));
50
+ } catch {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ // Homebrew symlinks every keg's lib/ into <prefix>/lib, so the gettext keg is
56
+ // reachable from the prefix even though glib's own .pc points at glib's cellar dir.
57
+ const prefix = brewPrefix();
58
+ const candidates = [
59
+ ...pkgConfigLibDirs(),
60
+ ...(prefix ? [join(prefix, 'lib')] : []),
61
+ '/opt/homebrew/lib',
62
+ '/usr/local/lib',
63
+ ];
64
+
65
+ for (const dir of candidates) {
66
+ if (existsSync(join(dir, 'libintl.dylib')) || existsSync(join(dir, 'libintl.a'))) {
67
+ process.stdout.write(`-L${dir} -lintl`);
68
+ break;
69
+ }
70
+ }
@@ -124,9 +124,22 @@ if (mode === '--includes') {
124
124
  // glib/gobject/gio/gmodule-2.0 g_*/GObject/GValue/GSignal/GClosure/Gio
125
125
  // ffi ffi_call / ffi_closure_alloc (girffi + the vfunc/callback trampolines)
126
126
  // cairo the native cairo foreign-struct binding (src/cairo.cc)
127
+ // intl bindtextdomain / textdomain / bind_textdomain_codeset (src/private.cc);
128
+ // glibc absorbs these into libc so Linux names nothing, but the MSVC CRT
129
+ // has no gettext and gvsbuild ships GNU gettext as `intl.dll` — the same
130
+ // library GLib itself links for g_dgettext
127
131
  // Pango/GdkPixbuf/Graphene are NOT linked — the conformance programs load their
128
132
  // typelibs at RUNTIME (girepository dlopens the DLL by soname), not at link time.
129
- const DIRECT_LIBS = ['girepository-2.0', 'gio-2.0', 'gobject-2.0', 'gmodule-2.0', 'glib-2.0', 'ffi', 'cairo'];
133
+ const DIRECT_LIBS = [
134
+ 'girepository-2.0',
135
+ 'gio-2.0',
136
+ 'gobject-2.0',
137
+ 'gmodule-2.0',
138
+ 'glib-2.0',
139
+ 'ffi',
140
+ 'cairo',
141
+ 'intl',
142
+ ];
130
143
  // Locate the import-lib dir: GTK_PREFIX/lib, else pkg-config's -L.
131
144
  const searchDirs = [];
132
145
  if (process.env.GTK_PREFIX) searchDirs.push(join(process.env.GTK_PREFIX, 'lib'));
@@ -146,10 +159,15 @@ if (mode === '--includes') {
146
159
  }
147
160
  if (found) break;
148
161
  }
162
+ // gmodule is optional (girepository may pull it internally). `intl` is too,
163
+ // and for a sharper reason: throwing here BLANKS the whole libraries list, so
164
+ // a gvsbuild that spells its gettext import lib differently would turn one
165
+ // missing library into "nothing links at all". Skipping leaves the linker to
166
+ // name the three undefined gettext symbols, which says what is actually wrong.
167
+ const OPTIONAL = new Set(['gmodule-2.0', 'intl']);
149
168
  if (found) resolved.push(found);
150
- else if (name !== 'gmodule-2.0') {
151
- // gmodule is optional (girepository may pull it internally); everything
152
- // else is required — surface a missing import lib loudly.
169
+ else if (!OPTIONAL.has(name)) {
170
+ // Everything else is required — surface a missing import lib loudly.
153
171
  throw new Error(`win-gi-gyp-flags: import lib for '${name}' not found under ${searchDirs.join(', ')}`);
154
172
  }
155
173
  }
package/src/addon.cc CHANGED
@@ -55,6 +55,15 @@ static void OnEnvShutdown(void* arg) {
55
55
  }
56
56
 
57
57
  static Napi::Object Init(Napi::Env env, Napi::Object exports) {
58
+ // FIRST, before any GLib call: adopt the locale the environment names, the way
59
+ // gjs's entry point does (`setlocale(LC_ALL, "")`, refs/gjs/gjs/console.cpp).
60
+ // This is the seam EVERY node-gi application crosses — `gi://`, `requireGi`, the
61
+ // globals shim and a bare `import '@gjsify/node-gi'` all load the addon — whereas
62
+ // the globals module is injected only when a bundle still references the GJS
63
+ // ambient globals, so a locale set there would miss every program that does not.
64
+ // Without it the process stays in the C locale, where GNU gettext refuses to
65
+ // translate; the block in private.cc records what that cost.
66
+ NodeGiInitProcessLocale();
58
67
  // The owner env + its JS/main thread are captured lazily at the first wrap
59
68
  // (EnsureDrainAsync) — NOT here, since Init runs once PER env and a per-env
60
69
  // overwrite would mis-identify the main thread under worker_threads. The cleanup
@@ -130,12 +139,20 @@ static Napi::Object Init(Napi::Env env, Napi::Object exports) {
130
139
  exports.Set("setTemplateCallbackResolver",
131
140
  Napi::Function::New(env, SetTemplateCallbackResolver));
132
141
  exports.Set("setConstructCallback", Napi::Function::New(env, SetConstructCallback));
142
+ exports.Set("setPropertySetCallback", Napi::Function::New(env, SetPropertySetCallback));
143
+ exports.Set("storedPropertyNames", Napi::Function::New(env, StoredPropertyNames));
133
144
  // GjsPrivate-mirroring helpers (private.cc): the structured-log writer func +
134
145
  // the bind_property_full / BindingGroup.bind_full transform trampolines.
135
146
  exports.Set("logSetWriterFunc", Napi::Function::New(env, LogSetWriterFunc));
136
147
  exports.Set("logSetWriterDefault", Napi::Function::New(env, LogSetWriterDefault));
137
148
  exports.Set("bindPropertyFull", Napi::Function::New(env, BindPropertyFull));
138
149
  exports.Set("bindingGroupBindFull", Napi::Function::New(env, BindingGroupBindFull));
150
+ // The libintl half of GjsPrivate: the binders GLib's GIR does not publish, and
151
+ // the LC_* constants read from this platform's headers.
152
+ exports.Set("setThreadLocale", Napi::Function::New(env, ApplyThreadLocale));
153
+ exports.Set("textdomain", Napi::Function::New(env, Textdomain));
154
+ exports.Set("bindtextdomain", Napi::Function::New(env, Bindtextdomain));
155
+ exports.Set("localeCategories", Napi::Function::New(env, LocaleCategories));
139
156
  // The native cairo binding + foreign-struct registration (the `__cairo` export).
140
157
  InitCairo(env, exports);
141
158
  return exports;
package/src/calls.cc CHANGED
@@ -513,6 +513,34 @@ int JsInArgCount(GICallableInfo* callable) {
513
513
  return count;
514
514
  }
515
515
 
516
+ // gjs's Gjs::Function::format_name (refs/gjs/gi/function.cpp:801) — the spelling its
517
+ // argument-count TypeError carries, so a refusal reads identically on both runtimes.
518
+ // Deliberately NOT the `displayName` the other refusals here use: that one names the
519
+ // RUNTIME type the call was made on (`GtkLabel.get_property`), which is the useful
520
+ // thing to say about a marshalling failure but not what gjs prints for arity. gjs
521
+ // names the DECLARING info — `method GObject.Object.get_property` — and consumers
522
+ // byte-compare these.
523
+ static std::string GjsCallableName(GICallableInfo* callable) {
524
+ GIBaseInfo* base = reinterpret_cast<GIBaseInfo*>(callable);
525
+ bool isMethod = gi_callable_info_is_method(callable);
526
+ std::string name = isMethod ? "method " : "function ";
527
+ const char* ns = gi_base_info_get_namespace(base);
528
+ if (ns != nullptr) name += ns;
529
+ name += '.';
530
+ if (isMethod) {
531
+ // Borrowed ref — no unref (same contract as the container read below).
532
+ GIBaseInfo* container = gi_base_info_get_container(base);
533
+ const char* containerName = container != nullptr ? gi_base_info_get_name(container) : nullptr;
534
+ if (containerName != nullptr) {
535
+ name += containerName;
536
+ name += '.';
537
+ }
538
+ }
539
+ const char* fn = gi_base_info_get_name(base);
540
+ if (fn != nullptr) name += fn;
541
+ return name;
542
+ }
543
+
516
544
  static Napi::Value InvokeFunctionInfo(Napi::Env env, GIFunctionInfo* func, gpointer instance,
517
545
  Napi::Array args, const std::string& displayName) {
518
546
  GICallableInfo* callable = reinterpret_cast<GICallableInfo*>(func);
@@ -574,6 +602,35 @@ static Napi::Value InvokeFunctionInfo(Napi::Env env, GIFunctionInfo* func, gpoin
574
602
  std::vector<bool> isLenArg(n_args, false);
575
603
  ComputeSkippedArgs(callable, n_args, &skip, &isLenArg);
576
604
 
605
+ // Too FEW arguments is a REFUSAL — gjs does the same at
606
+ // refs/gjs/gi/function.cpp:891 (JS::CallArgs::reportMoreArgsNeeded). node-gi used
607
+ // to read a missing argument as `undefined` and marshal THAT, which is not leniency
608
+ // but a wrong call: on a GValue parameter `undefined` becomes gjs's null guess, a
609
+ // G_TYPE_POINTER GValue (JsToFreshGValue), so `label.get_property('label')` — one
610
+ // of the two arguments gjs demands — printed "g_object_get_property: can't retrieve
611
+ // property 'label' of type 'gchararray' as value of type 'gpointer'" and evaluated
612
+ // to `undefined`, on stock and custom classes alike, with the set_property twin
613
+ // mirroring it. Counted off the pre-scan JUST computed rather than by calling
614
+ // JsInArgCount (which would redo it on every call, on the hottest path in the
615
+ // binding) — same walk, so what is demanded is by construction what the loop below
616
+ // consumes, and stays equal to the `Function.length` the method reports. Placed
617
+ // here, before the marshalling loop: nothing above has taken ownership of anything,
618
+ // so the early return needs no unwinding. Too MANY stays permitted — gjs only warns
619
+ // there, through a JS warning reporter node-gi has no equivalent of.
620
+ int required = 0;
621
+ for (unsigned int i = 0; i < n_args; i++) {
622
+ if (skip[i]) continue;
623
+ if (dirs[i] == GI_DIRECTION_IN || dirs[i] == GI_DIRECTION_INOUT) required++;
624
+ }
625
+ if (required > 0 && args.Length() < static_cast<uint32_t>(required)) {
626
+ Napi::TypeError::New(env, GjsCallableName(callable) + ": At least " +
627
+ std::to_string(required) + " argument" +
628
+ (required == 1 ? "" : "s") + " required, but only " +
629
+ std::to_string(args.Length()) + " passed")
630
+ .ThrowAsJavaScriptException();
631
+ return env.Undefined();
632
+ }
633
+
577
634
  // Caller-allocates OUT storage (fixed C array or boxed struct): blob != nullptr
578
635
  // marks the arg; boxedGType != 0 selects the boxed copy/free path on read-back.
579
636
  std::vector<gpointer> callerAllocBlob(n_args, nullptr);
package/src/class.cc CHANGED
@@ -299,6 +299,17 @@ static GQuark NodeGiInstancePropsQuark() {
299
299
  static GQuark q = g_quark_from_static_string("node-gi-instance-props");
300
300
  return q;
301
301
  }
302
+ // The ORDER in which this instance's custom properties were first set. A GHashTable
303
+ // has no order, and the construct-time replay is visible output: gjs runs the JS
304
+ // setters in the order g_object_new applied the values (CONSTRUCT properties, then
305
+ // the rest), so replaying in hash order prints the same setters in a different
306
+ // sequence. Recording what actually happened is cheaper than modelling GObject's
307
+ // application order, and it stays right for paths that do not go through
308
+ // g_object_new at all (GtkBuilder, a binding).
309
+ static GQuark NodeGiInstancePropOrderQuark() {
310
+ static GQuark q = g_quark_from_static_string("node-gi-instance-prop-order");
311
+ return q;
312
+ }
302
313
 
303
314
  static void FreeStoredGValue(gpointer p) {
304
315
  GValue* v = static_cast<GValue*>(p);
@@ -362,6 +373,72 @@ static void NodeGiGetProperty(GObject* obj, guint prop_id, GValue* value, GParam
362
373
  if (cd != nullptr && cd->parentGet != nullptr) cd->parentGet(obj, prop_id, value, pspec);
363
374
  }
364
375
 
376
+ // Push a just-set custom property through the class's own JS setter, the way gjs's
377
+ // set_property vfunc does (refs/gjs/gi/gobject.cpp gjs_object_set_gproperty →
378
+ // jsobj_set_gproperty → JS_SetProperty on the wrapper).
379
+ //
380
+ // THE GATE IS THE WRAPPER, AND THAT IS THE WHOLE DESIGN — do not replace it with a
381
+ // flag. gjs bails when `priv->wrapper()` is null and so do we, which means a set that
382
+ // lands DURING construction (g_object_new applying construct properties, before
383
+ // node-gi has built the wrapper) has nothing to call into and cannot run a setter
384
+ // early. That is not an accident to tidy up: running a plain READWRITE property's
385
+ // setter at construct time is what crashed Learn6502's SourceView (`selectable` →
386
+ // `_signalHandlers.forEach` against state the ctor body had not created yet). The
387
+ // construct-time values are replayed later, from the base ctor, once the wrapper
388
+ // exists — see gi.js flushPropertiesToJsSetters. A set that lands AFTER construction
389
+ // (GtkBuilder applying a non-construct template property via g_object_set, a
390
+ // property binding, set_property) is the case this branch exists for, and it is the
391
+ // one that left every Learn6502 tutorial code block empty on `--app node`.
392
+ static void MaybeRunJsPropertySetter(NodeGiClassData* cd, GObject* obj, const char* name) {
393
+ if (cd == nullptr || cd->env == nullptr) return;
394
+ napi_env env = cd->env;
395
+ // Never enter JS during GC / env teardown (same gate as the vfunc trampoline and
396
+ // RunJsConstructorForCObject); the property is already stored either way.
397
+ if (!NodeGiJsAvailable(env)) return;
398
+ Napi::Env napiEnv(env);
399
+ Napi::HandleScope scope(napiEnv);
400
+ NodeGiPumpJsDispatchScope pumpWindow;
401
+
402
+ Napi::Value handle = PeekGObjectHandle(napiEnv, obj);
403
+ if (handle.IsEmpty()) return; // construction has not reached the wrap yet
404
+
405
+ NodeGiEnvData* d = EnvData(env);
406
+ napi_value cb = nullptr;
407
+ if (d == nullptr || d->propertySetCallback == nullptr ||
408
+ napi_get_reference_value(env, d->propertySetCallback, &cb) != napi_ok || cb == nullptr) {
409
+ return; // L1 hasn't registered the callback (nothing to run)
410
+ }
411
+ napi_value nameVal = nullptr;
412
+ napi_create_string_utf8(env, name, NAPI_AUTO_LENGTH, &nameVal);
413
+ napi_value args[2] = {handle, nameVal};
414
+ napi_value undef = nullptr;
415
+ napi_get_undefined(env, &undef);
416
+ napi_value ret = nullptr;
417
+ // A plain call, not napi_make_callback: g_object_set may be running inside GTK's
418
+ // own frame and must not get a microtask checkpoint in the middle of it — the same
419
+ // reasoning as RunJsConstructorForCObject, and gjs's JS_SetProperty takes no
420
+ // checkpoint either.
421
+ if (napi_call_function(env, undef, cb, 2, args, &ret) != napi_ok) {
422
+ // The setter threw. A pending JS exception must never cross back into GObject
423
+ // (g_object_set has no way to report one), so fold it into a g_warning — gjs logs
424
+ // the uncaught exception at the same point (gjs_log_exception_uncaught).
425
+ napi_value ex = nullptr;
426
+ if (napi_get_and_clear_last_exception(env, &ex) == napi_ok && ex != nullptr) {
427
+ napi_value msg = nullptr;
428
+ std::string detail;
429
+ if (napi_get_named_property(env, ex, "message", &msg) == napi_ok) {
430
+ size_t len = 0;
431
+ if (napi_get_value_string_utf8(env, msg, nullptr, 0, &len) == napi_ok) {
432
+ detail.resize(len);
433
+ napi_get_value_string_utf8(env, msg, detail.data(), len + 1, &len);
434
+ }
435
+ }
436
+ g_warning("node-gi: JS setter for %s.%s threw: %s", g_type_name(G_OBJECT_TYPE(obj)), name,
437
+ detail.empty() ? "(no message)" : detail.c_str());
438
+ }
439
+ }
440
+ }
441
+
365
442
  static void NodeGiSetProperty(GObject* obj, guint prop_id, const GValue* value, GParamSpec* pspec) {
366
443
  NodeGiClassData* ownerCd =
367
444
  static_cast<NodeGiClassData*>(g_type_get_qdata(pspec->owner_type, NodeGiClassDataQuark()));
@@ -372,10 +449,26 @@ static void NodeGiSetProperty(GObject* obj, guint prop_id, const GValue* value,
372
449
  g_object_set_qdata_full(obj, NodeGiInstancePropsQuark(), store,
373
450
  reinterpret_cast<GDestroyNotify>(g_hash_table_destroy));
374
451
  }
452
+ // Record first-set order alongside the value (see NodeGiInstancePropOrderQuark).
453
+ // This is the ONLY writer of either structure, so they cannot drift.
454
+ if (!g_hash_table_contains(store, pspec->name)) {
455
+ GPtrArray* order =
456
+ static_cast<GPtrArray*>(g_object_get_qdata(obj, NodeGiInstancePropOrderQuark()));
457
+ if (order == nullptr) {
458
+ order = g_ptr_array_new_with_free_func(g_free);
459
+ g_object_set_qdata_full(obj, NodeGiInstancePropOrderQuark(), order,
460
+ reinterpret_cast<GDestroyNotify>(g_ptr_array_unref));
461
+ }
462
+ g_ptr_array_add(order, g_strdup(pspec->name));
463
+ }
375
464
  GValue* copy = g_new0(GValue, 1);
376
465
  g_value_init(copy, G_VALUE_TYPE(value));
377
466
  g_value_copy(value, copy);
378
467
  g_hash_table_replace(store, g_strdup(pspec->name), copy);
468
+ // Before the notify, so a ::notify handler observes the JS-side state the setter
469
+ // wrote — GObject emits its own notify after this vfunc returns, which is the
470
+ // order gjs's handlers see.
471
+ MaybeRunJsPropertySetter(ownerCd, obj, pspec->name);
379
472
  g_object_notify_by_pspec(obj, pspec);
380
473
  return;
381
474
  }
@@ -1502,4 +1595,46 @@ Napi::Value SetConstructCallback(const Napi::CallbackInfo& info) {
1502
1595
  return env.Undefined();
1503
1596
  }
1504
1597
 
1598
+ // setPropertySetCallback(cb) -> void. L1 registers the callback NodeGiSetProperty
1599
+ // invokes after storing a custom property: (instanceHandle, propertyName) → run the
1600
+ // class's own JS setter (see gi.js runJsPropertySetter). Mirrors
1601
+ // setConstructCallback.
1602
+ Napi::Value SetPropertySetCallback(const Napi::CallbackInfo& info) {
1603
+ Napi::Env env = info.Env();
1604
+ if (info.Length() < 1 || !info[0].IsFunction()) {
1605
+ Napi::TypeError::New(env, "setPropertySetCallback(cb: function)").ThrowAsJavaScriptException();
1606
+ return env.Undefined();
1607
+ }
1608
+ NodeGiEnvData* d = EnvData(env);
1609
+ if (d == nullptr) return env.Undefined();
1610
+ if (d->propertySetCallback != nullptr) {
1611
+ napi_delete_reference(env, d->propertySetCallback);
1612
+ d->propertySetCallback = nullptr;
1613
+ }
1614
+ napi_create_reference(env, info[0], 1, &d->propertySetCallback);
1615
+ return env.Undefined();
1616
+ }
1617
+
1618
+ // storedPropertyNames(handle) -> string[]. The custom properties that have actually
1619
+ // been SET on this instance, in the order they were first set — which is the only
1620
+ // honest record of it, since a property nobody set is absent (NodeGiGetProperty
1621
+ // answers such a read from the ParamSpec default instead). The base ctor reads it to
1622
+ // replay construct-time sets through the JS setters that did not exist yet, and
1623
+ // "actually set" is exactly the discriminator that keeps a declared-but-untouched
1624
+ // property's setter from firing early (the SourceView `selectable` crash). Order is
1625
+ // part of the contract, not a detail: the replay runs those setters, and gjs runs
1626
+ // them in the order g_object_new applied the values.
1627
+ Napi::Value StoredPropertyNames(const Napi::CallbackInfo& info) {
1628
+ Napi::Env env = info.Env();
1629
+ GObject* obj = UnwrapGObject(env, info[0]);
1630
+ if (obj == nullptr) return Napi::Array::New(env, 0);
1631
+ GPtrArray* order =
1632
+ static_cast<GPtrArray*>(g_object_get_qdata(obj, NodeGiInstancePropOrderQuark()));
1633
+ if (order == nullptr) return Napi::Array::New(env, 0);
1634
+ Napi::Array out = Napi::Array::New(env, order->len);
1635
+ for (guint i = 0; i < order->len; i++)
1636
+ out.Set(i, Napi::String::New(env, static_cast<const char*>(g_ptr_array_index(order, i))));
1637
+ return out;
1638
+ }
1639
+
1505
1640
  } // namespace nodegi
package/src/common.h CHANGED
@@ -95,6 +95,12 @@ struct NodeGiEnvData {
95
95
  // Reflect.construct(class) in adopt mode (see gi.js runCtorForCObject). Per-env
96
96
  // for the same reason as errorBuilder (a napi_ref is env-specific).
97
97
  napi_ref constructCallback = nullptr;
98
+ // L1 callback that pushes a just-set custom GObject property through the class's
99
+ // own JS setter — invoked by NodeGiSetProperty once the instance HAS a wrapper
100
+ // (handle, propertyName) → see gi.js runJsPropertySetter. gjs reaches the same
101
+ // setter from its set_property vfunc (refs/gjs/gi/gobject.cpp jsobj_set_gproperty).
102
+ // Per-env for the same reason as errorBuilder (a napi_ref is env-specific).
103
+ napi_ref propertySetCallback = nullptr;
98
104
  };
99
105
 
100
106
  void NodeGiEnvDataFinalize(napi_env env, void* data, void* hint);
@@ -458,6 +464,9 @@ void NodeGiToggleDebugLog(const char* fmt, ...) G_GNUC_PRINTF(1, 2);
458
464
 
459
465
  Napi::Value MakeGObjectHandle(Napi::Env env, GObject* obj);
460
466
  Napi::Value WrapGObject(Napi::Env env, GObject* obj, GITransfer transfer);
467
+ // The wrapper `obj` already has in `env`, or an empty value — never creates one. See
468
+ // toggle.cc; the caller that needs it is the set_property vfunc (class.cc).
469
+ Napi::Value PeekGObjectHandle(Napi::Env env, GObject* obj);
461
470
 
462
471
  extern int g_syncEmitDepth;
463
472
 
@@ -665,6 +674,8 @@ Napi::Value CallParentVfunc(const Napi::CallbackInfo& info);
665
674
  Napi::Value HasClassVfunc(const Napi::CallbackInfo& info);
666
675
  Napi::Value CallClassVfunc(const Napi::CallbackInfo& info);
667
676
  Napi::Value SetConstructCallback(const Napi::CallbackInfo& info);
677
+ Napi::Value SetPropertySetCallback(const Napi::CallbackInfo& info);
678
+ Napi::Value StoredPropertyNames(const Napi::CallbackInfo& info);
668
679
 
669
680
  // template.cc
670
681
  Napi::Value GetTemplateChild(const Napi::CallbackInfo& info);
@@ -699,6 +710,30 @@ Napi::Value LogSetWriterDefault(const Napi::CallbackInfo& info);
699
710
  Napi::Value BindPropertyFull(const Napi::CallbackInfo& info);
700
711
  Napi::Value BindingGroupBindFull(const Napi::CallbackInfo& info);
701
712
 
713
+ // private.cc — the locale + gettext binders (gjs_set_thread_locale /
714
+ // gjs_textdomain / gjs_bindtextdomain, and the LC_* constants GjsPrivate exports
715
+ // as GjsLocaleCategory). These need C because libintl is not introspectable:
716
+ // GLib publishes the LOOKUP half (g_dgettext/g_dngettext/g_dpgettext2) in its GIR
717
+ // and nothing else, so `bindtextdomain` has no `gi://` spelling on any runtime.
718
+ // NOT named SetThreadLocale, though that is what it mirrors: `<windows.h>` (pulled
719
+ // in by uv.h) defines SetThreadLocale as an object-like macro for the kernel32 API,
720
+ // which rewrote the token at the `Napi::Function::New(env, SetThreadLocale)` call
721
+ // site and failed the MSVC build with C2665 — the same collision the `#undef
722
+ // RegisterClass` above exists for. Renaming the C++ symbol keeps that undef list at
723
+ // one entry; `SetThreadLocaleImpl` is a distinct token and needs nothing.
724
+ Napi::Value ApplyThreadLocale(const Napi::CallbackInfo& info);
725
+ Napi::Value Textdomain(const Napi::CallbackInfo& info);
726
+ Napi::Value Bindtextdomain(const Napi::CallbackInfo& info);
727
+ // The LC_* values THIS platform's <locale.h> defines, as a JS object. Read from
728
+ // the headers rather than written down: the numbers differ per C library
729
+ // (LC_MESSAGES is 5 on glibc, 6 on darwin, and gettext's own 1729 on MSVC, which
730
+ // has no such category), so a literal table is correct on one platform and
731
+ // silently addresses the wrong category on every other one.
732
+ Napi::Value LocaleCategories(const Napi::CallbackInfo& info);
733
+ // Put the process in the locale the environment names — the `setlocale(LC_ALL,
734
+ // "")` gjs's entry point runs before anything else. Idempotent, process-wide.
735
+ void NodeGiInitProcessLocale();
736
+
702
737
  // loop.cc
703
738
  Napi::Value StartMainLoop(const Napi::CallbackInfo& info);
704
739
  Napi::Value IterateMainContext(const Napi::CallbackInfo& info);