@gjsify/node-gi 0.51.0 → 0.52.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/gi.js CHANGED
@@ -18,6 +18,9 @@ import { setImmediate, setInterval, clearInterval } from 'node:timers';
18
18
  // loop-liveness hooks below would silently never fire.
19
19
  import runtimeProcess from 'node:process';
20
20
  import { createGioDBus } from './overrides/gio-dbus.js';
21
+ // The windowing loader's record of what it wrote into process.env. Read here and not there
22
+ // because replaying it needs a GLib (mirrorWindowingEnvIntoCrt below).
23
+ import { windowingEnvWrites } from './gtk-runtime.js';
21
24
 
22
25
  // The raw native GObject handle on a wrapped instance, so it can be unwrapped again
23
26
  // when passed back into the engine as a GI argument.
@@ -2682,6 +2685,45 @@ function namespaceObject(namespace) {
2682
2685
  // startMainLoop is itself idempotent; this just avoids the extra call.
2683
2686
  let loopAttached = false;
2684
2687
 
2688
+ // Whether the windowing loader's win32 env writes have been copied into the C runtime's
2689
+ // environment yet. Set BEFORE the copy runs, because the copy calls requireGi() again.
2690
+ let windowingEnvMirrored = false;
2691
+
2692
+ /**
2693
+ * Give the bundled GTK's `getenv()` readers the variables the loader wrote, on win32.
2694
+ *
2695
+ * THE VARIABLE WAS SET AND UNREACHABLE, which reads in a log exactly like a platform
2696
+ * declining a request — and was read that way once (#1668). A Windows process carries
2697
+ * two environments: the Win32 block, which `GetEnvironmentVariableW()`, `g_getenv()`, the DLL
2698
+ * loader and every child process read, and the C runtime's own copy, which is what `getenv()`
2699
+ * returns. Node's `process.env` setter is `uv_os_setenv()` → `SetEnvironmentVariableW()` and
2700
+ * nothing else, so it writes the first and not the second. `pango_cairo_font_map_new()` reads
2701
+ * `getenv("PANGOCAIRO_BACKEND")` and fontconfig reads `getenv("FONTCONFIG_FILE")`: both saw
2702
+ * an empty environment while `process.env` reported the values back correctly.
2703
+ *
2704
+ * `g_setenv()` is the repair because glib already solved this — it calls `_wputenv()` first
2705
+ * and `SetEnvironmentVariableW()` second, and says why in its own source. Called through the
2706
+ * bundle's OWN GLib, so the runtime copy written is the one pango and fontconfig link against.
2707
+ * `overwrite` is true and that is not a policy decision: every value replayed came from
2708
+ * `setIfUnset`, so a value the operator set is already the value being written back.
2709
+ *
2710
+ * TIMING is why this is not in the loader: both readers are lazy — pango at the first font
2711
+ * map, fontconfig at the first config load — and neither can fire before a namespace is
2712
+ * required, while GLib cannot be called before the addon is loaded. The first `requireGi()` is
2713
+ * the one moment after the second and before the first.
2714
+ *
2715
+ * Strict no-op off win32 (POSIX `setenv()` is the only environment there, and Node uses it)
2716
+ * and when the windowing wiring made no writes. Held by `test/font-script-coverage.test.mjs`,
2717
+ * which measures the loader-set value against the same value put in the LAUNCH environment.
2718
+ * @returns {void}
2719
+ */
2720
+ function mirrorWindowingEnvIntoCrt() {
2721
+ const writes = windowingEnvWrites();
2722
+ if (writes.length === 0) return;
2723
+ const GLib = requireGi('GLib', '2.0');
2724
+ for (const [name, value] of writes) GLib.setenv(name, value, true);
2725
+ }
2726
+
2685
2727
  /**
2686
2728
  * Require a GObject-Introspection namespace and return a GJS-shaped namespace
2687
2729
  * object. The Node twin of `import Ns from 'gi://Ns?version=X'` /
@@ -2692,6 +2734,13 @@ let loopAttached = false;
2692
2734
  */
2693
2735
  export function requireGi(namespace, version) {
2694
2736
  native.requireNamespace(namespace, version);
2737
+ // The one moment the win32 CRT-environment mirror can run: the addon is loaded, so there
2738
+ // is a GLib to call, and nothing has built a font map or loaded a fontconfig
2739
+ // configuration yet. Flag first — mirrorWindowingEnvIntoCrt() re-enters here for GLib.
2740
+ if (!windowingEnvMirrored) {
2741
+ windowingEnvMirrored = true;
2742
+ mirrorWindowingEnvIntoCrt();
2743
+ }
2695
2744
  // Attach the libuv↔GLib integration once. On Node this arms BOTH directions:
2696
2745
  // the uv-in-GLib bridge (a blocking GLib.MainLoop.run / Gio.Application.run
2697
2746
  // keeps Node's event loop alive) AND the uv-driven auto-pump (pending GLib
package/gtk-runtime.js CHANGED
@@ -25,6 +25,31 @@ const here = dirname(fileURLToPath(import.meta.url)); // package root
25
25
  // Sentinel: set on the re-exec so a bundle-activated child never re-execs again.
26
26
  const REEXEC_SENTINEL = 'GJSIFY_GTK_REEXEC';
27
27
 
28
+ /** @type {Array<[string, string]>} every write maybeWireGtkWindowingEnv() made, in order. */
29
+ let windowingEnvWritesMade = [];
30
+
31
+ /**
32
+ * What `maybeWireGtkWindowingEnv()` wrote into `process.env`, as `[name, value]` pairs.
33
+ *
34
+ * A WINDOWS PROCESS HAS TWO ENVIRONMENTS and a JS `process.env` write reaches one of them.
35
+ * Node's setter is `uv_os_setenv()`, which is `SetEnvironmentVariableW()` and nothing else:
36
+ * that updates the Win32 environment block — what `GetEnvironmentVariableW()`, `g_getenv()`,
37
+ * the DLL loader and every child process read — and never the C runtime's own copy, which is
38
+ * what `getenv()` returns. Libraries in the bundle use BOTH: GLib, GIO, gdk-pixbuf and GTK go
39
+ * through `g_getenv()` and saw every variable all along, while `pango_cairo_font_map_new()`
40
+ * and fontconfig's config lookup call `getenv()` and saw none of them. That is measured
41
+ * rather than reasoned: see the PANGOCAIRO_BACKEND note in `maybeWireGtkWindowingEnv()`.
42
+ *
43
+ * The repair is `g_setenv()`, which glib implements as `_wputenv()` + `SetEnvironmentVariableW()`
44
+ * for exactly this reason — so it needs a loaded GLib, which does not exist when the loader
45
+ * runs. `mirrorWindowingEnvIntoCrt()` in `gi.js` replays this list on the first `requireGi()`.
46
+ * Empty off win32 (POSIX `setenv()` has one environment) and empty when the wiring no-opped.
47
+ * @returns {Array<[string, string]>}
48
+ */
49
+ export function windowingEnvWrites() {
50
+ return process.platform === 'win32' ? windowingEnvWritesMade.slice() : [];
51
+ }
52
+
28
53
  /**
29
54
  * Resolve the GTK runtime bundle directory for this platform, or `null`. A valid
30
55
  * bundle dir contains the native-code dir (`lib/` relocated dylibs on darwin,
@@ -362,7 +387,8 @@ export function maybePrependGtkRuntimeDllPath() {
362
387
  * finds its runtime DATA on Windows: compiled GSettings schemas
363
388
  * (`GSETTINGS_SCHEMA_DIR`), the gdk-pixbuf image loaders (`GDK_PIXBUF_MODULEDIR` +
364
389
  * `GDK_PIXBUF_MODULE_FILE`), the icon themes (`XDG_DATA_DIRS` → `<bundle>/share`),
365
- * and — when bundled — Fontconfig (`FONTCONFIG_PATH`/`FONTCONFIG_FILE`). These live
390
+ * and — when bundled — Fontconfig (`FONTCONFIG_PATH`/`FONTCONFIG_FILE`) plus the Pango
391
+ * backend that actually READS it (`PANGOCAIRO_BACKEND`). These live
366
392
  * beside the DLLs the display-free path already wires (maybePrependGtkRuntimeDllPath).
367
393
  *
368
394
  * STRICT no-op off win32, without a bundle, or when the bundle carries NO windowing
@@ -370,7 +396,12 @@ export function maybePrependGtkRuntimeDllPath() {
370
396
  * --windowing build produces, so the DEFAULT display-free bundle is byte-unchanged.
371
397
  * Windows re-reads these vars at first use (schema/loader/icon-theme init runs AFTER
372
398
  * the addon loads), so an in-process mutation here suffices — no re-exec, the DLL-
373
- * search analog. Each var is set only when currently unset (a host override wins).
399
+ * search analog. TRUE OF EVERY `g_getenv()` READER, and only of those: on win32 an
400
+ * in-process write lands in the WIN32 environment block alone, which is where
401
+ * `g_getenv()` looks and where `getenv()` does not. What every write here is recorded
402
+ * in `windowingEnvWrites()` for is the repair of that second half — see the
403
+ * `PANGOCAIRO_BACKEND` note below and `mirrorWindowingEnvIntoCrt()` in `gi.js`.
404
+ * Each var is set only when currently unset (a host override wins).
374
405
  * Idempotent; MUST run at module top-level with maybePrependGtkRuntimeDllPath, before
375
406
  * the app initializes GTK. GLib uses `;` as the win32 search-path separator.
376
407
  * @returns {void}
@@ -393,8 +424,17 @@ export function maybeWireGtkWindowingEnv() {
393
424
  if (!existsSync(join(schemaDir, 'gschemas.compiled'))) return;
394
425
 
395
426
  const pathSep = process.platform === 'win32' ? ';' : ':';
427
+ // Recorded, not just written. On win32 `process.env.X = v` reaches ONE of the two
428
+ // environments this process has (see `windowingEnvWrites` and the PANGOCAIRO_BACKEND
429
+ // note below), and the mirror that reaches the other one needs a GLib, which does not
430
+ // exist yet at module top level. Reset per call so the function stays idempotent.
431
+ windowingEnvWritesMade = [];
432
+ const write = (name, value) => {
433
+ process.env[name] = value;
434
+ windowingEnvWritesMade.push([name, value]);
435
+ };
396
436
  const setIfUnset = (name, value) => {
397
- if (!process.env[name]) process.env[name] = value;
437
+ if (!process.env[name]) write(name, value);
398
438
  };
399
439
  setIfUnset('GSETTINGS_SCHEMA_DIR', schemaDir);
400
440
 
@@ -427,7 +467,7 @@ export function maybeWireGtkWindowingEnv() {
427
467
  if (existsSync(shareDir)) {
428
468
  const cur = process.env.XDG_DATA_DIRS ?? '';
429
469
  if (!cur.split(pathSep).filter(Boolean).includes(shareDir)) {
430
- process.env.XDG_DATA_DIRS = cur ? `${shareDir}${pathSep}${cur}` : shareDir;
470
+ write('XDG_DATA_DIRS', cur ? `${shareDir}${pathSep}${cur}` : shareDir);
431
471
  }
432
472
  }
433
473
 
@@ -437,18 +477,92 @@ export function maybeWireGtkWindowingEnv() {
437
477
  setIfUnset('FONTCONFIG_FILE', fontsConf);
438
478
  }
439
479
 
440
- // THE FACES, which are a different question from the two lines above and on one of the
441
- // two platforms not answerable by an environment variable at all.
480
+ // WHICH FONT MAP PANGO BUILDS AT ALL, which is upstream of the two lines above and is the
481
+ // assumption neither of them stated. `pangocairo-fontmap.c` picks the FIRST backend
482
+ // COMPILED IN, in the order coretext → win32 → fc, and `PANGOCAIRO_BACKEND` is the only
483
+ // thing that overrides it. Both bundles carry every backend their platform builds, so
484
+ // without this line darwin gets CoreText and win32 gets pangowin32/DirectWrite — and
485
+ // fontconfig, which everything else here configures, drives nothing.
486
+ //
487
+ // MEASURED 2026-09-12 on real hardware — Learn6502 0.8.0 on the published windowing
488
+ // bundle, macOS 15.7.9 and Windows 11, with the variable exported BY HAND into the
489
+ // process before launch (nothing in the loader set it then — this line is what changed
490
+ // that, and the distinction turns out to be the whole story on win32):
491
+ //
492
+ // Tamil தமிழ் default backend: TOFU PANGOCAIRO_BACKEND=fc: renders
493
+ // Japanese 日本語 default backend: renders PANGOCAIRO_BACKEND=fc: renders
494
+ //
495
+ // JAPANESE IS NOT A TEST — it came out right in the SAME window of the SAME run in which
496
+ // Tamil was empty boxes, because both platform maps carry a CJK fallback. What neither
497
+ // reaches from a GTK process are the Indic faces the OS itself installs (`Tamil Sangam
498
+ // MN.ttc`, `Nirmala.ttf` — present on both machines, and found immediately by fontconfig).
499
+ // So anything checking this must use a script the DEFAULT backend genuinely misses; Tamil
500
+ // is the one that was measured, and `test/font-script-coverage.test.mjs` carries its own
501
+ // negative control rather than trusting that it still is.
502
+ //
503
+ // It was NOT the translation (the UI switched language, only the glyphs were missing), not
504
+ // a broken fallback chain, not a missing face and not missing configuration: the win32
505
+ // bundle already ships `etc/fonts/fonts.conf` and the two lines above already point at it.
506
+ // The configuration was being read by nobody.
507
+ //
508
+ // ON WIN32 THIS WRITE ALONE DOES NOT REACH PANGO, and the first reading of that is kept
509
+ // here because it is the kind of wrong answer that gets re-derived. CI run 34873488108
510
+ // measured `PANGOCAIRO_BACKEND=fc` in `process.env` and a `PangoCairoWin32FontMap` anyway,
511
+ // and that was written up as "gvsbuild's pango has no fontconfig backend to select". It has
512
+ // one. `bin/pangocairo-1.0-0.dll` out of GTK4_Gvsbuild_2026.6.0_x64.zip registers
513
+ // `PangoCairoFcFontMap` as well as `PangoCairoWin32FontMap`, imports `pangoft2-1.0-0.dll`
514
+ // and `fontconfig-1.dll`, and carries ` win32 fontconfig` — the string pango prints for an
515
+ // unknown value — beside the three literals it compares against: `win32`, `fc`,
516
+ // `fontconfig`. Two things hid that: a `PangoCairo[A-Za-z]*FontMap` regex cannot match the
517
+ // digits in `Win32`, and `strings` defaults to a 4-character minimum, one more than `fc`
518
+ // has. An invalid value is also LOUD (`Unknown $PANGOCAIRO_BACKEND value` + a NULL map),
519
+ // and the run printed none — which was the evidence against the first reading all along.
520
+ //
521
+ // WHAT IS ACTUALLY WRONG IS THE REACH OF THE WRITE. `pango_cairo_font_map_new()` reads
522
+ // plain `getenv()`, not `g_getenv()`. On win32 Node's `process.env` setter is
523
+ // `uv_os_setenv()`, which is `SetEnvironmentVariableW()` and nothing else — the WIN32
524
+ // environment block, never the C runtime's own copy, and `getenv()` returns the copy. So
525
+ // `process.env.PANGOCAIRO_BACKEND` reads back `fc` (Node reads it through
526
+ // `GetEnvironmentVariableW`), every CHILD process inherits it, every `g_getenv()` reader on
527
+ // this list sees it — and pango in THIS process sees nothing, takes the `!backend` branch
528
+ // and builds the platform map. glib documents the split in `g_setenv()` itself and repairs
529
+ // it by calling `_wputenv()` first and `SetEnvironmentVariableW()` second; that is the
530
+ // repair here too, and it needs a GLib, which does not exist at module top level. So it
531
+ // happens on the first `requireGi()` — `mirrorWindowingEnvIntoCrt()` in `gi.js`, over
532
+ // exactly the writes recorded above. This write stays: it is what the mirror copies, what a
533
+ // child inherits, and what every GLib reader already uses.
534
+ //
535
+ // NOTHING ABOVE CONTRADICTS THE 2026-09-12 WINDOWS MEASUREMENT — both readings are kept
536
+ // because together they name which half is which. There the variable was in the process at
537
+ // LAUNCH, so the C runtime had it before any code of ours ran, and Tamil rendered. The
538
+ // gvsbuild pin was already 2026.6.0 on that date, so those are the same DLLs CI builds
539
+ // against: same backend, same value, different reach.
540
+ //
541
+ // `setIfUnset`, because an operator who pins `coretext`/`win32` has to win: the price of
542
+ // this line is a different RASTERISER (FreeType instead of ClearType/CoreText), which is a
543
+ // preference, while a glyph that never arrives is a defect. No platform branch is needed —
544
+ // the function has already returned on anything but darwin/win32 — and it is scoped to the
545
+ // BUNDLE deliberately: fontconfig is a safe choice only where something hands it both a
546
+ // configuration and faces, which is exactly what a windowing bundle does and what an
547
+ // arbitrary host's Pango does not owe us.
548
+ setIfUnset('PANGOCAIRO_BACKEND', 'fc');
549
+
550
+ // THE FACES, which are a different question from the three settings above and on the
551
+ // platform font maps not answerable by an environment variable at all.
442
552
  //
443
- // `XDG_DATA_DIRS` already reaches `<bundle>/share/fonts` wherever fontconfig drives
444
- // Pango — its stock configuration carries `<dir prefix="xdg">fonts</dir>` — so on darwin
445
- // the set above is enough and this variable is a second, cheaper route to the same files.
446
- // On WIN32 it is the only route there is: GTK4 uses pangowin32, whose font map is filled
447
- // exclusively by `pango_font_map_dwrite_populate()` from the DirectWrite system
448
- // collection, and a `FONTCONFIG_FILE` naming a directory of faces moves that map by ZERO
449
- // families even when it is the only configuration present (measured on Windows 11 /
450
- // GTK 4.22.4, both directions — ADR 0038 § W1-W5). The face has to be handed to the map
451
- // through `add_font_file`, which is a RUNTIME call somebody has to make.
553
+ // `XDG_DATA_DIRS` reaches `<bundle>/share/fonts` wherever fontconfig drives Pango — its
554
+ // stock configuration carries `<dir prefix="xdg">fonts</dir>` — which is true only BECAUSE
555
+ // of the backend selection above. This comment used to read "so on darwin the set above is
556
+ // enough", and that sentence WAS the defect: nothing made fontconfig the darwin backend, so
557
+ // the directory was being named to a reader nobody had asked.
558
+ // It stays the route for any process that ends up on a PLATFORM map — an operator pinning
559
+ // `win32`/`coretext`, a host GTK, a pango built without the fontconfig backend. GTK4 on
560
+ // pangowin32 has a font map filled exclusively by `pango_font_map_dwrite_populate()` from
561
+ // the DirectWrite system collection, so a `FONTCONFIG_FILE` naming a directory of faces
562
+ // moves THAT map by ZERO families however it is set (measured on Windows 11 / GTK 4.22.4,
563
+ // both directions — ADR 0038 § W1-W5). The face has to be handed to the map through
564
+ // `add_font_file`, which is a RUNTIME call somebody has to make — so `initFonts()` stays
565
+ // wired on both platforms and is not conditional on which map won.
452
566
  //
453
567
  // So the loader's job here is to name the directory, not to register anything: it runs
454
568
  // before the addon loads and has no Pango to talk to. `@gjsify/gtk-host`'s `initFonts()`
package/index.js CHANGED
@@ -30,9 +30,12 @@ maybeReexecForGtkRuntime();
30
30
  // g_module_open of typelib backers — no re-exec needed.
31
31
  maybePrependGtkRuntimeDllPath();
32
32
 
33
- // Windows full-windowing GTK: point GSETTINGS_SCHEMA_DIR / GDK_PIXBUF_MODULE_FILE /
34
- // XDG_DATA_DIRS / FONTCONFIG_* at the data a real GTK window needs. Strict no-op for
35
- // the display-free bundle, which therefore loads byte-unchanged.
33
+ // Windows/macOS full-windowing GTK: point GSETTINGS_SCHEMA_DIR / GDK_PIXBUF_MODULE_FILE /
34
+ // XDG_DATA_DIRS / FONTCONFIG_* at the data a real GTK window needs, and PANGOCAIRO_BACKEND
35
+ // at the one backend that reads any of the font half. Strict no-op for the display-free
36
+ // bundle, which therefore loads byte-unchanged. On win32 these writes reach the Win32
37
+ // environment block ONLY — gi.js replays them into the C runtime's copy on the first
38
+ // requireGi(), which is the environment pango's and fontconfig's getenv() read.
36
39
  maybeWireGtkWindowingEnv();
37
40
 
38
41
  const require = createRequire(import.meta.url);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/node-gi",
3
- "version": "0.51.0",
3
+ "version": "0.52.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",
Binary file
Binary file
Binary file