@gjsify/node-gi 0.27.0 → 0.28.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 +43 -1
- package/gi.js +68 -7
- package/gtk-runtime.js +55 -15
- package/package.json +4 -2
- package/prebuilds/darwin-arm64/node_gi.node +0 -0
- package/prebuilds/darwin-x64/node_gi.node +0 -0
- package/prebuilds/linux-arm64/node_gi.node +0 -0
- package/prebuilds/linux-x64/node_gi.node +0 -0
- package/prebuilds/win32-x64/node_gi.node +0 -0
- package/src/template.cc +24 -6
- package/system-gi.d.ts +20 -0
- package/system-gi.js +209 -0
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
|
|
@@ -682,7 +721,10 @@ property get/set through `org.freedesktop.DBus.Properties`, a throwing method
|
|
|
682
721
|
returning a DBus error, `emit_signal`, `emit_property_changed` updating a
|
|
683
722
|
proxy's cached property, and unexport — runs byte-identical to gjs
|
|
684
723
|
(`dbus/export-scenario.mjs`, cross-checked against `gjs -m` under
|
|
685
|
-
`dbus-run-session` by `npm run test:dbus
|
|
724
|
+
`dbus-run-session` by `npm run test:dbus`, which passes
|
|
725
|
+
`--config-file=test/session.conf` — a plain `unix:tmpdir=/tmp` bus, because
|
|
726
|
+
dbus-run-session's DEFAULT config cannot start on macOS, where Homebrew's dbus
|
|
727
|
+
listens on launchd). Lifetime: the registration ref+sinks
|
|
686
728
|
its closures, so the service object lives exactly as long as the registration
|
|
687
729
|
(surviving GC with no JS references) and becomes collectable after
|
|
688
730
|
`unexport()` — guarded by the `--expose-gc` leg of the dbus suite. A method
|
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
|
|
1960
|
-
//
|
|
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
|
|
1964
|
-
return
|
|
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(
|
|
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
|
|
67
|
-
*
|
|
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.
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
101
|
-
// covered) — see the top-of-file note; keeps it out of non-darwin
|
|
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
|
-
|
|
108
|
-
|
|
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.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "GObject-Introspection runtime for Node.js — load gi:// namespaces (GLib/GObject/Gio/…) with GJS-compatible semantics. Vendored + rewritten from node-gtk under MIT.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -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",
|
|
@@ -86,7 +88,7 @@
|
|
|
86
88
|
"test": "NODE_GI_NATIVE=build node --test",
|
|
87
89
|
"test:node": "NODE_GI_NATIVE=build node --test",
|
|
88
90
|
"test:gc": "NODE_GI_NATIVE=build node --test --expose-gc",
|
|
89
|
-
"test:dbus": "dbus-run-session -- env NODE_GI_NATIVE=build node --test --expose-gc test/dbus.test.mjs test/dbus-async.test.mjs",
|
|
91
|
+
"test:dbus": "dbus-run-session --config-file=test/session.conf -- env NODE_GI_NATIVE=build node --test --expose-gc test/dbus.test.mjs test/dbus-async.test.mjs",
|
|
90
92
|
"test:bun": "node scripts/cross-runtime.mjs bun",
|
|
91
93
|
"test:conformance": "node scripts/conformance.mjs",
|
|
92
94
|
"test:conformance:gjs-napi": "node scripts/conformance.mjs --runtimes=gjs-napi",
|
|
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).
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
|
|
26
|
-
|
|
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
|
+
}
|