@gjsify/node-gi 0.27.0 → 0.27.1

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
@@ -166,6 +166,45 @@ conformance subset since 2.9 (the marshalling/async N-API quirks that excluded
166
166
  `arrays`/`async-error` on Deno 2.1 are fixed upstream) — the Node-only co-pump
167
167
  cases are the remaining, by-design gap. The authoritative full suite runs on Node.
168
168
 
169
+ **Architectures.** CI runs the Bun/Deno legs on x86-64 only, so `linux-arm64` was
170
+ measured by hand on a OnePlus 6T / postmarketOS (2026-08-03) against the PUBLISHED
171
+ 0.27.0 `prebuilds/linux-arm64/node_gi.node`: **Bun 1.3.14 → 38/38** cross-runtime
172
+ files, **Deno 2.9.4 → 35/38 plus the three known non-gating Deno teardown exits,
173
+ 0 failed**. Two libc notes from that run:
174
+
175
+ - the prebuild declares glibc, yet it **loads and runs on a pure-musl host** (Alpine
176
+ / postmarketOS, no glibc, no `gcompat`) under Bun's musl build: musl's dynamic
177
+ loader maps the `libc.so.6` / `libm.so.6` `DT_NEEDED` entries onto ITSELF via its
178
+ reserved-name list, and every other dependency (`libglib-2.0`,
179
+ `libgirepository-2.0`, `libcairo`, `libstdc++`, `libgcc_s`) exists musl-built.
180
+ **Do not read a successful load as "every symbol resolved".** Measured with a real
181
+ artifact (`@gjsify/sab-native`'s pre-#955 prebuild) on aarch64 musl and in
182
+ `alpine:3.24` x86-64: `dlopen` behaves oppositely by mode — `RTLD_NOW` fails at
183
+ load, `RTLD_LAZY` **loads** and the process dies at the first call through the
184
+ deferred PLT slot. GI opens typelib backers with `G_MODULE_BIND_LAZY`, so on musl a
185
+ glibc-only symbol really does hide from a load. Both arches behave identically.
186
+ A weak undefined symbol hides the same way. So this binary is clear by audit, not
187
+ by its load: 5 of its 579 undefined symbols are weak, and the 3 unresolvable on
188
+ musl (`_ITM_{,de}registerTMCloneTable`, `__gmon_start__`) are the NULL-guarded gcc
189
+ boilerplate every DSO carries. What defeats the hiding is `dlopen(RTLD_NOW)`, or —
190
+ with no musl machine at all — a glibc-floor `SHT_GNU_verneed` audit.
191
+ - **Deno publishes no musl build** (`aarch64-unknown-linux-gnu` only), so on such a
192
+ host Deno needs a glibc environment (here the `org.gnome.Platform//49` flatpak
193
+ runtime: glibc 2.42, gjs 1.86.0). With `node_modules/` already materialised, both
194
+ `--node-modules-dir=manual` and Deno's default worked.
195
+
196
+ On **Linux** the typelib's bare library leaf needs no help: `Gtk`/`Gdk`/`Adw`/`Pango`/
197
+ `Graphene` all resolve and `Gtk.DrawingArea` subclasses correctly with no
198
+ `GI_TYPELIB_PATH` and no `LD_LIBRARY_PATH`, both on the musl host and inside the
199
+ flatpak runtime, because the system loader's default search path already covers the
200
+ distro's own libraries. That is worth stating explicitly because the same step FAILS
201
+ on macOS (`Failed to load shared library 'libgtk-4.1.dylib' referenced by the
202
+ typelib` → `Gtk.DrawingArea is not a subclassable GObject type`): dyld has no
203
+ `ld.so.conf` equivalent, and a plain `node`/`bun`/`deno` binary carries no rpath into
204
+ Homebrew's prefix, where `gjs` itself does. A loader-path failure of that shape is
205
+ never a node-gi or runtime defect — see `gtk-runtime.js` and the CLI's
206
+ `detectNativePackages()` / `buildNativeEnv()` for how the env normally gets wired.
207
+
169
208
  Promise draining during a blocking loop is cross-runtime because the engine runs
170
209
  a **microtask checkpoint at every outermost loop-dispatched GLib→JS callback
171
210
  boundary**: Node's `napi_make_callback` performs it natively; Bun's and Deno's
package/gi.js CHANGED
@@ -1956,25 +1956,74 @@ function paramSpecToNative(desc, key) {
1956
1956
  return spec;
1957
1957
  }
1958
1958
 
1959
- // Normalise a signal param/return type to the native type-name vocabulary: a
1960
- // string passes through; a ParamSpec descriptor contributes its `.type`.
1959
+ // Normalise a signal param/return type to the native type-name vocabulary the
1960
+ // engine resolves with g_type_from_name (src/class.cc TypeNameToGType). THREE
1961
+ // spellings are accepted, in the order tried:
1962
+ // • a string — node-gi's own shorthand ('int', 'GBytes', any registered GType name)
1963
+ // • a GObject.ParamSpec descriptor — contributes its `.type`
1964
+ // • a GTYPE — `GObject.TYPE_INT`, `SomeClass.$gtype`. This is what GJS documents
1965
+ // and what every ported GJS class writes, and it is an OPAQUE tagged External
1966
+ // (marshal.cc kGTypeHandleTag): neither a string nor a carrier of `.type`, so it
1967
+ // has to be turned back into its canonical name via the introspected
1968
+ // `g_type_name()` — the same GType→name round-trip TypeNameToGType inverts.
1969
+ // `undefined` = "not a type at all"; the caller REJECTS it (see signalSpecToNative).
1961
1970
  function normalizeSignalType(t) {
1962
1971
  if (typeof t === 'string') return t;
1963
- if (t !== null && typeof t === 'object' && typeof t.type === 'string') return t.type;
1964
- return undefined;
1972
+ if (t === null || typeof t !== 'object') return undefined;
1973
+ if (typeof t.type === 'string') return t.type;
1974
+ // Lazy + namespaceCache'd; GObject is necessarily loaded already (the caller got
1975
+ // here through GObject.registerClass), same shape as the Symbol.hasInstance GType
1976
+ // lookup above. `type_name` THROWS a TypeError for anything that is not a GType
1977
+ // handle (marshal.cc UnwrapGTypeArg) — that IS the "not a type" answer, and the
1978
+ // caller turns it into a message naming the signal, so it is caught here.
1979
+ try {
1980
+ const name = requireGi('GObject', '2.0').type_name(t);
1981
+ return typeof name === 'string' && name.length > 0 ? name : undefined;
1982
+ } catch {
1983
+ return undefined;
1984
+ }
1985
+ }
1986
+
1987
+ function describeSignalType(t) {
1988
+ return t === null ? 'null' : typeof t === 'object' ? 'an object' : typeof t === 'string' ? `'${t}'` : String(t);
1965
1989
  }
1966
1990
 
1967
1991
  // Map a meta.Signals entry (`{ param_types?, return_type?, flags? }`, GJS keys,
1968
1992
  // camelCase also accepted) to a native SignalSpec; the signal name is the key.
1993
+ //
1994
+ // An unresolvable entry THROWS, it is never dropped. Dropping is what shipped: the
1995
+ // GJS-canonical `Signals: { changed: { param_types: [GObject.TYPE_INT] } }` produced
1996
+ // a signal registered with ZERO parameters, so `emit('changed', 42)` delivered
1997
+ // nothing and the handler's payload argument was silently `undefined` — no error on
1998
+ // either side (the engine's own loop skips a G_TYPE_INVALID param too). Measured
1999
+ // against gjs 1.88 (n_params 1, payload 42) on aarch64.
1969
2000
  function signalSpecToNative(spec, key) {
1970
2001
  const s = spec !== null && typeof spec === 'object' ? spec : {};
1971
2002
  const out = { name: key };
1972
2003
  const params = s.param_types !== undefined ? s.param_types : s.paramTypes;
1973
2004
  if (Array.isArray(params)) {
1974
- out.paramTypes = params.map(normalizeSignalType).filter((t) => t !== undefined);
2005
+ out.paramTypes = params.map((t, i) => {
2006
+ const name = normalizeSignalType(t);
2007
+ if (name === undefined) {
2008
+ throw new TypeError(
2009
+ `GObject.registerClass: signal '${key}' param_types[${i}] is not a type — pass a GType ` +
2010
+ `(GObject.TYPE_INT, SomeClass.$gtype) or a type name ('int', 'GBytes'), got ${describeSignalType(t)}`,
2011
+ );
2012
+ }
2013
+ return name;
2014
+ });
2015
+ }
2016
+ const rawRet = s.return_type !== undefined ? s.return_type : s.returnType;
2017
+ if (rawRet !== undefined) {
2018
+ const ret = normalizeSignalType(rawRet);
2019
+ if (ret === undefined) {
2020
+ throw new TypeError(
2021
+ `GObject.registerClass: signal '${key}' return_type is not a type — pass a GType ` +
2022
+ `(GObject.TYPE_INT, SomeClass.$gtype) or a type name ('int', 'GBytes'), got ${describeSignalType(rawRet)}`,
2023
+ );
2024
+ }
2025
+ out.returnType = ret;
1975
2026
  }
1976
- const ret = normalizeSignalType(s.return_type !== undefined ? s.return_type : s.returnType);
1977
- if (ret !== undefined) out.returnType = ret;
1978
2027
  if (typeof s.flags === 'number') out.flags = s.flags;
1979
2028
  return out;
1980
2029
  }
@@ -2144,6 +2193,18 @@ function registerClass(metaOrClass, maybeClass) {
2144
2193
  // Reverse index for runCtorForCObject: the engine identifies a C-created instance
2145
2194
  // by its GType name (= gtypeName, what native.registerClass registered).
2146
2195
  classesByGType.set(gtypeName, klass);
2196
+ // GJS parity: registration must be COMPLETE when registerClass returns. The
2197
+ // engine installs the custom properties, signals and vfunc trampolines in
2198
+ // class_init (src/class.cc), which GObject runs lazily on the first
2199
+ // g_type_class_ref — so without this the declared surface did not exist until the
2200
+ // first instance was constructed, and `GObject.signal_lookup('changed',
2201
+ // Klass.$gtype)` answered 0 where gjs answers the real signal id (measured: gjs
2202
+ // 1.88.1 vs node-gi on aarch64). g_type_class_ref is what forces it, exactly as
2203
+ // gjs's own registerClass does; the ref is deliberately never released (a class
2204
+ // struct outlives its GType, which is permanent). Placed LAST so every L1 record
2205
+ // above is in place before any class_init side effect (a Gtk template install)
2206
+ // runs. Idempotent — GObject initialises a class exactly once.
2207
+ requireGi('GObject', '2.0').type_class_ref(typeHandle);
2147
2208
  return klass;
2148
2209
  }
2149
2210
 
package/gtk-runtime.js CHANGED
@@ -11,6 +11,7 @@ import { createRequire } from 'node:module';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { dirname, join } from 'node:path';
13
13
  import { existsSync } from 'node:fs';
14
+ import { pathCovers, splitSearchPath, systemGiLibraryDirs } from './system-gi.js';
14
15
 
15
16
  // NB: node:child_process is intentionally NOT imported at module top level. It is
16
17
  // pulled in lazily (via `require` below) ONLY on the darwin re-exec path — importing
@@ -63,19 +64,36 @@ export function resolveGtkRuntimeBundle() {
63
64
  }
64
65
 
65
66
  /**
66
- * Make the bundled GTK runtime genuinely env-free by RE-EXECING this process once
67
- * with `DYLD_FALLBACK_LIBRARY_PATH` (+ `GI_TYPELIB_PATH`) pointed at the bundle.
67
+ * Make GTK genuinely env-free on macOS by RE-EXECING this process once with
68
+ * `DYLD_FALLBACK_LIBRARY_PATH` pointing at the dylibs a typelib names by bare
69
+ * leaf — the BUNDLED runtime when one ships, otherwise the HOST's system GI stack
70
+ * (`systemGiLibraryDirs()`; Homebrew at either prefix, MacPorts, custom).
68
71
  *
69
72
  * Why a re-exec and not just `process.env` mutation: on macOS, dyld captures
70
73
  * `DYLD_FALLBACK_LIBRARY_PATH` at PROCESS LAUNCH, so setting it from JS never
71
- * affects the running dyld. It is load-bearing beyond the addon's own link
72
- * closure: GObject-Introspection resolves a type's `get_type()` (e.g. for
73
- * `registerClass` subclassing) — and the Pango/Gdk/Graphene backers — via
74
+ * affects the running dyld. Measured on macOS 15.7.8 / Node 24.18.1 — mutating
75
+ * `process.env` before the first `requireGi('Gtk','4.0')` reproduces the failure
76
+ * verbatim. It is load-bearing beyond the addon's own link closure:
77
+ * GObject-Introspection resolves a type's `get_type()` (e.g. for `registerClass`
78
+ * subclassing) — and the Pango/Gdk/Graphene/GdkPixbuf backers — via
74
79
  * `g_module_open(<leaf soname>)`, and dyld will NOT satisfy a bare-leaf dlopen
75
- * from an `@loader_path`-loaded image. Re-launching with the fallback set (exactly
76
- * what the Homebrew-based CI leg does) resolves every such leaf lookup from the
77
- * bundle. Guarded by a sentinel env var so it fires AT MOST ONCE; a no-op unless
78
- * darwin + a bundle is present + the fallback does not already cover it.
80
+ * from an `@loader_path`-loaded image, nor from a prefix absent from its default
81
+ * fallback path. Re-launching with the fallback set (exactly what the
82
+ * Homebrew-based CI legs export by hand) resolves every such leaf lookup.
83
+ *
84
+ * ONE re-exec path for both sources, on purpose. The BUNDLE still wins whenever
85
+ * one is installed and its behaviour here is byte-unchanged (its libDir alone,
86
+ * plus the bundle's `GI_TYPELIB_PATH`) — #920 reverted making a bundle a
87
+ * dependency of this package precisely because an installed bundle silently beats
88
+ * the GTK the addon was built against, so the system branch is reached ONLY when
89
+ * `resolveGtkRuntimeBundle()` finds nothing and it never touches typelib
90
+ * resolution (the host's typelibs are already found; only the loader is blind).
91
+ *
92
+ * Sentinel-guarded so it fires AT MOST ONCE, and skipped entirely when the
93
+ * launching environment already covers the directories — so a CI job or a
94
+ * launcher that exported the variable pays nothing, and the re-exec'd child never
95
+ * re-execs again. A host with no GI stack under any probed or pkg-config-reported
96
+ * prefix yields no directories and is left byte-unchanged.
79
97
  *
80
98
  * MUST be called at module top-level BEFORE the native addon (and thus its GTK
81
99
  * dependency closure) is dlopen'd — the re-exec then loads everything correctly.
@@ -90,23 +108,45 @@ export function maybeReexecForGtkRuntime() {
90
108
  // their DYLD path for non-addon-linked backers is a documented follow-up), so the
91
109
  // darwin path can never spawn a malformed re-exec under a non-Node runtime.
92
110
  if (typeof globalThis.Bun !== 'undefined' || typeof globalThis.Deno !== 'undefined') return;
111
+ // GJS host mode (@gjsify/napi loads this addon inside gjs): the process is
112
+ // `gjs`, so process.execPath/execArgv describe an interpreter that cannot be
113
+ // re-launched with a Node argv — and `createRequire` there is
114
+ // @gjsify/module's polyfill, which rejects synchronous BUILTIN loads, so even
115
+ // REACHING the child_process require below would throw during module init.
116
+ // Nothing needs the repair anyway: gjs itself resolves these leaves (its
117
+ // binary carries the rpath this whole module exists to compensate for).
118
+ // Same probe index.js uses for RUNTIME — as with the Bun/Deno pair above, the
119
+ // check has to live here too because this runs before that const is defined.
120
+ if (typeof globalThis.imports !== 'undefined' && typeof globalThis.print === 'function') return;
93
121
  if (process.env[REEXEC_SENTINEL]) return; // already re-exec'd — the child path
122
+
123
+ // A bundle, when installed, is the whole answer and stays the ONLY entry (see
124
+ // the #920 note above). Without one, the host's own GI libdirs are the answer.
94
125
  const bundle = resolveGtkRuntimeBundle();
95
- if (!bundle) return; // strict no-op when no bundle is present
126
+ const libDirs = bundle ? [bundle.libDir] : systemGiLibraryDirs();
127
+ if (libDirs.length === 0) return; // no bundle and no host GI stack — nothing to point at
96
128
 
97
129
  const curDyld = process.env.DYLD_FALLBACK_LIBRARY_PATH ?? '';
98
- if (curDyld.split(':').filter(Boolean).includes(bundle.libDir)) return; // already covered
130
+ const curDyldDirs = splitSearchPath(curDyld);
131
+ if (pathCovers(libDirs, curDyldDirs)) return; // the launch env already covers it
99
132
 
100
- // Lazily load node:child_process ONLY here (darwin + bundle present + not yet
101
- // covered) — see the top-of-file note; keeps it out of non-darwin module graphs.
133
+ // Lazily load node:child_process ONLY here (darwin + something to point at +
134
+ // not yet covered) — see the top-of-file note; keeps it out of non-darwin
135
+ // module graphs.
102
136
  const { spawnSync } = require('node:child_process');
103
137
  const curTypelib = process.env.GI_TYPELIB_PATH ?? '';
104
138
  const env = {
105
139
  ...process.env,
106
140
  [REEXEC_SENTINEL]: '1',
107
- DYLD_FALLBACK_LIBRARY_PATH: curDyld ? `${bundle.libDir}:${curDyld}` : `${bundle.libDir}:/usr/lib`,
108
- GI_TYPELIB_PATH: curTypelib ? `${bundle.typelibDir}:${curTypelib}` : bundle.typelibDir,
141
+ // Our dirs first, then whatever the host asked for — falling back to
142
+ // dyld's own default tail so setting the variable never REMOVES /usr/lib.
143
+ DYLD_FALLBACK_LIBRARY_PATH: [...libDirs, ...(curDyldDirs.length > 0 ? curDyldDirs : ['/usr/lib'])].join(':'),
109
144
  };
145
+ // Only a bundle relocates typelibs; a host GI stack's are already on GI's own
146
+ // search path, so the system branch deliberately leaves GI_TYPELIB_PATH alone.
147
+ if (bundle) {
148
+ env.GI_TYPELIB_PATH = curTypelib ? `${bundle.typelibDir}:${curTypelib}` : bundle.typelibDir;
149
+ }
110
150
  // Reproduce this exact invocation (node flags + argv) with the augmented env.
111
151
  const res = spawnSync(process.execPath, [...process.execArgv, ...process.argv.slice(1)], {
112
152
  stdio: 'inherit',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/node-gi",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
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",
@@ -44,6 +44,8 @@
44
44
  "gi.d.ts",
45
45
  "gtk-runtime.js",
46
46
  "gtk-runtime.d.ts",
47
+ "system-gi.js",
48
+ "system-gi.d.ts",
47
49
  "globals.js",
48
50
  "globals.d.ts",
49
51
  "system.js",
Binary file
Binary file
Binary file
Binary file
Binary file
package/src/template.cc CHANGED
@@ -6,10 +6,9 @@
6
6
  namespace nodegi {
7
7
 
8
8
  // Resolve the GTK template API once (C++11 function-local static init is
9
- // thread-safe; node-gi calls these only on the main thread). dlopen with
10
- // RTLD_NOLOAD first — requireGi('Gtk','4.0') already dlopened libgtk-4 via the
11
- // typelib, so this just bumps the refcount; fall back to a plain dlopen so a
12
- // caller that never required Gtk through the typelib still resolves the symbols.
9
+ // thread-safe; node-gi calls these only on the main thread). How the library
10
+ // handle is obtained — and why the leaf name has to be per-OS — is documented at
11
+ // the dlopen below.
13
12
  const GtkTemplateApi* GetGtkTemplateApi() {
14
13
  static GtkTemplateApi api = {};
15
14
  static bool initialised = false;
@@ -22,8 +21,27 @@ const GtkTemplateApi* GetGtkTemplateApi() {
22
21
  // callers warn + no-op / throw a clear "template API unavailable" error.
23
22
  return &api;
24
23
  #else
25
- void* lib = dlopen("libgtk-4.so.1", RTLD_LAZY | RTLD_NOLOAD);
26
- if (lib == nullptr) lib = dlopen("libgtk-4.so.1", RTLD_LAZY);
24
+ // The library's LEAF NAME IS PER-OS, and hardcoding the ELF soname made the
25
+ // whole composite-template API unavailable on macOS: `libgtk-4.so.1` does not
26
+ // exist there, so every `registerClass({ Template })` widget failed with
27
+ // "the libgtk-4 template API is unavailable" on a host with GTK 4 correctly
28
+ // installed. Invisible until v0.27.0 shipped the first darwin-x64 addon.
29
+ //
30
+ // Candidates are the leaf names GTK 4 actually installs, newest ABI first.
31
+ #ifdef __APPLE__
32
+ static const char* const kGtkLeaves[] = {"libgtk-4.1.dylib", "libgtk-4.dylib"};
33
+ #else
34
+ static const char* const kGtkLeaves[] = {"libgtk-4.so.1", "libgtk-4.so"};
35
+ #endif
36
+ void* lib = nullptr;
37
+ for (const char* leaf : kGtkLeaves) {
38
+ // RTLD_NOLOAD first — requireGi('Gtk','4.0') already dlopened libgtk-4 via the
39
+ // typelib, so this only bumps the refcount; the plain dlopen behind it serves a
40
+ // caller that never required Gtk through the typelib.
41
+ lib = dlopen(leaf, RTLD_LAZY | RTLD_NOLOAD);
42
+ if (lib == nullptr) lib = dlopen(leaf, RTLD_LAZY);
43
+ if (lib != nullptr) break;
44
+ }
27
45
  if (lib == nullptr) return &api; // api.ok stays false → callers warn + no-op
28
46
  api.set_template = reinterpret_cast<decltype(api.set_template)>(
29
47
  dlsym(lib, "gtk_widget_class_set_template"));
package/system-gi.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ // SPDX-License-Identifier: MIT
2
+ /** Injectable host facts for {@link systemGiLibraryDirs} — the darwin branch is testable from Linux. */
3
+ export interface SystemGiOptions {
4
+ /** `process.platform` value; defaults to the running process. */
5
+ platform?: string;
6
+ /** Environment to read `GI_TYPELIB_PATH` / `PKG_CONFIG_PATH` from; defaults to `process.env`. */
7
+ env?: NodeJS.ProcessEnv;
8
+ /** Directory-existence predicate; defaults to a `statSync` probe. */
9
+ existsDir?: (dir: string) => boolean;
10
+ /** `pkg-config`'s `.pc` search directories; defaults to {@link pkgConfigSearchDirs}. */
11
+ searchDirs?: (env: NodeJS.ProcessEnv) => string[];
12
+ }
13
+ /** Split an OS search-path variable, dropping empty segments. */
14
+ export function splitSearchPath(value: string | undefined, separator?: string): string[];
15
+ /** `$PKG_CONFIG_PATH` plus `pkg-config`'s own `pc_path`; memoized, absent pkg-config yields just the env. */
16
+ export function pkgConfigSearchDirs(env: NodeJS.ProcessEnv): string[];
17
+ /** Directories holding the host's system GI shared libraries, most specific first; `[]` off darwin. */
18
+ export function systemGiLibraryDirs(opts?: SystemGiOptions): string[];
19
+ /** Whether a split loader path already covers every wanted directory. */
20
+ export function pathCovers(wanted: readonly string[], current: readonly string[]): boolean;
package/system-gi.js ADDED
@@ -0,0 +1,209 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // @gjsify/node-gi/system-gi — WHERE the host's GObject-Introspection stack keeps
3
+ // its shared libraries, so the dynamic loader can find the ones a typelib names
4
+ // by BARE LEAF (`libgtk-4.1.dylib`, `libgdk_pixbuf-2.0.0.dylib`, …).
5
+ //
6
+ // WHY THIS EXISTS — the failure it removes, measured on macOS 15.7.8 / Homebrew
7
+ // /usr/local / Node 24.18.1 / GTK 4.22.4, the first release that shipped a
8
+ // darwin-x64 addon:
9
+ //
10
+ // GLib-GIRepository-WARNING: Failed to load shared library 'libgtk-4.1.dylib'
11
+ // referenced by the typelib: dlopen(libgtk-4.1.dylib, 0x0009): tried:
12
+ // 'libgtk-4.1.dylib' (no such file), …, '/usr/lib/libgtk-4.1.dylib' (no such file)
13
+ // TypeError: Gtk.DrawingArea is not a subclassable GObject type
14
+ //
15
+ // Everything WAS installed: `/usr/local/lib/libgtk-4.1.dylib` exists and
16
+ // `/usr/local/lib/girepository-1.0/Gtk-4.0.typelib` names exactly that leaf. The
17
+ // gap is the LOADER, not the installation — modern dyld does not carry
18
+ // `/usr/local/lib` in its default fallback search path, and a plain `node` has no
19
+ // rpath there (Homebrew's own `gjs` binary does, which is why `--runtime gjs`
20
+ // never showed this and `--runtime node` did). `gi_repository_require` resolves
21
+ // the typelib, `g_module_open('libgtk-4.1.dylib')` then fails, no
22
+ // `gtk_drawing_area_get_type` is ever registered, and the failure surfaces one
23
+ // layer up as "not a subclassable GObject type".
24
+ //
25
+ // The value CANNOT come from `pkg-config --variable=libdir gtk4`. Measured on the
26
+ // same host: that answers `/usr/local/Cellar/gtk4/4.22.4/lib` — a PER-FORMULA
27
+ // directory that fixes GTK and leaves `libgdk_pixbuf-2.0.0.dylib` (a different
28
+ // formula, so a different Cellar libdir) still unresolvable. What works is the
29
+ // directory every keg is symlinked into, `<prefix>/lib`, because it is the UNION.
30
+ // So the question this module answers is "which directories hold the GI stack",
31
+ // never "where is one library".
32
+ //
33
+ // THE RULE — a typelib and the library it names are SIBLINGS by GI's own install
34
+ // layout: `<libdir>/girepository-1.0/Gtk-4.0.typelib` beside
35
+ // `<libdir>/libgtk-4.1.dylib`. A directory holding `girepository-1.0/` therefore
36
+ // IS a GI library directory. That convention is what makes this
37
+ // package-manager-agnostic: it lands on `/usr/local/lib` (Homebrew x64),
38
+ // `/opt/homebrew/lib` (Homebrew arm64), `/opt/local/lib` (MacPorts) or a custom
39
+ // prefix without needing to know that any of them exist.
40
+ //
41
+ // PURE FUNCTION of injected host facts (platform / env / fs / pkg-config), like
42
+ // `hostTarget()` beside it and the CLI's `resolvePrebuildDirName()` — so the
43
+ // darwin branch is unit-testable from a Linux host and the host values are read
44
+ // only at the outermost call site, as defaults.
45
+ import { statSync } from 'node:fs';
46
+ import { createRequire } from 'node:module';
47
+ import { basename, dirname, join } from 'node:path';
48
+
49
+ const require = createRequire(import.meta.url);
50
+
51
+ /**
52
+ * Prefixes a macOS GI stack is installed under in practice — probed, never
53
+ * assumed: each is accepted only when it actually holds a `girepository-1.0/`.
54
+ *
55
+ * This is the no-pkg-config safety net, and it is load-bearing rather than
56
+ * belt-and-braces: `brew install gtk4` installs RUNTIME dependencies only, so a
57
+ * user running a published showcase routinely has the entire GTK stack and no
58
+ * `pkg-config` at all (it is a BUILD dependency of those formulae). The
59
+ * pkg-config source below is the GENERAL mechanism — it is what finds a jhbuild
60
+ * or otherwise bespoke prefix — but it cannot be the only one.
61
+ * @type {Record<string, readonly string[]>}
62
+ */
63
+ const PROBED_GI_LIBDIRS = {
64
+ darwin: ['/opt/homebrew/lib', '/usr/local/lib', '/opt/local/lib'],
65
+ };
66
+
67
+ /** The subdir GI installs typelibs into — historically `1.0` even for the girepository-2.0 API. */
68
+ const TYPELIB_SUBDIR = 'girepository-1.0';
69
+
70
+ /** Memoized `pkg-config` answer: the spawn happens at most once per process. */
71
+ let pkgConfigDirsCache = null;
72
+
73
+ /**
74
+ * Split an OS search-path variable, dropping empty segments.
75
+ * @param {string | undefined} value
76
+ * @param {string} [separator]
77
+ * @returns {string[]}
78
+ */
79
+ export function splitSearchPath(value, separator = ':') {
80
+ return (value ?? '').split(separator).filter(Boolean);
81
+ }
82
+
83
+ /**
84
+ * @param {string} dir
85
+ * @returns {boolean}
86
+ */
87
+ function isDirectory(dir) {
88
+ try {
89
+ return statSync(dir).isDirectory();
90
+ } catch {
91
+ // Any stat failure means the same thing here — ENOENT on an uninstalled
92
+ // prefix, EACCES on a path this process may not read: not a usable libdir.
93
+ return false;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * `pkg-config`'s OWN configured `.pc` search path plus `$PKG_CONFIG_PATH`. Every
99
+ * entry is a `<libdir>/pkgconfig` (or `<datadir>/pkgconfig`) directory, so its
100
+ * parent is a candidate libdir — which is how a prefix nobody hardcoded is
101
+ * found. Measured on the reported host: `pc_path` =
102
+ * `/usr/local/lib/pkgconfig:/usr/local/share/pkgconfig:/usr/lib/pkgconfig:…`,
103
+ * whose first parent is exactly the `/usr/local/lib` that resolves every leaf.
104
+ *
105
+ * `spawnSync` reports a missing executable as `result.error`, not by throwing —
106
+ * an absent `pkg-config` is expected on a runtime-only GTK install, so it is a
107
+ * checked return value here and not an exception path.
108
+ * @param {NodeJS.ProcessEnv} env
109
+ * @returns {string[]}
110
+ */
111
+ export function pkgConfigSearchDirs(env) {
112
+ if (pkgConfigDirsCache) return pkgConfigDirsCache;
113
+ const dirs = splitSearchPath(env.PKG_CONFIG_PATH);
114
+ // Lazily required, never imported at module top level: `index.js` deliberately
115
+ // keeps node:child_process out of non-darwin module graphs because an eager
116
+ // import tripped a Deno teardown regression on an unrelated conformance file.
117
+ const { spawnSync } = require('node:child_process');
118
+ const res = spawnSync('pkg-config', ['--variable=pc_path', 'pkg-config'], {
119
+ encoding: 'utf8',
120
+ timeout: 10_000,
121
+ });
122
+ if (!res.error && res.status === 0) dirs.push(...splitSearchPath(String(res.stdout).trim()));
123
+ pkgConfigDirsCache = dirs;
124
+ return dirs;
125
+ }
126
+
127
+ /**
128
+ * Directories holding the host's system GObject-Introspection shared libraries —
129
+ * most specific first, deduplicated, each verified on disk.
130
+ *
131
+ * Three sources, in precedence order:
132
+ * 1. `GI_TYPELIB_PATH` — the host stating outright where typelibs live; the
133
+ * libraries are the sibling `dirname()`. Accepted on directory existence
134
+ * alone: an explicit statement is not second-guessed.
135
+ * 2. `pkg-config`'s `.pc` search path → each `<dir>/pkgconfig`'s parent. The
136
+ * general mechanism (any prefix, including a custom one).
137
+ * 3. {@link PROBED_GI_LIBDIRS} — the standard macOS prefixes, for the common
138
+ * case where GTK is installed and pkg-config is not.
139
+ * Sources 2 and 3 are GUESSES at a prefix, so each must show the
140
+ * `girepository-1.0/` marker before it is believed.
141
+ *
142
+ * Empty off darwin, and that is a statement about LOADERS, not about GI. Linux
143
+ * resolves these leaves through `ld.so`'s system-wide configured cache
144
+ * (`/etc/ld.so.conf.d`), which a package install populates — nothing per-process
145
+ * is needed. dyld is the one loader that neither consults a system-wide config
146
+ * NOR re-reads its search path after launch, which is why darwin needs a re-exec
147
+ * and nobody else does.
148
+ *
149
+ * win32 is deliberately absent rather than proven unnecessary: `PATH` is the DLL
150
+ * search path, a Windows GTK distribution puts its `bin` there itself, and
151
+ * Windows re-reads that path at every `LoadLibrary` — so if a system GTK ever
152
+ * turns out not to be on it, the repair is an in-process `process.env.PATH`
153
+ * prepend (the shape `maybePrependGtkRuntimeDllPath()` already uses for the
154
+ * bundle), never this re-exec. Untested there, and node-gi's win32 GTK support is
155
+ * Phase 2 regardless.
156
+ * @param {object} [opts]
157
+ * @param {string} [opts.platform] defaults to `process.platform`
158
+ * @param {NodeJS.ProcessEnv} [opts.env] defaults to `process.env`
159
+ * @param {(dir: string) => boolean} [opts.existsDir] directory predicate (injectable for tests)
160
+ * @param {(env: NodeJS.ProcessEnv) => string[]} [opts.searchDirs] pkg-config `.pc` dirs (injectable)
161
+ * @returns {string[]} absolute directories, or `[]` when this platform's loader needs none
162
+ */
163
+ export function systemGiLibraryDirs({
164
+ platform = process.platform,
165
+ env = process.env,
166
+ existsDir = isDirectory,
167
+ searchDirs = pkgConfigSearchDirs,
168
+ } = {}) {
169
+ const probed = PROBED_GI_LIBDIRS[platform];
170
+ if (!probed) return [];
171
+
172
+ /** @type {string[]} */
173
+ const out = [];
174
+ const add = (dir) => {
175
+ if (dir && dir !== '/' && !out.includes(dir)) out.push(dir);
176
+ };
177
+
178
+ // 1. Explicit: the typelib dir's sibling libdir.
179
+ for (const typelibDir of splitSearchPath(env.GI_TYPELIB_PATH)) {
180
+ const libDir = dirname(typelibDir);
181
+ if (existsDir(libDir)) add(libDir);
182
+ }
183
+
184
+ // 2 + 3. Candidate prefixes, believed only on the girepository-1.0 marker.
185
+ /** @type {string[]} */
186
+ const candidates = [];
187
+ for (const pcDir of searchDirs(env)) {
188
+ if (basename(pcDir) === 'pkgconfig') candidates.push(dirname(pcDir));
189
+ }
190
+ candidates.push(...probed);
191
+ for (const libDir of candidates) {
192
+ if (existsDir(join(libDir, TYPELIB_SUBDIR))) add(libDir);
193
+ }
194
+
195
+ return out;
196
+ }
197
+
198
+ /**
199
+ * Whether `current` (a split `DYLD_FALLBACK_LIBRARY_PATH`) already covers every
200
+ * directory in `wanted` — the "nothing to do" test that keeps a correctly
201
+ * launched process (a CI job, or a launcher that exported the variable) from
202
+ * re-execing at all, and keeps a re-exec'd child from doing it twice.
203
+ * @param {readonly string[]} wanted
204
+ * @param {readonly string[]} current
205
+ * @returns {boolean}
206
+ */
207
+ export function pathCovers(wanted, current) {
208
+ return wanted.every((dir) => current.includes(dir));
209
+ }