@gjsify/node-gi 0.26.1 → 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.
@@ -108,8 +108,7 @@ if (mode === '--includes') {
108
108
  // Last resort: locate ffi.h anywhere under the tree and add its dir.
109
109
  if (!dirs.some((d) => existsSync(join(d, 'ffi.h')))) {
110
110
  const ffiDir =
111
- findHeaderDir(join(prefix, 'include'), 'ffi.h') ??
112
- findHeaderDir(join(prefix, 'lib'), 'ffi.h');
111
+ findHeaderDir(join(prefix, 'include'), 'ffi.h') ?? findHeaderDir(join(prefix, 'lib'), 'ffi.h');
113
112
  if (ffiDir) dirs.push(ffiDir);
114
113
  }
115
114
  }
@@ -127,15 +126,7 @@ if (mode === '--includes') {
127
126
  // cairo the native cairo foreign-struct binding (src/cairo.cc)
128
127
  // Pango/GdkPixbuf/Graphene are NOT linked — the conformance programs load their
129
128
  // typelibs at RUNTIME (girepository dlopens the DLL by soname), not at link time.
130
- const DIRECT_LIBS = [
131
- 'girepository-2.0',
132
- 'gio-2.0',
133
- 'gobject-2.0',
134
- 'gmodule-2.0',
135
- 'glib-2.0',
136
- 'ffi',
137
- 'cairo',
138
- ];
129
+ const DIRECT_LIBS = ['girepository-2.0', 'gio-2.0', 'gobject-2.0', 'gmodule-2.0', 'glib-2.0', 'ffi', 'cairo'];
139
130
  // Locate the import-lib dir: GTK_PREFIX/lib, else pkg-config's -L.
140
131
  const searchDirs = [];
141
132
  if (process.env.GTK_PREFIX) searchDirs.push(join(process.env.GTK_PREFIX, 'lib'));
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
+ }
package/system.d.ts CHANGED
@@ -30,19 +30,19 @@ export function dumpMemoryInfo(): void;
30
30
 
31
31
  /** The GJS `System` module object (`import System from 'system'`). */
32
32
  export interface SystemModule {
33
- exit(code?: number): void;
34
- gc(): void;
35
- version: number;
36
- readonly programArgs: string[];
37
- readonly programInvocationName: string;
38
- readonly programPath: string | null;
39
- addressOf(): string;
40
- addressOfGObject(): string;
41
- refcount(): number;
42
- breakpoint(): void;
43
- clearDateCaches(): void;
44
- dumpHeap(): void;
45
- dumpMemoryInfo(): void;
33
+ exit(code?: number): void;
34
+ gc(): void;
35
+ version: number;
36
+ readonly programArgs: string[];
37
+ readonly programInvocationName: string;
38
+ readonly programPath: string | null;
39
+ addressOf(): string;
40
+ addressOfGObject(): string;
41
+ refcount(): number;
42
+ breakpoint(): void;
43
+ clearDateCaches(): void;
44
+ dumpHeap(): void;
45
+ dumpMemoryInfo(): void;
46
46
  }
47
47
 
48
48
  declare const System: SystemModule;
package/system.js CHANGED
@@ -35,14 +35,14 @@
35
35
  // module is loaded from `@gjsify/node-gi/globals`, before any bundle body (and
36
36
  // therefore before any register side effect) runs.
37
37
  const runtimeProcess = (() => {
38
- const hostProcess = typeof process !== 'undefined' ? process : undefined;
39
- try {
40
- const builtin = hostProcess?.getBuiltinModule?.('node:process');
41
- if (builtin && typeof builtin.exit === 'function') return builtin;
42
- } catch {
43
- // getBuiltinModule missing or refusing the specifier — use the captured one.
44
- }
45
- return hostProcess;
38
+ const hostProcess = typeof process !== 'undefined' ? process : undefined;
39
+ try {
40
+ const builtin = hostProcess?.getBuiltinModule?.('node:process');
41
+ if (builtin && typeof builtin.exit === 'function') return builtin;
42
+ } catch {
43
+ // getBuiltinModule missing or refusing the specifier — use the captured one.
44
+ }
45
+ return hostProcess;
46
46
  })();
47
47
 
48
48
  const runtimeExit = typeof runtimeProcess?.exit === 'function' ? runtimeProcess.exit.bind(runtimeProcess) : undefined;
@@ -50,17 +50,17 @@ const runtimeExit = typeof runtimeProcess?.exit === 'function' ? runtimeProcess.
50
50
  // `programArgs` mirrors GJS's ARGV: the script arguments, excluding the
51
51
  // interpreter (argv[0]) and the script path (argv[1]).
52
52
  function readProgramArgs() {
53
- return Array.isArray(runtimeProcess?.argv) ? runtimeProcess.argv.slice(2) : [];
53
+ return Array.isArray(runtimeProcess?.argv) ? runtimeProcess.argv.slice(2) : [];
54
54
  }
55
55
 
56
56
  // `programInvocationName` / `programPath` track the running script — Node's
57
57
  // `process.argv[1]`.
58
58
  function readProgramInvocationName() {
59
- return runtimeProcess?.argv?.[1] || '';
59
+ return runtimeProcess?.argv?.[1] || '';
60
60
  }
61
61
 
62
62
  function readProgramPath() {
63
- return runtimeProcess?.argv?.[1] || null;
63
+ return runtimeProcess?.argv?.[1] || null;
64
64
  }
65
65
 
66
66
  /**
@@ -74,12 +74,12 @@ function readProgramPath() {
74
74
  * `conformance/programs/system-exit-lifetime.conf.mjs`).
75
75
  */
76
76
  export function exit(code) {
77
- if (runtimeExit !== undefined) runtimeExit(code ?? 0);
77
+ if (runtimeExit !== undefined) runtimeExit(code ?? 0);
78
78
  }
79
79
 
80
80
  /** Trigger a garbage collection if the host exposed `globalThis.gc` (`--expose-gc`). */
81
81
  export function gc() {
82
- if (typeof globalThis.gc === 'function') globalThis.gc();
82
+ if (typeof globalThis.gc === 'function') globalThis.gc();
83
83
  }
84
84
 
85
85
  /** The SpiderMonkey/mozjs version number — no equivalent on Node, reported as 0. */
@@ -96,17 +96,17 @@ export const programPath = readProgramPath();
96
96
 
97
97
  /** Return the address of a JS object as a string. No Node equivalent — stub. */
98
98
  export function addressOf() {
99
- return '0x0';
99
+ return '0x0';
100
100
  }
101
101
 
102
102
  /** Return the address of a GObject as a string. No Node equivalent — stub. */
103
103
  export function addressOfGObject() {
104
- return '0x0';
104
+ return '0x0';
105
105
  }
106
106
 
107
107
  /** Return the refcount of a GObject. No Node equivalent — stub. */
108
108
  export function refcount() {
109
- return 0;
109
+ return 0;
110
110
  }
111
111
 
112
112
  /** Trigger a debugger breakpoint. No-op on Node. */
@@ -128,25 +128,25 @@ export function dumpMemoryInfo() {}
128
128
  * track the running script.
129
129
  */
130
130
  const System = {
131
- exit,
132
- gc,
133
- version,
134
- addressOf,
135
- addressOfGObject,
136
- refcount,
137
- breakpoint,
138
- clearDateCaches,
139
- dumpHeap,
140
- dumpMemoryInfo,
141
- get programArgs() {
142
- return readProgramArgs();
143
- },
144
- get programInvocationName() {
145
- return readProgramInvocationName();
146
- },
147
- get programPath() {
148
- return readProgramPath();
149
- },
131
+ exit,
132
+ gc,
133
+ version,
134
+ addressOf,
135
+ addressOfGObject,
136
+ refcount,
137
+ breakpoint,
138
+ clearDateCaches,
139
+ dumpHeap,
140
+ dumpMemoryInfo,
141
+ get programArgs() {
142
+ return readProgramArgs();
143
+ },
144
+ get programInvocationName() {
145
+ return readProgramInvocationName();
146
+ },
147
+ get programPath() {
148
+ return readProgramPath();
149
+ },
150
150
  };
151
151
 
152
152
  export default System;