@gjsify/node-gi 0.30.0 → 0.32.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/gtk-runtime.js CHANGED
@@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url';
12
12
  import { dirname, join } from 'node:path';
13
13
  import { existsSync } from 'node:fs';
14
14
  import { pathCovers, splitSearchPath, systemGiLibraryDirs } from './system-gi.js';
15
+ import { nativeCandidates, prebuildAddonPath } from './native-paths.js';
15
16
 
16
17
  // NB: node:child_process is intentionally NOT imported at module top level. It is
17
18
  // pulled in lazily (via `require` below) ONLY on the darwin re-exec path — importing
@@ -63,6 +64,167 @@ export function resolveGtkRuntimeBundle() {
63
64
  return null;
64
65
  }
65
66
 
67
+ // ---- WHICH GTK WINS ---------------------------------------------------------
68
+ //
69
+ // Two GTKs can be reachable at once: a batteries-included bundle
70
+ // (@gjsify/gtk-runtime-<os>-<arch>) and the host's own. Until now the answer was
71
+ // implicit — "a bundle, if one is present" — and the rule keeping that safe was
72
+ // "a bundle must NEVER be a dependency of @gjsify/node-gi", because #910 made it
73
+ // one and a CI job that had COMPILED the addon against Homebrew GTK then re-exec'd
74
+ // onto the BUNDLE's typelibs: wrong method entries, then a 29-minute timeout
75
+ // (#920 reverted it). That rule protected the from-source case and left every end
76
+ // user on Windows with no GTK at all (#1063) — nothing installs the bundle there
77
+ // and Windows ships no system GTK.
78
+ //
79
+ // The repair separates two things that were tangled:
80
+ //
81
+ // • WHO INSTALLS IT is not this package's business. node-gi still declares no
82
+ // bundle — whoever SHIPS an app declares it (for the showcases that is the
83
+ // gjsify CLI, which already supplies @gjsify/node-gi the same way). A
84
+ // from-source build therefore never receives a bundle it did not ask for,
85
+ // which is #910's accident removed at the source rather than caught later.
86
+ //
87
+ // • WHICH ONE WINS when both are present is a POLICY — stated per-OS, and
88
+ // overridable.
89
+ //
90
+ // PER-OS DEFAULTS, and why they are spelled out rather than derived. The cheaper
91
+ // rule "prefer a bundle wherever we ship one" gives identical answers today,
92
+ // since bundles exist exactly for the platforms without a dependable system
93
+ // stack. It is a trap: the day someone builds a Linux bundle (Flatpak, a CI
94
+ // image) that rule would SILENTLY flip Linux from system-first to bundle-first
95
+ // with nobody deciding it. Stating each default now means a new bundle cannot
96
+ // move an existing platform's behaviour.
97
+ //
98
+ // linux → system first. dnf/apt/pacman resolve GTK *and* its typelibs
99
+ // correctly; that is the combination the distro tested.
100
+ // win32 → bundle first. There is no system GTK; a host with gvsbuild has it
101
+ // deliberately and stays reachable as the fallback.
102
+ // darwin → bundle first. Homebrew is usually present yet measurably NOT
103
+ // findable (gjs's own rpath points into glib's keg alone), which is
104
+ // the reason the darwin bundle exists at all.
105
+ //
106
+ // `GJSIFY_GTK_PREFER=bundle|system` overrides. Deliberately NOT the same knob as
107
+ // `GJSIFY_GTK_RUNTIME`, which names a bundle DIRECTORY: where a bundle is and
108
+ // which source wins are two questions, and one variable answering both leaves a
109
+ // user unable to say "I have a bundle, use the system anyway".
110
+
111
+ /** @typedef {'bundle' | 'system' | 'none'} GtkSource */
112
+ /** @typedef {'prebuild' | 'source' | 'unknown'} AddonProvenance */
113
+
114
+ /** Per-OS default: which source is consulted FIRST. See the note above. */
115
+ export const DEFAULT_GTK_PREFERENCE = Object.freeze({
116
+ linux: 'system',
117
+ win32: 'bundle',
118
+ darwin: 'bundle',
119
+ });
120
+
121
+ /**
122
+ * Decide which GTK this process should use. PURE — every input is a parameter,
123
+ * so all three platforms' branches are executable from any host.
124
+ *
125
+ * The result is an ordered preference filtered by availability, never an
126
+ * exclusion: "prefer system" on a host with no system GTK still falls back to a
127
+ * bundle, because a preference that silently also means "and nothing else" turns
128
+ * a working setup into a hard failure.
129
+ *
130
+ * THE ONE HARD PART. A `source` addon was linked against whatever GTK the builder
131
+ * had, so pairing it with a bundle is not a preference but an ABI error — it is
132
+ * #910 exactly. The bundle is therefore dropped from the order for a from-source
133
+ * addon. `GJSIFY_GTK_PREFER=bundle` can still lift it, because building against a
134
+ * bundle on purpose is legitimate and an explicitly-set variable is consent;
135
+ * what #910 was is an accident nobody chose, and the DEFAULT must never
136
+ * reproduce it.
137
+ *
138
+ * @param {object} opts
139
+ * @param {NodeJS.Platform | string} opts.platform
140
+ * @param {boolean} opts.hasBundle a complete bundle is installed
141
+ * @param {boolean} opts.hasSystem a host GTK is worth trying (`hostGtkIsWorthTrying`)
142
+ * @param {AddonProvenance} [opts.provenance] which addon binary will load
143
+ * @param {string} [opts.override] raw `GJSIFY_GTK_PREFER` value, if any
144
+ * @returns {GtkSource}
145
+ */
146
+ export function decideGtkSource({ platform, hasBundle, hasSystem, provenance = 'unknown', override }) {
147
+ const bundleFirst =
148
+ override === 'bundle' ? true : override === 'system' ? false : DEFAULT_GTK_PREFERENCE[platform] !== 'system';
149
+ let order = bundleFirst ? ['bundle', 'system'] : ['system', 'bundle'];
150
+
151
+ if (provenance === 'source' && override !== 'bundle') {
152
+ order = order.filter((source) => source !== 'bundle');
153
+ }
154
+
155
+ const available = { bundle: hasBundle, system: hasSystem };
156
+ for (const source of order) {
157
+ if (available[source]) return source;
158
+ }
159
+ return 'none';
160
+ }
161
+
162
+ /**
163
+ * Which binary `loadNative()` will pick, WITHOUT loading it.
164
+ *
165
+ * Load-bearing on Windows: the DLL search path must be set BEFORE the addon is
166
+ * dlopen'd, so the provenance question cannot wait until we hold the loaded
167
+ * module. `nativeCandidates()` is a pure, ordered list and the same one the
168
+ * loader walks, so probing it here cannot disagree with what actually loads.
169
+ *
170
+ * An explicit `NODE_GI_NATIVE=<path>` pin answers `unknown`, not `source`: the
171
+ * caller named a binary and we cannot tell what it was built against, so the safe
172
+ * reading is that a deliberate choice does not get the from-source veto.
173
+ * @returns {AddonProvenance}
174
+ */
175
+ export function addonProvenance() {
176
+ const pinned = process.env.NODE_GI_NATIVE;
177
+ if (pinned && pinned !== 'build' && pinned !== 'prebuild') return 'unknown';
178
+ const prebuild = prebuildAddonPath();
179
+ for (const candidate of nativeCandidates()) {
180
+ if (!existsSync(candidate)) continue;
181
+ return candidate === prebuild ? 'prebuild' : 'source';
182
+ }
183
+ return 'unknown';
184
+ }
185
+
186
+ /**
187
+ * Is the HOST's own GI stack worth trying?
188
+ *
189
+ * Deliberately cheap and conservative rather than a probe that pretends to know.
190
+ * On linux and win32 "system" means "change nothing and let the OS loader do its
191
+ * job", which is always worth attempting — and on win32 the failure that follows
192
+ * is what produces the actionable diagnosis in `index.js` instead of a raw OS
193
+ * string. Only darwin has a real question, because there the loader needs the
194
+ * libdirs handed to it and `systemGiLibraryDirs()` is what finds them.
195
+ * @param {NodeJS.Platform | string} [platform]
196
+ * @returns {boolean}
197
+ */
198
+ export function hostGtkIsWorthTrying(platform = process.platform) {
199
+ if (platform !== 'darwin') return true;
200
+ return systemGiLibraryDirs().length > 0;
201
+ }
202
+
203
+ let decidedSource = null;
204
+
205
+ /**
206
+ * The decision for THIS process, memoized — every entry point below must reach
207
+ * the same answer, and re-deciding per call is how two of them drift apart.
208
+ * @returns {GtkSource}
209
+ */
210
+ export function gtkSource() {
211
+ if (decidedSource === null) {
212
+ decidedSource = decideGtkSource({
213
+ platform: process.platform,
214
+ hasBundle: resolveGtkRuntimeBundle() !== null,
215
+ hasSystem: hostGtkIsWorthTrying(),
216
+ provenance: addonProvenance(),
217
+ override: process.env.GJSIFY_GTK_PREFER,
218
+ });
219
+ }
220
+ return decidedSource;
221
+ }
222
+
223
+ /** TEST-ONLY: drop the memoized decision so a spec can vary the environment. */
224
+ export function resetGtkSourceForTests() {
225
+ decidedSource = null;
226
+ }
227
+
66
228
  /**
67
229
  * Make GTK genuinely env-free on macOS by RE-EXECING this process once with
68
230
  * `DYLD_FALLBACK_LIBRARY_PATH` pointing at the dylibs a typelib names by bare
@@ -127,9 +289,11 @@ export function maybeReexecForGtkRuntime() {
127
289
  if (typeof globalThis.imports !== 'undefined' && typeof globalThis.print === 'function') return;
128
290
  if (process.env[REEXEC_SENTINEL]) return; // already re-exec'd — the child path
129
291
 
130
- // A bundle, when installed, is the whole answer and stays the ONLY entry (see
131
- // the #920 note above). Without one, the host's own GI libdirs are the answer.
132
- const bundle = resolveGtkRuntimeBundle();
292
+ // A bundle THE POLICY CHOSE is the whole answer and stays the ONLY entry (see
293
+ // the #920 note above). Otherwise the host's own GI libdirs are the answer —
294
+ // which is also what a from-source addon gets, since `gtkSource()` refuses it
295
+ // a bundle by default.
296
+ const bundle = gtkSource() === 'bundle' ? resolveGtkRuntimeBundle() : null;
133
297
  const libDirs = bundle ? [bundle.libDir] : systemGiLibraryDirs();
134
298
  if (libDirs.length === 0) return; // no bundle and no host GI stack — nothing to point at
135
299
 
@@ -180,6 +344,9 @@ export function maybeReexecForGtkRuntime() {
180
344
  */
181
345
  export function maybePrependGtkRuntimeDllPath() {
182
346
  if (process.platform !== 'win32') return; // strict no-op on every non-win32 runtime
347
+ // Gated on the POLICY, not on mere presence: an installed bundle does not win
348
+ // for a from-source addon, and `GJSIFY_GTK_PREFER=system` opts out entirely.
349
+ if (gtkSource() !== 'bundle') return;
183
350
  const bundle = resolveGtkRuntimeBundle();
184
351
  if (!bundle) return; // strict no-op when no bundle is present
185
352
  const binDir = bundle.libDir; // on win32 the native-code dir is gtk/bin (the DLLs)
@@ -213,6 +380,9 @@ export function maybeWireGtkWindowingEnv() {
213
380
  // so setting them in-process works on both. The only platform difference is the
214
381
  // XDG_DATA_DIRS separator (';' on win32, ':' on unix/darwin).
215
382
  if (process.platform !== 'win32' && process.platform !== 'darwin') return;
383
+ // Same gate as the DLL/dylib path: the runtime DATA belongs to the bundle, so
384
+ // it is wired only when the bundle is the source the policy picked.
385
+ if (gtkSource() !== 'bundle') return;
216
386
  const bundle = resolveGtkRuntimeBundle();
217
387
  if (!bundle) return; // strict no-op when no bundle is present
218
388
 
@@ -284,6 +454,7 @@ export function activateBundledGtkRuntime(native) {
284
454
  if (activated !== null) return activated || null;
285
455
  activated = false;
286
456
  if (process.platform !== 'darwin' && process.platform !== 'win32') return null;
457
+ if (gtkSource() !== 'bundle') return null; // the policy chose the host's GTK
287
458
 
288
459
  const bundle = resolveGtkRuntimeBundle();
289
460
  if (!bundle) return null;
package/index.js CHANGED
@@ -13,6 +13,7 @@
13
13
  import { createRequire } from 'node:module';
14
14
  import { existsSync } from 'node:fs';
15
15
  import { hostTarget, nativeCandidates, packageRoot } from './native-paths.js';
16
+ import { describeAddonLoadFailure } from './load-diagnostics.js';
16
17
  import {
17
18
  activateBundledGtkRuntime,
18
19
  maybePrependGtkRuntimeDllPath,
@@ -100,7 +101,20 @@ function gjsLoadAddon(path) {
100
101
  function loadNative() {
101
102
  for (const candidate of nativeCandidates()) {
102
103
  if (existsSync(candidate)) {
103
- return RUNTIME === 'gjs' ? gjsLoadAddon(candidate) : require(candidate);
104
+ try {
105
+ return RUNTIME === 'gjs' ? gjsLoadAddon(candidate) : require(candidate);
106
+ } catch (err) {
107
+ // The addon FILE is here and still would not load. On win32 and
108
+ // darwin that means its GTK dependency closure was not found, and
109
+ // the raw error names only the .node — in the OS's language, about
110
+ // a file that plainly exists. Reported from a clean Windows host as
111
+ // `Error: Das angegebene Modul wurde nicht gefunden.` pointing at a
112
+ // present node_gi.node (#1063), which names nothing a user can act
113
+ // on. Same class as PR #994, where a missing load-time library
114
+ // surfaced as "Unsupported type void, deriving from fundamental
115
+ // void". The cause is knowable here, so say it.
116
+ throw new Error(describeAddonLoadFailure(candidate, err), { cause: err });
117
+ }
104
118
  }
105
119
  }
106
120
  throw new Error(
@@ -0,0 +1,83 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // @gjsify/node-gi/load-diagnostics — what a user is told when the addon FILE is
3
+ // present and still will not load.
4
+ //
5
+ // A MODULE OF ITS OWN, and that is the point: `index.js` loads the native addon
6
+ // at evaluation time, so anything living there can only be imported by a process
7
+ // that already succeeded at the thing being diagnosed. The message has to be
8
+ // reachable — and testable — without a working addon.
9
+ //
10
+ // Pure over its inputs (platform, decided GTK source, target are parameters), so
11
+ // the Windows and macOS wording is assertable from a Linux host. Neither has ever
12
+ // been printed by a job in this repository.
13
+ import { hostTarget } from './native-paths.js';
14
+ import { gtkSource } from './gtk-runtime.js';
15
+
16
+ /**
17
+ * Turn a failed addon load into something the reader can act on.
18
+ *
19
+ * PURE over its inputs (platform + the decided GTK source are parameters) so all
20
+ * three platforms' wording is assertable from any host — the Windows message in
21
+ * particular is the one no CI job on this project has ever printed.
22
+ *
23
+ * @param {string} addonPath the .node that failed to load
24
+ * @param {unknown} err the original loader error
25
+ * @param {object} [ctx]
26
+ * @param {NodeJS.Platform | string} [ctx.platform]
27
+ * @param {'bundle' | 'system' | 'none'} [ctx.source] which GTK the policy picked
28
+ * @param {string} [ctx.target] the `<os>-<arch>` tag
29
+ * @returns {string}
30
+ */
31
+ export function describeAddonLoadFailure(addonPath, err, ctx = {}) {
32
+ const platform = ctx.platform ?? process.platform;
33
+ const source = ctx.source ?? gtkSource();
34
+ const target = ctx.target ?? hostTarget();
35
+ const reason = err instanceof Error ? err.message : String(err);
36
+
37
+ const lines = [
38
+ `@gjsify/node-gi: the native addon exists but could not be loaded.`,
39
+ ` addon: ${addonPath}`,
40
+ ` cause: ${reason}`,
41
+ '',
42
+ ];
43
+
44
+ if (platform === 'win32' || platform === 'darwin') {
45
+ const libs = platform === 'win32' ? 'DLLs' : 'dylibs';
46
+ lines.push(
47
+ `The file is present, so this is almost always its GTK/GObject-Introspection`,
48
+ `dependency closure (glib, gobject, gio, girepository, …) not being found —`,
49
+ `not the addon itself. GTK source selected for this process: ${source}.`,
50
+ '',
51
+ );
52
+ if (source !== 'bundle') {
53
+ lines.push(
54
+ `Install the batteries-included runtime, which ships those ${libs}:`,
55
+ ` npm install @gjsify/gtk-runtime-${target}`,
56
+ `or point GJSIFY_GTK_RUNTIME at an existing bundle directory.`,
57
+ '',
58
+ );
59
+ } else {
60
+ lines.push(
61
+ `A bundle WAS selected, so it is likely incomplete for this target.`,
62
+ `Reinstall @gjsify/gtk-runtime-${target}, or set GJSIFY_GTK_PREFER=system`,
63
+ `to use the host's own GTK instead.`,
64
+ '',
65
+ );
66
+ }
67
+ if (platform === 'win32') {
68
+ lines.push(
69
+ `Also required on Windows and easy to miss: the Visual C++ redistributable`,
70
+ `(vcruntime140.dll / vcruntime140_1.dll / msvcp140.dll). \`gjsify system-check\``,
71
+ `reports whether it is present.`,
72
+ );
73
+ }
74
+ } else {
75
+ lines.push(
76
+ `Install your distribution's GTK4 + GObject-Introspection runtime`,
77
+ `(e.g. \`dnf install gtk4 gobject-introspection\`). \`gjsify system-check\``,
78
+ `lists what is missing.`,
79
+ );
80
+ }
81
+
82
+ return lines.join('\n');
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/node-gi",
3
- "version": "0.30.0",
3
+ "version": "0.32.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,7 @@
44
44
  "gi.d.ts",
45
45
  "gtk-runtime.js",
46
46
  "gtk-runtime.d.ts",
47
+ "load-diagnostics.js",
47
48
  "system-gi.js",
48
49
  "system-gi.d.ts",
49
50
  "globals.js",
Binary file
Binary file
Binary file
package/system-gi.js CHANGED
@@ -55,7 +55,16 @@
55
55
  // eventual lift to a shared package is tracked in `status/open-todos.md`.
56
56
  import { statSync } from 'node:fs';
57
57
  import { createRequire } from 'node:module';
58
- import { basename, dirname, join } from 'node:path';
58
+ // POSIX path semantics, not the HOST's. This function answers a question about
59
+ // a darwin filesystem — `platform` is a PARAMETER, so the host running the code
60
+ // may be any of the three. Node's default `join`/`dirname`/`basename` follow the
61
+ // host, so on win32 `join('/usr/local/lib', 'girepository-1.0')` becomes
62
+ // `\usr\local\lib\girepository-1.0` and never matches the darwin path the
63
+ // caller (or the injected `existsDir`) is holding. Measured on the Windows leg:
64
+ // three `systemGiLibraryDirs` specs returned `[]` where they assert a prefix.
65
+ // `PROBED_GI_LIBDIRS` is darwin-only, so POSIX is always the right dialect here.
66
+ import { posix } from 'node:path';
67
+ const { basename, dirname, join } = posix;
59
68
 
60
69
  const require = createRequire(import.meta.url);
61
70