@gjsify/node-gi 0.50.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/src/private.cc CHANGED
@@ -18,6 +18,9 @@
18
18
  // node-gi mirrors that architecture 1:1 so the JS-visible contract matches gjs
19
19
  // byte-for-byte (verified: scratch gold runs vs gjs 1.88 — see the test file).
20
20
 
21
+ #include <libintl.h> // bindtextdomain / bind_textdomain_codeset / textdomain
22
+ #include <locale.h> // setlocale + the LC_* categories (and, on POSIX, locale_t)
23
+
21
24
  #include <cstring>
22
25
 
23
26
  #include "common.h"
@@ -348,4 +351,230 @@ Napi::Value BindingGroupBindFull(const Napi::CallbackInfo& info) {
348
351
  return env.Undefined();
349
352
  }
350
353
 
354
+ // ---- locale + gettext ------------------------------------------------------
355
+ //
356
+ // THE DEFECT THIS CLOSES. A C program starts in the "C" locale, where GNU gettext
357
+ // returns the untranslated msgid whatever LANG says and whatever bindtextdomain was
358
+ // told. Node never leaves it and neither did node-gtk, so every `--app node`
359
+ // application shipped untranslated with its catalogs present and correctly bound;
360
+ // gjs's entry point opens with `setlocale(LC_ALL, "")` (refs/gjs/gjs/console.cpp),
361
+ // so the SAME source translated under `--app gjs` and not under `--app node`.
362
+ // Why it hid for so long, why LC_NUMERIC is taken along, and the divergences from
363
+ // gjs: docs/node-gi-gjs-surface.md § Locale and gettext.
364
+
365
+ // LC_MESSAGES is a POSIX category MSVC's <locale.h> does not define; GNU gettext
366
+ // answers for it anyway and its <libintl.h> picks 1729 for the constant. Mirror
367
+ // that rather than dropping the category, so the JS-visible enum has the same
368
+ // members everywhere.
369
+ #ifndef LC_MESSAGES
370
+ #define LC_MESSAGES 1729
371
+ #endif
372
+
373
+ void NodeGiInitProcessLocale() {
374
+ static std::once_flag once;
375
+ std::call_once(once, [] {
376
+ // gjs ignores the result here; we log at debug level instead of warning,
377
+ // because the failure is silent in a way that has already cost a release:
378
+ // a LANG naming a locale the host never generated leaves the process in C
379
+ // and the whole UI in English, with nothing printed anywhere.
380
+ if (setlocale(LC_ALL, "") == nullptr)
381
+ g_debug("node-gi: setlocale(LC_ALL, \"\") failed — the locale named by the "
382
+ "environment is not available on this host; messages stay untranslated");
383
+ });
384
+ }
385
+
386
+ // The per-thread locale gjs keeps behind GjsPrivate.set_thread_locale. The NAME
387
+ // cache is not an optimisation: gjs reports the locale through `setlocale(category,
388
+ // NULL)`, which answers for the GLOBAL locale and is therefore unchanged by the
389
+ // `uselocale` that did the work. Measured against gjs 1.88 with LC_ALL=de_DE.UTF-8:
390
+ // setting LC_MESSAGES to "C" stops translation immediately, yet the next query
391
+ // still answers "de_DE.UTF-8". Keeping the cache reproduces that answer exactly.
392
+ namespace {
393
+
394
+ struct ThreadLocale {
395
+ std::map<int, std::string> names; // last name set on this thread, per category
396
+ #ifndef _WIN32
397
+ locale_t id = static_cast<locale_t>(0);
398
+
399
+ // Cleanup beside creation: a worker thread that set a locale owns a locale_t
400
+ // nothing else can reach, so without this it leaks once per such thread.
401
+ // Uninstall BEFORE freeing — freelocale() on the locale still installed by
402
+ // uselocale() is undefined, and the thread is ending, so the global locale is
403
+ // the right thing to fall back to. gjs frees the same object from its GPrivate
404
+ // destructor for the same reason.
405
+ ~ThreadLocale() {
406
+ if (id == static_cast<locale_t>(0)) return;
407
+ uselocale(LC_GLOBAL_LOCALE);
408
+ freelocale(id);
409
+ id = static_cast<locale_t>(0);
410
+ }
411
+ #endif
412
+ };
413
+
414
+ thread_local ThreadLocale t_locale;
415
+
416
+ #ifndef _WIN32
417
+ int LocaleCategoryMask(int category) {
418
+ // The header tells you not to compute this as (1 << category).
419
+ switch (category) {
420
+ case LC_ALL: return LC_ALL_MASK;
421
+ case LC_COLLATE: return LC_COLLATE_MASK;
422
+ case LC_CTYPE: return LC_CTYPE_MASK;
423
+ case LC_MONETARY: return LC_MONETARY_MASK;
424
+ case LC_NUMERIC: return LC_NUMERIC_MASK;
425
+ case LC_TIME: return LC_TIME_MASK;
426
+ #ifdef LC_MESSAGES_MASK
427
+ case LC_MESSAGES: return LC_MESSAGES_MASK;
428
+ #endif
429
+ default: return 0;
430
+ }
431
+ }
432
+ #endif
433
+
434
+ // Returns the prior locale name for `category`, or nullptr when the locale could
435
+ // not be set — gjs's contract, and the JS layer turns nullptr into null.
436
+ const char* SetThreadLocaleImpl(int category, const char* name) {
437
+ auto reportCurrent = [category]() -> const char* {
438
+ auto it = t_locale.names.find(category);
439
+ if (it != t_locale.names.end()) return it->second.c_str();
440
+ return setlocale(category, nullptr);
441
+ };
442
+
443
+ if (name == nullptr) return reportCurrent(); // query form
444
+
445
+ const char* current = reportCurrent();
446
+ std::string prior = current == nullptr ? std::string() : std::string(current);
447
+
448
+ #ifdef _WIN32
449
+ // MSVC has no newlocale/uselocale. `_configthreadlocale` switches setlocale
450
+ // itself to per-thread, which is the same contract in a different spelling.
451
+ //
452
+ // LC_MESSAGES goes through the SAME call as every other category, deliberately.
453
+ // The MSVC CRT has no such category and answers NULL for it, and null is the
454
+ // honest report: the caller learns the category did not take. Special-casing it
455
+ // to "cache the name and skip the call" was worse than useless — it returned the
456
+ // PRIOR name for `setlocale(MESSAGES, 'no-such-locale.invalid')`, so a failure
457
+ // was indistinguishable from success. Messages still resolve on Windows, because
458
+ // GNU gettext reads them from the environment there rather than from the CRT
459
+ // locale; that is the divergence, and it is documented rather than papered over.
460
+ _configthreadlocale(_ENABLE_PER_THREAD_LOCALE);
461
+ if (setlocale(category, name) == nullptr) return nullptr;
462
+ #else
463
+ int mask = LocaleCategoryMask(category);
464
+ if (mask == 0) return nullptr;
465
+
466
+ locale_t base = uselocale(static_cast<locale_t>(0));
467
+ // duplocale() of LC_GLOBAL_LOCALE is the documented way to start from the
468
+ // global locale; newlocale() takes ownership of what it is handed.
469
+ locale_t seed = duplocale(base == LC_GLOBAL_LOCALE ? LC_GLOBAL_LOCALE : base);
470
+ if (seed == static_cast<locale_t>(0)) return nullptr;
471
+
472
+ locale_t composed = newlocale(mask, name, seed);
473
+ if (composed == static_cast<locale_t>(0)) {
474
+ freelocale(seed); // not consumed on failure
475
+ return nullptr;
476
+ }
477
+ if (uselocale(composed) == static_cast<locale_t>(0)) {
478
+ freelocale(composed);
479
+ return nullptr;
480
+ }
481
+ // Only now is the previous object unreferenced — freeing it while installed
482
+ // would leave every subsequent locale-sensitive call reading freed memory.
483
+ if (t_locale.id != static_cast<locale_t>(0)) freelocale(t_locale.id);
484
+ t_locale.id = composed;
485
+ #endif
486
+
487
+ t_locale.names[category] = prior; // gjs answers queries with the PRIOR name
488
+ // gjs returns prior_name, and the cache above now holds the same string — so
489
+ // hand back the cached copy rather than `prior`, which is about to go out of scope.
490
+ return t_locale.names[category].c_str();
491
+ }
492
+
493
+ } // namespace
494
+
495
+ // setThreadLocale(category: number, locale: string | null) -> string | null
496
+ Napi::Value ApplyThreadLocale(const Napi::CallbackInfo& info) {
497
+ Napi::Env env = info.Env();
498
+ if (info.Length() < 1 || !info[0].IsNumber()) {
499
+ Napi::TypeError::New(env, "setThreadLocale: expected (category: number, locale: string|null)")
500
+ .ThrowAsJavaScriptException();
501
+ return env.Null();
502
+ }
503
+ int category = info[0].As<Napi::Number>().Int32Value();
504
+ std::string name;
505
+ bool query = info.Length() < 2 || info[1].IsNull() || info[1].IsUndefined();
506
+ if (!query) {
507
+ if (!info[1].IsString()) {
508
+ Napi::TypeError::New(env, "setThreadLocale: locale must be a string or null")
509
+ .ThrowAsJavaScriptException();
510
+ return env.Null();
511
+ }
512
+ name = info[1].As<Napi::String>().Utf8Value();
513
+ }
514
+ const char* result = SetThreadLocaleImpl(category, query ? nullptr : name.c_str());
515
+ if (result == nullptr) return env.Null();
516
+ return Napi::String::New(env, result);
517
+ }
518
+
519
+ // textdomain(domain: string | null) -> string | null
520
+ Napi::Value Textdomain(const Napi::CallbackInfo& info) {
521
+ Napi::Env env = info.Env();
522
+ bool query = info.Length() < 1 || info[0].IsNull() || info[0].IsUndefined();
523
+ std::string domain;
524
+ if (!query) {
525
+ if (!info[0].IsString()) {
526
+ Napi::TypeError::New(env, "textdomain: expected a string or null")
527
+ .ThrowAsJavaScriptException();
528
+ return env.Null();
529
+ }
530
+ domain = info[0].As<Napi::String>().Utf8Value();
531
+ }
532
+ const char* result = textdomain(query ? nullptr : domain.c_str());
533
+ if (result == nullptr) return env.Null();
534
+ return Napi::String::New(env, result);
535
+ }
536
+
537
+ // bindtextdomain(domain: string, location: string | null) -> string | null
538
+ //
539
+ // The codeset is pinned to UTF-8 exactly as gjs_bindtextdomain does it: JS strings
540
+ // are UTF-16 and every marshalling path here assumes UTF-8 on the C side, so a
541
+ // catalog delivered in the locale's legacy charset would arrive as mojibake.
542
+ Napi::Value Bindtextdomain(const Napi::CallbackInfo& info) {
543
+ Napi::Env env = info.Env();
544
+ if (info.Length() < 1 || !info[0].IsString()) {
545
+ Napi::TypeError::New(env, "bindtextdomain: expected (domain: string, location: string|null)")
546
+ .ThrowAsJavaScriptException();
547
+ return env.Null();
548
+ }
549
+ std::string domain = info[0].As<Napi::String>().Utf8Value();
550
+ std::string location;
551
+ bool query = info.Length() < 2 || info[1].IsNull() || info[1].IsUndefined();
552
+ if (!query) {
553
+ if (!info[1].IsString()) {
554
+ Napi::TypeError::New(env, "bindtextdomain: location must be a string or null")
555
+ .ThrowAsJavaScriptException();
556
+ return env.Null();
557
+ }
558
+ location = info[1].As<Napi::String>().Utf8Value();
559
+ }
560
+ const char* result = bindtextdomain(domain.c_str(), query ? nullptr : location.c_str());
561
+ if (!query) bind_textdomain_codeset(domain.c_str(), "UTF-8");
562
+ if (result == nullptr) return env.Null();
563
+ return Napi::String::New(env, result);
564
+ }
565
+
566
+ // localeCategories() -> { ALL, COLLATE, CTYPE, MESSAGES, MONETARY, NUMERIC, TIME }
567
+ Napi::Value LocaleCategories(const Napi::CallbackInfo& info) {
568
+ Napi::Env env = info.Env();
569
+ Napi::Object out = Napi::Object::New(env);
570
+ out.Set("ALL", Napi::Number::New(env, LC_ALL));
571
+ out.Set("COLLATE", Napi::Number::New(env, LC_COLLATE));
572
+ out.Set("CTYPE", Napi::Number::New(env, LC_CTYPE));
573
+ out.Set("MESSAGES", Napi::Number::New(env, LC_MESSAGES));
574
+ out.Set("MONETARY", Napi::Number::New(env, LC_MONETARY));
575
+ out.Set("NUMERIC", Napi::Number::New(env, LC_NUMERIC));
576
+ out.Set("TIME", Napi::Number::New(env, LC_TIME));
577
+ return out;
578
+ }
579
+
351
580
  } // namespace nodegi
package/src/repo.cc CHANGED
@@ -19,6 +19,7 @@ void NodeGiEnvDataFinalize(napi_env env, void* data, void* /*hint*/) {
19
19
  if (d->cairoWrappers != nullptr) napi_delete_reference(env, d->cairoWrappers);
20
20
  if (d->microtaskDrain != nullptr) napi_delete_reference(env, d->microtaskDrain);
21
21
  if (d->constructCallback != nullptr) napi_delete_reference(env, d->constructCallback);
22
+ if (d->propertySetCallback != nullptr) napi_delete_reference(env, d->propertySetCallback);
22
23
  delete d;
23
24
  }
24
25
 
package/src/toggle.cc CHANGED
@@ -807,6 +807,23 @@ Napi::Value WrapGObject(Napi::Env env, GObject* obj, GITransfer transfer) {
807
807
  return MakeGObjectHandle(env, obj);
808
808
  }
809
809
 
810
+ // The canonical wrapper `obj` ALREADY has in this env, or an empty value when it has
811
+ // none. Never creates one and never touches a refcount — the whole point is to ask
812
+ // "is there a JS object to call into yet?" without bringing one into being, which is
813
+ // gjs's `priv->wrapper()` test (refs/gjs/gi/gobject.cpp gjs_object_set_gproperty
814
+ // bails when it is null). Same cross-env gate as WrapGObject's cache hit: a napi_ref
815
+ // belongs to inst->env and dereferencing it from another env is a UAF.
816
+ Napi::Value PeekGObjectHandle(Napi::Env env, GObject* obj) {
817
+ if (obj == nullptr) return Napi::Value();
818
+ NodeGiInstance* inst =
819
+ static_cast<NodeGiInstance*>(g_object_get_qdata(obj, NodeGiWrapperQuark()));
820
+ if (inst == nullptr || inst->env != static_cast<napi_env>(env)) return Napi::Value();
821
+ napi_value cached = nullptr;
822
+ if (napi_get_reference_value(env, inst->handle_ref, &cached) != napi_ok || cached == nullptr)
823
+ return Napi::Value(); // collected, teardown pending — there is nothing live to call
824
+ return Napi::Value(env, cached);
825
+ }
826
+
810
827
  // ============================ TEST-ONLY ===============================
811
828
  // __stressRefUnrefOffThread(handle, iterations) — the GLib-thread vehicle the
812
829
  // cross-thread GC stress test needs (there is no headless JS way to make another