@gjsify/node-gi 0.51.1 → 0.53.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/README.md CHANGED
@@ -124,8 +124,9 @@ console.log(action.get_name()); // 'greet' — introspected method
124
124
  action.enabled = false; // property set → set_property
125
125
 
126
126
  const cancellable = new Gio.Cancellable();
127
- cancellable.connect('cancelled', () => console.log('cancelled'));
127
+ cancellable.connect_after('cancelled', () => console.log('cancelled'));
128
128
  cancellable.cancel(); // fires the signal
129
+ // (plain `connect` is Cancellable's OWN g_cancellable_connect(cb), as on gjs)
129
130
 
130
131
  console.log(Gio.BusType.SESSION); // 2 — enums, flags and constants
131
132
  const file = Gio.File.new_for_path('/usr/bin/gjs');
package/binding.gyp CHANGED
@@ -10,6 +10,7 @@
10
10
  "src/loop.cc",
11
11
  "src/marshal.cc",
12
12
  "src/object.cc",
13
+ "src/opengl-win32.cc",
13
14
  "src/private.cc",
14
15
  "src/repo.cc",
15
16
  "src/signals.cc",
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.
@@ -33,6 +36,19 @@ const USER_PROTO = Symbol('nodeGiUserProto');
33
36
  // make every wrapper look thenable).
34
37
  const RESERVED = new Set(['then', 'toString', 'valueOf', 'constructor', 'inspect']);
35
38
 
39
+ // Names the instance wrapper's `get` trap otherwise shortcuts straight to the
40
+ // GObject signal API (see wrapInstance below). A concrete class can introspect
41
+ // its OWN method under one of these — `Soup.Server.disconnect(): void` is
42
+ // `soup_server_disconnect`, zero-arg, nothing to do with a signal handler id —
43
+ // and on gjs that own prototype property already shadows the inherited
44
+ // override through ordinary JS lookup. This Proxy has no such chain, so
45
+ // `wrapInstance` checks for an own introspected member under the name FIRST
46
+ // and only falls through to the shortcut when the class declares none.
47
+ // Regression: `Http2SecureServer.close()` called `this._soupServer.disconnect()`
48
+ // (real libsoup API, zero args) and got `disconnectSignal(handle, handlerId:
49
+ // number)` — the shortcut's `disconnect(id)` — under node-gi on Node.
50
+ const SIGNAL_API_SHORTCUTS = new Set(['connect', 'connect_after', 'emit', 'disconnect']);
51
+
36
52
  // GJS accepts both snake_case and camelCase for methods/properties: map a JS accessor
37
53
  // to the GI method name (snake_case) and to a GObject property name (kebab-case); a
38
54
  // name already in the target case passes through.
@@ -1301,6 +1317,19 @@ function wrapInstance(handle, userProto) {
1301
1317
  get(t, prop) {
1302
1318
  if (prop === HANDLE) return handle;
1303
1319
  if (typeof prop !== 'string' || RESERVED.has(prop)) return t[prop];
1320
+ if (SIGNAL_API_SHORTCUTS.has(prop)) {
1321
+ const up = t[USER_PROTO];
1322
+ const own = up !== undefined ? findProtoDescriptor(up, prop) : undefined;
1323
+ if (own !== undefined) return typeof own.get === 'function' ? own.get.call(proxy) : own.value;
1324
+ // An INTERFACE method under the name never reaches a class prototype
1325
+ // when the concrete type is private: GSocks5Proxy wraps as its nearest
1326
+ // introspectable ancestor (GObject.Object), yet on gjs 1.88.1
1327
+ // `socks5.connect` IS g_proxy_connect (Gio.Proxy, 3 IN args) — measured.
1328
+ // The same native walk the GI-method fallback below uses sees it.
1329
+ if (instanceHasMethod(handle, prop)) {
1330
+ return (...args) => wrapReturn(native.callMethod(handle, prop, unwrapArgs(args)));
1331
+ }
1332
+ }
1304
1333
  switch (prop) {
1305
1334
  case '$typeName':
1306
1335
  // The instance's concrete RUNTIME GType name —
@@ -2295,8 +2324,15 @@ function collectVfuncs(klass) {
2295
2324
  if (desc === undefined || typeof desc.value !== 'function') continue;
2296
2325
  seen.add(key);
2297
2326
  const userFn = desc.value;
2327
+ // The ARGUMENTS cross the same boundary `this` does and need the same
2328
+ // treatment: the engine hands a GObject arg over as a raw handle, so a
2329
+ // `Gtk.LayoutManager.vfunc_measure(widget, …)` override received an object
2330
+ // with no prototype and `widget.get_ancestor` was `undefined`. The return
2331
+ // goes back through `unwrapArg` because the engine reads a GObject return
2332
+ // as a handle, never as the wrapper user code naturally hands back.
2333
+ // Same contract as `wrapCallbackFn`, which callbacks have always had.
2298
2334
  vfuncs[key.slice('vfunc_'.length)] = function (...args) {
2299
- return userFn.apply(wrapInstance(this, klass.prototype), args);
2335
+ return unwrapArg(userFn.apply(wrapInstance(this, klass.prototype), args.map(wrapReturn)));
2300
2336
  };
2301
2337
  }
2302
2338
  }
@@ -2682,6 +2718,45 @@ function namespaceObject(namespace) {
2682
2718
  // startMainLoop is itself idempotent; this just avoids the extra call.
2683
2719
  let loopAttached = false;
2684
2720
 
2721
+ // Whether the windowing loader's win32 env writes have been copied into the C runtime's
2722
+ // environment yet. Set BEFORE the copy runs, because the copy calls requireGi() again.
2723
+ let windowingEnvMirrored = false;
2724
+
2725
+ /**
2726
+ * Give the bundled GTK's `getenv()` readers the variables the loader wrote, on win32.
2727
+ *
2728
+ * THE VARIABLE WAS SET AND UNREACHABLE, which reads in a log exactly like a platform
2729
+ * declining a request — and was read that way once (#1668). A Windows process carries
2730
+ * two environments: the Win32 block, which `GetEnvironmentVariableW()`, `g_getenv()`, the DLL
2731
+ * loader and every child process read, and the C runtime's own copy, which is what `getenv()`
2732
+ * returns. Node's `process.env` setter is `uv_os_setenv()` → `SetEnvironmentVariableW()` and
2733
+ * nothing else, so it writes the first and not the second. `pango_cairo_font_map_new()` reads
2734
+ * `getenv("PANGOCAIRO_BACKEND")` and fontconfig reads `getenv("FONTCONFIG_FILE")`: both saw
2735
+ * an empty environment while `process.env` reported the values back correctly.
2736
+ *
2737
+ * `g_setenv()` is the repair because glib already solved this — it calls `_wputenv()` first
2738
+ * and `SetEnvironmentVariableW()` second, and says why in its own source. Called through the
2739
+ * bundle's OWN GLib, so the runtime copy written is the one pango and fontconfig link against.
2740
+ * `overwrite` is true and that is not a policy decision: every value replayed came from
2741
+ * `setIfUnset`, so a value the operator set is already the value being written back.
2742
+ *
2743
+ * TIMING is why this is not in the loader: both readers are lazy — pango at the first font
2744
+ * map, fontconfig at the first config load — and neither can fire before a namespace is
2745
+ * required, while GLib cannot be called before the addon is loaded. The first `requireGi()` is
2746
+ * the one moment after the second and before the first.
2747
+ *
2748
+ * Strict no-op off win32 (POSIX `setenv()` is the only environment there, and Node uses it)
2749
+ * and when the windowing wiring made no writes. Held by `test/font-script-coverage.test.mjs`,
2750
+ * which measures the loader-set value against the same value put in the LAUNCH environment.
2751
+ * @returns {void}
2752
+ */
2753
+ function mirrorWindowingEnvIntoCrt() {
2754
+ const writes = windowingEnvWrites();
2755
+ if (writes.length === 0) return;
2756
+ const GLib = requireGi('GLib', '2.0');
2757
+ for (const [name, value] of writes) GLib.setenv(name, value, true);
2758
+ }
2759
+
2685
2760
  /**
2686
2761
  * Require a GObject-Introspection namespace and return a GJS-shaped namespace
2687
2762
  * object. The Node twin of `import Ns from 'gi://Ns?version=X'` /
@@ -2692,6 +2767,13 @@ let loopAttached = false;
2692
2767
  */
2693
2768
  export function requireGi(namespace, version) {
2694
2769
  native.requireNamespace(namespace, version);
2770
+ // The one moment the win32 CRT-environment mirror can run: the addon is loaded, so there
2771
+ // is a GLib to call, and nothing has built a font map or loaded a fontconfig
2772
+ // configuration yet. Flag first — mirrorWindowingEnvIntoCrt() re-enters here for GLib.
2773
+ if (!windowingEnvMirrored) {
2774
+ windowingEnvMirrored = true;
2775
+ mirrorWindowingEnvIntoCrt();
2776
+ }
2695
2777
  // Attach the libuv↔GLib integration once. On Node this arms BOTH directions:
2696
2778
  // the uv-in-GLib bridge (a blocking GLib.MainLoop.run / Gio.Application.run
2697
2779
  // keeps Node's event loop alive) AND the uv-driven auto-pump (pending GLib
package/gtk-runtime.d.ts CHANGED
@@ -26,3 +26,34 @@ export function appGiLibraryDirs(opts?: { platform?: NodeJS.Platform | string; e
26
26
  export function activateGiLibraryPath(native: { prependLibraryPath?: (p: string) => void }): string[];
27
27
  /** TEST-ONLY: allow the activation to run again. */
28
28
  export function resetGiLibraryPathForTests(): void;
29
+
30
+ /** What `native.probeHostOpenGL()` reports on win32 — each field "" when absent. */
31
+ export interface HostOpenGL {
32
+ wddmIcd: string;
33
+ registryIcd: string;
34
+ loadedFrom: string;
35
+ }
36
+ /** The OpenGL a win32 process uses: the optional GL package's Mesa opengl32.dll or the host's. */
37
+ export interface OpenGLDecision {
38
+ source: 'bundle' | 'system';
39
+ reason: string;
40
+ /** Set when the host has no ICD and the optional GL package is not installed. */
41
+ missing?: true;
42
+ }
43
+ /** `@gjsify/gl-runtime-<tag>` — the optional package carrying Mesa for driverless hosts. */
44
+ export function glRuntimePackageName(tag?: string): string;
45
+ /** The optional GL package's opengl32.dll (monorepo sibling or installed package), or null. */
46
+ export function resolveGlRuntime(): string | null;
47
+ /** Decide which OpenGL serves GTK on win32 (pure). `override` is `GJSIFY_OPENGL`. */
48
+ export function decideOpenGLSource(opts: {
49
+ bundled: string | null;
50
+ host: HostOpenGL;
51
+ override?: string;
52
+ }): OpenGLDecision;
53
+ /** win32: preload the optional GL package's opengl32.dll when the host has no OpenGL ICD (no-op elsewhere). */
54
+ export function activateBundledOpenGL(native: {
55
+ probeHostOpenGL?: () => HostOpenGL;
56
+ preloadOpenGL?: (path: string) => string;
57
+ }): (OpenGLDecision & { loadedFrom: string | null }) | null;
58
+ /** The decision `activateBundledOpenGL` made for this process, or null. */
59
+ export function openGLActivation(): (OpenGLDecision & { loadedFrom: string | null }) | null;
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,
@@ -64,6 +89,22 @@ export function resolveGtkRuntimeBundle() {
64
89
  return null;
65
90
  }
66
91
 
92
+ /**
93
+ * The bundle the GTK-in-use is served by, but only when it can open a WINDOW — the
94
+ * `share/glib-2.0/schemas/gschemas.compiled` marker the --windowing build produces.
95
+ * Null for no bundle, a system GTK, or the DEFAULT display-free bundle.
96
+ *
97
+ * ONE definition of that marker for both callers that need it: the windowing env
98
+ * wiring, which is a strict no-op without the data it locates, and the OpenGL advice
99
+ * below, which must not tell a headless process to add a GL package. Spelled twice the
100
+ * question "can this process show a window?" had two answers to drift apart.
101
+ * @returns {{ dir: string, libDir: string, typelibDir: string } | null}
102
+ */
103
+ function windowingBundle() {
104
+ const bundle = gtkSource() === 'bundle' ? resolveGtkRuntimeBundle() : null;
105
+ return bundle && existsSync(join(bundle.dir, 'share', 'glib-2.0', 'schemas', 'gschemas.compiled')) ? bundle : null;
106
+ }
107
+
67
108
  // ---- WHICH GTK WINS ---------------------------------------------------------
68
109
  //
69
110
  // Two GTKs can be reachable at once: a batteries-included bundle
@@ -283,7 +324,7 @@ export function maybeReexecForGtkRuntime() {
283
324
  // gjs process, one hop earlier: `buildNativeEnv()` in `@gjsify/cli` puts
284
325
  // `systemGiLibraryDirs()` on the child's `DYLD_FALLBACK_LIBRARY_PATH` (its
285
326
  // `system-gi.ts` is the pinned mirror of this module's `system-gi.js`). A gjs
286
- // launched by hand keeps the gap — tracked in `status/open-todos.md`.
327
+ // launched by hand keeps the gap — tracked in `status/open-todos/README.md`.
287
328
  // Same probe index.js uses for RUNTIME — as with the Bun/Deno pair above, the
288
329
  // check has to live here too because this runs before that const is defined.
289
330
  if (typeof globalThis.imports !== 'undefined' && typeof globalThis.print === 'function') return;
@@ -362,7 +403,8 @@ export function maybePrependGtkRuntimeDllPath() {
362
403
  * finds its runtime DATA on Windows: compiled GSettings schemas
363
404
  * (`GSETTINGS_SCHEMA_DIR`), the gdk-pixbuf image loaders (`GDK_PIXBUF_MODULEDIR` +
364
405
  * `GDK_PIXBUF_MODULE_FILE`), the icon themes (`XDG_DATA_DIRS` → `<bundle>/share`),
365
- * and — when bundled — Fontconfig (`FONTCONFIG_PATH`/`FONTCONFIG_FILE`). These live
406
+ * and — when bundled — Fontconfig (`FONTCONFIG_PATH`/`FONTCONFIG_FILE`) plus the Pango
407
+ * backend that actually READS it (`PANGOCAIRO_BACKEND`). These live
366
408
  * beside the DLLs the display-free path already wires (maybePrependGtkRuntimeDllPath).
367
409
  *
368
410
  * STRICT no-op off win32, without a bundle, or when the bundle carries NO windowing
@@ -370,7 +412,12 @@ export function maybePrependGtkRuntimeDllPath() {
370
412
  * --windowing build produces, so the DEFAULT display-free bundle is byte-unchanged.
371
413
  * Windows re-reads these vars at first use (schema/loader/icon-theme init runs AFTER
372
414
  * 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).
415
+ * search analog. TRUE OF EVERY `g_getenv()` READER, and only of those: on win32 an
416
+ * in-process write lands in the WIN32 environment block alone, which is where
417
+ * `g_getenv()` looks and where `getenv()` does not. What every write here is recorded
418
+ * in `windowingEnvWrites()` for is the repair of that second half — see the
419
+ * `PANGOCAIRO_BACKEND` note below and `mirrorWindowingEnvIntoCrt()` in `gi.js`.
420
+ * Each var is set only when currently unset (a host override wins).
374
421
  * Idempotent; MUST run at module top-level with maybePrependGtkRuntimeDllPath, before
375
422
  * the app initializes GTK. GLib uses `;` as the win32 search-path separator.
376
423
  * @returns {void}
@@ -384,17 +431,23 @@ export function maybeWireGtkWindowingEnv() {
384
431
  if (process.platform !== 'win32' && process.platform !== 'darwin') return;
385
432
  // Same gate as the DLL/dylib path: the runtime DATA belongs to the bundle, so
386
433
  // it is wired only when the bundle is the source the policy picked.
387
- if (gtkSource() !== 'bundle') return;
388
- const bundle = resolveGtkRuntimeBundle();
389
- if (!bundle) return; // strict no-op when no bundle is present
434
+ const bundle = windowingBundle();
435
+ if (!bundle) return; // strict no-op without a windowing bundle
390
436
 
391
437
  const schemaDir = join(bundle.dir, 'share', 'glib-2.0', 'schemas');
392
- // gschemas.compiled = the windowing-data marker; absent → display-free bundle.
393
- if (!existsSync(join(schemaDir, 'gschemas.compiled'))) return;
394
438
 
395
439
  const pathSep = process.platform === 'win32' ? ';' : ':';
440
+ // Recorded, not just written. On win32 `process.env.X = v` reaches ONE of the two
441
+ // environments this process has (see `windowingEnvWrites` and the PANGOCAIRO_BACKEND
442
+ // note below), and the mirror that reaches the other one needs a GLib, which does not
443
+ // exist yet at module top level. Reset per call so the function stays idempotent.
444
+ windowingEnvWritesMade = [];
445
+ const write = (name, value) => {
446
+ process.env[name] = value;
447
+ windowingEnvWritesMade.push([name, value]);
448
+ };
396
449
  const setIfUnset = (name, value) => {
397
- if (!process.env[name]) process.env[name] = value;
450
+ if (!process.env[name]) write(name, value);
398
451
  };
399
452
  setIfUnset('GSETTINGS_SCHEMA_DIR', schemaDir);
400
453
 
@@ -427,7 +480,7 @@ export function maybeWireGtkWindowingEnv() {
427
480
  if (existsSync(shareDir)) {
428
481
  const cur = process.env.XDG_DATA_DIRS ?? '';
429
482
  if (!cur.split(pathSep).filter(Boolean).includes(shareDir)) {
430
- process.env.XDG_DATA_DIRS = cur ? `${shareDir}${pathSep}${cur}` : shareDir;
483
+ write('XDG_DATA_DIRS', cur ? `${shareDir}${pathSep}${cur}` : shareDir);
431
484
  }
432
485
  }
433
486
 
@@ -437,18 +490,92 @@ export function maybeWireGtkWindowingEnv() {
437
490
  setIfUnset('FONTCONFIG_FILE', fontsConf);
438
491
  }
439
492
 
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.
493
+ // WHICH FONT MAP PANGO BUILDS AT ALL, which is upstream of the two lines above and is the
494
+ // assumption neither of them stated. `pangocairo-fontmap.c` picks the FIRST backend
495
+ // COMPILED IN, in the order coretext → win32 → fc, and `PANGOCAIRO_BACKEND` is the only
496
+ // thing that overrides it. Both bundles carry every backend their platform builds, so
497
+ // without this line darwin gets CoreText and win32 gets pangowin32/DirectWrite — and
498
+ // fontconfig, which everything else here configures, drives nothing.
499
+ //
500
+ // MEASURED 2026-09-12 on real hardware — Learn6502 0.8.0 on the published windowing
501
+ // bundle, macOS 15.7.9 and Windows 11, with the variable exported BY HAND into the
502
+ // process before launch (nothing in the loader set it then — this line is what changed
503
+ // that, and the distinction turns out to be the whole story on win32):
504
+ //
505
+ // Tamil தமிழ் default backend: TOFU PANGOCAIRO_BACKEND=fc: renders
506
+ // Japanese 日本語 default backend: renders PANGOCAIRO_BACKEND=fc: renders
507
+ //
508
+ // JAPANESE IS NOT A TEST — it came out right in the SAME window of the SAME run in which
509
+ // Tamil was empty boxes, because both platform maps carry a CJK fallback. What neither
510
+ // reaches from a GTK process are the Indic faces the OS itself installs (`Tamil Sangam
511
+ // MN.ttc`, `Nirmala.ttf` — present on both machines, and found immediately by fontconfig).
512
+ // So anything checking this must use a script the DEFAULT backend genuinely misses; Tamil
513
+ // is the one that was measured, and `test/font-script-coverage.test.mjs` carries its own
514
+ // negative control rather than trusting that it still is.
515
+ //
516
+ // It was NOT the translation (the UI switched language, only the glyphs were missing), not
517
+ // a broken fallback chain, not a missing face and not missing configuration: the win32
518
+ // bundle already ships `etc/fonts/fonts.conf` and the two lines above already point at it.
519
+ // The configuration was being read by nobody.
520
+ //
521
+ // ON WIN32 THIS WRITE ALONE DOES NOT REACH PANGO, and the first reading of that is kept
522
+ // here because it is the kind of wrong answer that gets re-derived. CI run 34873488108
523
+ // measured `PANGOCAIRO_BACKEND=fc` in `process.env` and a `PangoCairoWin32FontMap` anyway,
524
+ // and that was written up as "gvsbuild's pango has no fontconfig backend to select". It has
525
+ // one. `bin/pangocairo-1.0-0.dll` out of GTK4_Gvsbuild_2026.6.0_x64.zip registers
526
+ // `PangoCairoFcFontMap` as well as `PangoCairoWin32FontMap`, imports `pangoft2-1.0-0.dll`
527
+ // and `fontconfig-1.dll`, and carries ` win32 fontconfig` — the string pango prints for an
528
+ // unknown value — beside the three literals it compares against: `win32`, `fc`,
529
+ // `fontconfig`. Two things hid that: a `PangoCairo[A-Za-z]*FontMap` regex cannot match the
530
+ // digits in `Win32`, and `strings` defaults to a 4-character minimum, one more than `fc`
531
+ // has. An invalid value is also LOUD (`Unknown $PANGOCAIRO_BACKEND value` + a NULL map),
532
+ // and the run printed none — which was the evidence against the first reading all along.
533
+ //
534
+ // WHAT IS ACTUALLY WRONG IS THE REACH OF THE WRITE. `pango_cairo_font_map_new()` reads
535
+ // plain `getenv()`, not `g_getenv()`. On win32 Node's `process.env` setter is
536
+ // `uv_os_setenv()`, which is `SetEnvironmentVariableW()` and nothing else — the WIN32
537
+ // environment block, never the C runtime's own copy, and `getenv()` returns the copy. So
538
+ // `process.env.PANGOCAIRO_BACKEND` reads back `fc` (Node reads it through
539
+ // `GetEnvironmentVariableW`), every CHILD process inherits it, every `g_getenv()` reader on
540
+ // this list sees it — and pango in THIS process sees nothing, takes the `!backend` branch
541
+ // and builds the platform map. glib documents the split in `g_setenv()` itself and repairs
542
+ // it by calling `_wputenv()` first and `SetEnvironmentVariableW()` second; that is the
543
+ // repair here too, and it needs a GLib, which does not exist at module top level. So it
544
+ // happens on the first `requireGi()` — `mirrorWindowingEnvIntoCrt()` in `gi.js`, over
545
+ // exactly the writes recorded above. This write stays: it is what the mirror copies, what a
546
+ // child inherits, and what every GLib reader already uses.
547
+ //
548
+ // NOTHING ABOVE CONTRADICTS THE 2026-09-12 WINDOWS MEASUREMENT — both readings are kept
549
+ // because together they name which half is which. There the variable was in the process at
550
+ // LAUNCH, so the C runtime had it before any code of ours ran, and Tamil rendered. The
551
+ // gvsbuild pin was already 2026.6.0 on that date, so those are the same DLLs CI builds
552
+ // against: same backend, same value, different reach.
442
553
  //
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.
554
+ // `setIfUnset`, because an operator who pins `coretext`/`win32` has to win: the price of
555
+ // this line is a different RASTERISER (FreeType instead of ClearType/CoreText), which is a
556
+ // preference, while a glyph that never arrives is a defect. No platform branch is needed —
557
+ // the function has already returned on anything but darwin/win32 — and it is scoped to the
558
+ // BUNDLE deliberately: fontconfig is a safe choice only where something hands it both a
559
+ // configuration and faces, which is exactly what a windowing bundle does and what an
560
+ // arbitrary host's Pango does not owe us.
561
+ setIfUnset('PANGOCAIRO_BACKEND', 'fc');
562
+
563
+ // THE FACES, which are a different question from the three settings above and on the
564
+ // platform font maps not answerable by an environment variable at all.
565
+ //
566
+ // `XDG_DATA_DIRS` reaches `<bundle>/share/fonts` wherever fontconfig drives Pango — its
567
+ // stock configuration carries `<dir prefix="xdg">fonts</dir>` — which is true only BECAUSE
568
+ // of the backend selection above. This comment used to read "so on darwin the set above is
569
+ // enough", and that sentence WAS the defect: nothing made fontconfig the darwin backend, so
570
+ // the directory was being named to a reader nobody had asked.
571
+ // It stays the route for any process that ends up on a PLATFORM map — an operator pinning
572
+ // `win32`/`coretext`, a host GTK, a pango built without the fontconfig backend. GTK4 on
573
+ // pangowin32 has a font map filled exclusively by `pango_font_map_dwrite_populate()` from
574
+ // the DirectWrite system collection, so a `FONTCONFIG_FILE` naming a directory of faces
575
+ // moves THAT map by ZERO families however it is set (measured on Windows 11 / GTK 4.22.4,
576
+ // both directions — ADR 0038 § W1-W5). The face has to be handed to the map through
577
+ // `add_font_file`, which is a RUNTIME call somebody has to make — so `initFonts()` stays
578
+ // wired on both platforms and is not conditional on which map won.
452
579
  //
453
580
  // So the loader's job here is to name the directory, not to register anything: it runs
454
581
  // before the addon loads and has no Pango to talk to. `@gjsify/gtk-host`'s `initFonts()`
@@ -668,3 +795,143 @@ export function activateBundledGtkRuntime(native) {
668
795
  activated = bundle;
669
796
  return bundle;
670
797
  }
798
+
799
+ // --- OpenGL on win32 (#1097) -------------------------------------------------
800
+ //
801
+ // GTK 4 needs desktop GL >= 3.2 core over WGL on win32 — the gvsbuild GTK is built without
802
+ // EGL, so ANGLE is unreachable, and without Vulkan. A host with no OpenGL ICD (a GPU-less VM,
803
+ // an RDP session, a CI runner) offers only Microsoft's GDI generic OpenGL 1.1, which GDK
804
+ // rejects: every Gtk.GLArea then reads "No GL implementation is available" and GSK falls
805
+ // back to cairo. The OPTIONAL package `@gjsify/gl-runtime-win32-x64` carries Mesa's
806
+ // opengl32.dll for exactly those hosts; it is ~22 MB, so it is a consumer's opt-in and never
807
+ // part of the bundle or a dependency of node-gi (ADR 0023). This decides per process whether
808
+ // it or the host's OpenGL serves GTK. Why it has to be a native preload rather than a PATH
809
+ // entry: src/opengl-win32.cc.
810
+ //
811
+ // `GJSIFY_OPENGL=bundle|system` overrides — `system` keeps a host's OpenGL even when the
812
+ // probe finds no ICD, `bundle` takes Mesa over a vendor driver.
813
+
814
+ /** The optional GL package's npm name for a platform-arch tag. */
815
+ export function glRuntimePackageName(tag = `${process.platform}-${process.arch}`) {
816
+ return `@gjsify/gl-runtime-${tag}`;
817
+ }
818
+
819
+ /**
820
+ * Where the optional GL package's opengl32.dll is, or null. Resolved BY NAME, like the GTK
821
+ * bundle: the monorepo sibling first (what CI builds in place), then the installed package.
822
+ * @returns {string | null}
823
+ */
824
+ export function resolveGlRuntime() {
825
+ const tag = `${process.platform}-${process.arch}`;
826
+ const candidates = [join(here, '..', `gl-runtime-${tag}`, 'bin')];
827
+ try {
828
+ candidates.push(join(dirname(require.resolve(glRuntimePackageName(tag))), 'bin'));
829
+ } catch {
830
+ // Not installed — the common case; decideOpenGLSource says what that costs.
831
+ }
832
+ for (const dir of candidates) {
833
+ const dll = join(dir, 'opengl32.dll');
834
+ if (existsSync(dll)) return dll;
835
+ }
836
+ return null;
837
+ }
838
+
839
+ /** @typedef {{ wddmIcd: string, registryIcd: string, loadedFrom: string }} HostOpenGL */
840
+
841
+ /**
842
+ * Which OpenGL this process should use. PURE, so every branch runs on any host.
843
+ *
844
+ * A host ICD wins by default: Mesa over a real GPU would still work (its d3d12 driver runs on
845
+ * the hardware) but replaces the vendor's driver for this process, which is a regression
846
+ * nobody asked for. An opengl32 that is ALREADY loaded is a fact, not a preference — no
847
+ * override can move it, so it is reported rather than fought. `missing` marks the one case a
848
+ * consumer can fix: no ICD, no package.
849
+ *
850
+ * @param {object} opts
851
+ * @param {string | null} opts.bundled absolute path of the GL package's opengl32.dll, or null
852
+ * @param {HostOpenGL} opts.host `native.probeHostOpenGL()`
853
+ * @param {string} [opts.override] raw `GJSIFY_OPENGL`
854
+ * @returns {{ source: 'bundle' | 'system', reason: string, missing?: true }}
855
+ */
856
+ export function decideOpenGLSource({ bundled, host, override }) {
857
+ if (host.loadedFrom) return { source: 'system', reason: `opengl32 was already loaded from ${host.loadedFrom}` };
858
+ if (override === 'system') return { source: 'system', reason: 'GJSIFY_OPENGL=system' };
859
+ if (bundled && override === 'bundle') return { source: 'bundle', reason: 'GJSIFY_OPENGL=bundle' };
860
+ if (host.wddmIcd)
861
+ return { source: 'system', reason: `the display driver provides an OpenGL ICD (${host.wddmIcd})` };
862
+ if (host.registryIcd) return { source: 'system', reason: `an OpenGL ICD is registered (${host.registryIcd})` };
863
+ if (!bundled) {
864
+ return {
865
+ source: 'system',
866
+ missing: true,
867
+ reason:
868
+ 'this host has no OpenGL driver (only the GDI generic OpenGL 1.1, which GTK rejects), so ' +
869
+ 'Gtk.GLArea fails and GSK renders with cairo. Add ' +
870
+ `${glRuntimePackageName('win32-x64')} as a dependency to ship Mesa for such hosts ` +
871
+ '(VMs, RDP sessions, CI), or set GJSIFY_OPENGL=system to accept this',
872
+ };
873
+ }
874
+ return { source: 'bundle', reason: 'the host has no OpenGL ICD, only the GDI generic OpenGL 1.1' };
875
+ }
876
+
877
+ let openGL = null;
878
+
879
+ /**
880
+ * Apply `decideOpenGLSource` to this process: preload the GL package's opengl32.dll when it
881
+ * wins. MUST run before anything loads gtk-4-1.dll, which imports OPENGL32 statically —
882
+ * index.js calls it right after the addon loads. Strict no-op off win32. Idempotent.
883
+ * @param {{ probeHostOpenGL?: () => HostOpenGL, preloadOpenGL?: (p: string) => string }} native
884
+ * @param {object} [deps] the host facts this reads, so a spec can drive the win32 advice path
885
+ * on a host that has neither win32 nor a bundle: `platform` (default `process.platform`),
886
+ * `bundled` (the GL package's opengl32.dll, default `resolveGlRuntime()`), `windowing`
887
+ * (default `hasWindowingRuntime()`). Every default is the real reading; no production caller
888
+ * passes this — it exists so the warning and the preload fallback are executed by a test at all.
889
+ * @returns {{ source: 'bundle' | 'system', reason: string, loadedFrom: string | null } | null}
890
+ */
891
+ export function activateBundledOpenGL(native, deps) {
892
+ if (openGL !== null) return openGL || null;
893
+ openGL = false;
894
+ const platform = deps?.platform ?? process.platform;
895
+ if (platform !== 'win32' || typeof native?.probeHostOpenGL !== 'function') return null;
896
+ const candidate = deps?.bundled !== undefined ? deps.bundled : resolveGlRuntime();
897
+ const host = native.probeHostOpenGL();
898
+ const decision = decideOpenGLSource({ bundled: candidate, host, override: process.env.GJSIFY_OPENGL });
899
+ if (decision.source === 'system') {
900
+ // Said once, at the point the process could still be told what to add — the
901
+ // alternative is a GLArea painting "No GL implementation is available" with nothing
902
+ // naming the package. Only where a WINDOWING runtime is present: a headless process
903
+ // never asks for GL, and this would be noise there.
904
+ const canOpenWindows = deps?.windowing ?? hasWindowingRuntime();
905
+ if (decision.missing && canOpenWindows) {
906
+ process.emitWarning(decision.reason, { code: 'GJSIFY_OPENGL_MISSING' });
907
+ }
908
+ openGL = { ...decision, loadedFrom: host.loadedFrom || null };
909
+ return openGL;
910
+ }
911
+ try {
912
+ openGL = { ...decision, loadedFrom: native.preloadOpenGL(candidate) };
913
+ } catch (error) {
914
+ // preloadOpenGL throws with the loader's own reason (a missing libgallium_wgl.dll, a
915
+ // blocked file). The process still runs — GTK falls back to cairo — so this is a
916
+ // warning with the cause, where silence would read as "the host has no GL".
917
+ const reason = `preloading the bundled OpenGL failed: ${error.message}`;
918
+ process.emitWarning(reason, { code: 'GJSIFY_OPENGL_PRELOAD' });
919
+ openGL = { source: 'system', reason, loadedFrom: null };
920
+ }
921
+ return openGL;
922
+ }
923
+
924
+ /** TEST-ONLY: drop the memoized decision so a spec can run the activation again. */
925
+ export function resetOpenGLForTests() {
926
+ openGL = null;
927
+ }
928
+
929
+ /** Whether the GTK in use can open windows — the bundle's windowing marker. */
930
+ function hasWindowingRuntime() {
931
+ return !!windowingBundle();
932
+ }
933
+
934
+ /** What `activateBundledOpenGL` decided for this process, or null when it did not apply. */
935
+ export function openGLActivation() {
936
+ return openGL || null;
937
+ }
package/index.d.ts CHANGED
@@ -73,6 +73,17 @@ export function prependSearchPath(path: string): void;
73
73
  /** Prepend a directory GI searches for the SHARED LIBRARY a typelib names. */
74
74
  export function prependLibraryPath(path: string): void;
75
75
 
76
+ /** win32 only: the host's OpenGL ICDs (display driver / registry) and any opengl32 already loaded; each "" when absent. */
77
+ export const probeHostOpenGL: (() => { wddmIcd: string; registryIcd: string; loadedFrom: string }) | undefined;
78
+ /**
79
+ * win32 only: load an opengl32.dll by ABSOLUTE path and return the path the loaded module
80
+ * reports. THROWS when the loader refuses it (a missing sibling, a blocked file) — the reason
81
+ * is in the message, and the caller treats it as "this host has no usable GL", not a crash.
82
+ */
83
+ export const preloadOpenGL: ((path: string) => string) | undefined;
84
+ /** win32 only: GL_VENDOR/GL_RENDERER/GL_VERSION of the context current on this thread, or null without one. */
85
+ export const currentGLStrings: (() => { vendor: string; renderer: string; version: string } | null) | undefined;
86
+
76
87
  /**
77
88
  * Invoke a namespace-level GObject-Introspection function (not an instance
78
89
  * method) with primitive/string/object args. OUT and INOUT parameters are
@@ -591,6 +602,9 @@ declare const native: {
591
602
  setErrorBuilder: typeof setErrorBuilder;
592
603
  prependSearchPath: typeof prependSearchPath;
593
604
  prependLibraryPath: typeof prependLibraryPath;
605
+ probeHostOpenGL: typeof probeHostOpenGL;
606
+ preloadOpenGL: typeof preloadOpenGL;
607
+ currentGLStrings: typeof currentGLStrings;
594
608
  callFunction: typeof callFunction;
595
609
  callMethod: typeof callMethod;
596
610
  hasMethod: typeof hasMethod;
package/index.js CHANGED
@@ -11,6 +11,7 @@ import { hostTarget, nativeCandidates, packageRoot } from './native-paths.js';
11
11
  import { describeAddonLoadFailure } from './load-diagnostics.js';
12
12
  import {
13
13
  activateBundledGtkRuntime,
14
+ activateBundledOpenGL,
14
15
  activateGiLibraryPath,
15
16
  maybePrependGtkRuntimeDllPath,
16
17
  maybeReexecForGtkRuntime,
@@ -30,9 +31,12 @@ maybeReexecForGtkRuntime();
30
31
  // g_module_open of typelib backers — no re-exec needed.
31
32
  maybePrependGtkRuntimeDllPath();
32
33
 
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.
34
+ // Windows/macOS full-windowing GTK: point GSETTINGS_SCHEMA_DIR / GDK_PIXBUF_MODULE_FILE /
35
+ // XDG_DATA_DIRS / FONTCONFIG_* at the data a real GTK window needs, and PANGOCAIRO_BACKEND
36
+ // at the one backend that reads any of the font half. Strict no-op for the display-free
37
+ // bundle, which therefore loads byte-unchanged. On win32 these writes reach the Win32
38
+ // environment block ONLY — gi.js replays them into the C runtime's copy on the first
39
+ // requireGi(), which is the environment pango's and fontconfig's getenv() read.
36
40
  maybeWireGtkWindowingEnv();
37
41
 
38
42
  const require = createRequire(import.meta.url);
@@ -125,6 +129,11 @@ try {
125
129
  // Never fatal: a missing/partial bundle just leaves the host GTK in charge.
126
130
  }
127
131
 
132
+ // win32: decide whether the bundle's Mesa opengl32.dll or the host's OpenGL serves GTK, and
133
+ // preload the bundled one when it wins. Here because gtk-4-1.dll imports OPENGL32 statically,
134
+ // so it must happen before any namespace loads GTK. Reports rather than throws.
135
+ activateBundledOpenGL(native);
136
+
128
137
  // Every OTHER staged prebuild typelib (@gjsify/webkit-native and its kin): same
129
138
  // env-free treatment, so a namespace whose typelib is installed in node_modules
130
139
  // resolves under a plain `node app.mjs` and not only under `gjsify run`. Deliberately
@@ -302,4 +311,10 @@ export const textdomain = native.textdomain;
302
311
  export const bindtextdomain = native.bindtextdomain;
303
312
  export const localeCategories = native.localeCategories;
304
313
 
314
+ // win32 only (undefined elsewhere): which OpenGL the process got — see gtk-runtime.js
315
+ // activateBundledOpenGL. currentGLStrings asks the implementation behind the context
316
+ // current on this thread, so a caller can tell Mesa from a vendor driver.
317
+ export const probeHostOpenGL = native.probeHostOpenGL;
318
+ export const currentGLStrings = native.currentGLStrings;
319
+
305
320
  export default native;