ntk 7.1.0 → 7.3.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/lib/app.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import Clipboard from './clipboard.js';
2
2
  import { CursorCache } from './cursor.js';
3
+ import { GLError, backendFor, glCapabilities, glError, resolveGLPolicy } from './gl.js';
3
4
  import { chooseGLXConfig } from './glx.js';
4
5
  import Picture from './picture.js';
5
6
  import Pixmap from './pixmap.js';
@@ -265,6 +266,39 @@ export default class App {
265
266
  : DEFAULT_RASTER_POLICY;
266
267
  }
267
268
 
269
+ /**
270
+ * Which OpenGL backend windows on this connection draw through, merged over
271
+ * `DEFAULT_GL_POLICY` and overridden by `NTK_GL_POLICY` — see
272
+ * docs/context-gles.md. The default is `'indirect'`.
273
+ *
274
+ * Recomputed rather than cached, so raising it at run time works the way
275
+ * `rasterPolicy` does. A policy raised to `auto`/`direct` after connecting
276
+ * has missed the probe `createClient` would have run for it, so one
277
+ * `await app.glCapabilities()` is needed before a context is created.
278
+ */
279
+ get glPolicy() {
280
+ return resolveGLPolicy(this.options);
281
+ }
282
+
283
+ /**
284
+ * What GL this connection can really do: `{ direct, indirect, device,
285
+ * reason }`, where `reason` is the coded error explaining a false `direct`
286
+ * (see `GLError`). Asked once and cached.
287
+ *
288
+ * `createClient` warms this during the handshake whenever the policy could
289
+ * choose the direct backend — that is what lets `getContext` pick one
290
+ * synchronously afterwards.
291
+ *
292
+ * @returns {Promise<{direct: boolean, indirect: boolean,
293
+ * device: string|null, reason: Error|null}>}
294
+ */
295
+ glCapabilities() {
296
+ return glCapabilities(this).then((caps) => {
297
+ this._glCapsResolved = caps;
298
+ return caps;
299
+ });
300
+ }
301
+
268
302
  /** selection/clipboard transfer: write()/read() text (docs/clipboard.md) */
269
303
  get clipboard() {
270
304
  if (!this._clipboard) this._clipboard = new Clipboard(this);
@@ -305,6 +339,53 @@ export default class App {
305
339
  return chooseGLXConfig(this, spec);
306
340
  }
307
341
 
342
+ /**
343
+ * The same question, asked of whichever backend `glPolicy` selects: what to
344
+ * create a GL window with. Resolves with a `chooseGLXConfig` object plus
345
+ * `backend` (`'direct'` or `'indirect'`) — pass `visual`/`depth` to
346
+ * `createWindow` and the whole object to `wnd.getContext('opengl', config)`.
347
+ *
348
+ * The spec is GLX's attribute vocabulary either way, because a caller
349
+ * should not have to write the request twice: `DEPTH_SIZE` becomes the EGL
350
+ * depth-buffer size on the direct backend, and `ALPHA_SIZE` picks an ARGB
351
+ * visual there rather than an fbconfig with an alpha channel. Direct needs
352
+ * no round trip to answer — there are no fbconfigs in it, only a window the
353
+ * GPU's buffers can be copied or flipped into.
354
+ *
355
+ * @param {object} [spec] GLX attributes, e.g. `{ DEPTH_SIZE: 24 }`
356
+ */
357
+ async chooseGLConfig(spec = {}) {
358
+ const caps = this._glCapsResolved ?? (await this.glCapabilities());
359
+ const backend = backendFor(this);
360
+ if (backend === 'off') throw caps.reason ?? new Error('ntk: no GL backend is available');
361
+ if (backend !== 'direct') return { ...(await chooseGLXConfig(this, spec)), backend: 'indirect' };
362
+
363
+ const screenNum = spec.screen ?? 0;
364
+ const screen = this.display.screen[screenNum];
365
+ const wantAlpha = (spec.ALPHA_SIZE ?? 0) > 0;
366
+ const argb = wantAlpha ? this.findArgbVisual(screenNum) : null;
367
+ if (wantAlpha && !argb) {
368
+ throw glError(
369
+ GLError.CONTEXT_FAILED,
370
+ `direct rendering: screen ${screenNum} has no 32-bit visual, so a GL window cannot have an alpha channel`
371
+ );
372
+ }
373
+ return {
374
+ backend: 'direct',
375
+ // depth 24 on the root visual needs no colormap of its own, which is
376
+ // why an opaque GL window here is an ordinary window
377
+ visual: argb?.visual ?? screen.root_visual,
378
+ depth: argb?.depth ?? screen.root_depth,
379
+ class: 4, // TrueColor; the only class these buffers can be read as
380
+ doubleBuffer: true, // a swap chain, always
381
+ depthSize: spec.DEPTH_SIZE ?? 16,
382
+ screen: screenNum,
383
+ fbconfig: null,
384
+ device: caps.device,
385
+ config: {}
386
+ };
387
+ }
388
+
308
389
  /**
309
390
  * Find a 32-bit TrueColor visual with an alpha channel, for per-pixel
310
391
  * transparent windows (ARGB). Returns `{ visual, depth: 32 }` — pass
@@ -420,6 +501,20 @@ export default class App {
420
501
  picture._sourcePixmap?.destroy();
421
502
  }
422
503
  this._solidPictures.clear();
504
+ // GPU contexts are shared by every direct GL surface on this connection
505
+ // (see renderingcontext_gles.js), so they outlive individual contexts and
506
+ // are the connection's to release. Each holds an EGL display, a GBM device
507
+ // and an open render node.
508
+ if (this._glGpus) {
509
+ for (const gpu of this._glGpus.values()) {
510
+ try {
511
+ gpu.destroy();
512
+ } catch {
513
+ // already torn down, or the driver is gone
514
+ }
515
+ }
516
+ this._glGpus.clear();
517
+ }
423
518
  return new Promise((resolve) => this.X.close(resolve));
424
519
  }
425
520
 
package/lib/gl.js ADDED
@@ -0,0 +1,321 @@
1
+ // Which OpenGL backend a window draws through, and whether it can.
2
+ //
3
+ // ntk has two, and they are different pipelines rather than two spellings of
4
+ // one:
5
+ //
6
+ // - **indirect GLX** (lib/renderingcontext_opengl.js) — GL commands encoded
7
+ // into the X connection. Reaches any server that allows indirect contexts,
8
+ // including over a network, and is a fixed-function OpenGL 1.x pipeline
9
+ // with no shaders, because that is what the GLX protocol encodes.
10
+ // - **direct** (lib/renderingcontext_gles.js) — a GPU render node draws the
11
+ // frame, and the finished buffer reaches the server as a dma-buf
12
+ // descriptor over DRI3 + Present. OpenGL ES 2 with real shaders, and no
13
+ // pixels on the socket; local connections only, and it needs the optional
14
+ // `x11-dri` addon.
15
+ //
16
+ // Which one runs is `glPolicy`, and the default is `indirect` — the backend
17
+ // that has always run. See docs/context-gles.md.
18
+ //
19
+ // This module is the decision and nothing else: what is available, what the
20
+ // caller asked for, and what that resolves to. Neither context imports the
21
+ // other, and the direct one is only ever loaded when the answer is `direct`.
22
+
23
+ import { nodeRequire } from './builtin.js';
24
+
25
+ /**
26
+ * `err.code` on failures of the direct path, alongside `GLXError` for the
27
+ * indirect one. Every code names a distinct remedy:
28
+ *
29
+ * - `GL_DISABLED` — `glPolicy: 'off'`; nothing tried.
30
+ * - `GL_NO_ADDON` — the optional `x11-dri` addon is not installed or would
31
+ * not load. `npm install x11-dri`.
32
+ * - `GL_NO_DRIVER` — the addon loaded but the GPU libraries it needs are
33
+ * missing (`libgbm`, `libEGL`, `libGLESv2`), or this is not Linux.
34
+ * - `GL_NO_DEVICE` — no readable DRM render node (`/dev/dri/renderD*`).
35
+ * - `GL_REMOTE_DISPLAY` — the connection cannot carry descriptors: a TCP or
36
+ * forwarded display, so there is no way to hand the server a buffer.
37
+ * - `GL_NO_DRI3` — the server has no DRI3/Present (Xvfb, Xephyr, XQuartz).
38
+ * - `GL_IMPORT_FAILED` — the server refused the buffer; usually client and
39
+ * server on different DRM devices.
40
+ * - `GL_CONTEXT_FAILED` — GBM/EGL setup failed for some other reason.
41
+ */
42
+ export const GLError = {
43
+ DISABLED: 'GL_DISABLED',
44
+ NO_ADDON: 'GL_NO_ADDON',
45
+ NO_DRIVER: 'GL_NO_DRIVER',
46
+ NO_DEVICE: 'GL_NO_DEVICE',
47
+ REMOTE_DISPLAY: 'GL_REMOTE_DISPLAY',
48
+ NO_DRI3: 'GL_NO_DRI3',
49
+ IMPORT_FAILED: 'GL_IMPORT_FAILED',
50
+ CONTEXT_FAILED: 'GL_CONTEXT_FAILED'
51
+ };
52
+
53
+ export function glError(code, message, hint, cause) {
54
+ const err = new Error(message);
55
+ err.code = code;
56
+ if (hint) err.hint = hint;
57
+ if (cause) err.cause = cause;
58
+ return err;
59
+ }
60
+
61
+ /**
62
+ * The backend choice, and the knobs the direct one has.
63
+ *
64
+ * `mode` is the whole decision:
65
+ * - `'indirect'` (default) — indirect GLX, the only backend before 7.x.
66
+ * - `'auto'` — direct where everything for it is present, indirect otherwise.
67
+ * - `'direct'` — direct or nothing; `getContext` fails with a coded error
68
+ * rather than quietly running a fixed-function pipeline instead.
69
+ * - `'off'` — no GL at all.
70
+ *
71
+ * The default is deliberately not `'auto'`: the two backends expose different
72
+ * GL APIs (see docs/context-gles.md), so switching under an app that never
73
+ * asked would break its draw code. Opt in per app, or per run with
74
+ * `NTK_GL_POLICY`.
75
+ */
76
+ export const DEFAULT_GL_POLICY = {
77
+ mode: 'indirect',
78
+ // which render node to draw on; null picks the first usable one
79
+ devicePath: null,
80
+ // presents allowed in flight before a frame waits for a buffer to come back
81
+ maxInFlight: 2,
82
+ // retry a refused buffer import once with a linear layout, which is what
83
+ // makes cross-device (render on one GPU, display on another) work
84
+ linearFallback: true
85
+ };
86
+
87
+ export const GL_MODES = ['auto', 'direct', 'indirect', 'off'];
88
+
89
+ function badMode(mode, source) {
90
+ return new Error(
91
+ `ntk: ${source} is ${JSON.stringify(mode)}, which is not a GL policy mode — use one of ${GL_MODES.join(', ')} (see docs/context-gles.md)`
92
+ );
93
+ }
94
+
95
+ /**
96
+ * The effective policy for an app: defaults, then `options.glPolicy` (a mode
97
+ * string is sugar for `{ mode }`), then `NTK_GL_POLICY`.
98
+ *
99
+ * The environment wins on purpose. Its job is running one build both ways —
100
+ * `NTK_GL_POLICY=direct npm start` against the same app that ships
101
+ * `'indirect'` — which it cannot do if the code overrides it.
102
+ */
103
+ export function resolveGLPolicy(options = {}) {
104
+ const given = options.glPolicy;
105
+ const asObject = typeof given === 'string' ? { mode: given } : given;
106
+ if (asObject && typeof asObject !== 'object') throw badMode(given, 'glPolicy');
107
+ const policy = { ...DEFAULT_GL_POLICY, ...asObject };
108
+
109
+ const fromEnv = globalThis.process?.env?.NTK_GL_POLICY;
110
+ if (fromEnv) policy.mode = fromEnv;
111
+
112
+ if (!GL_MODES.includes(policy.mode)) {
113
+ throw badMode(policy.mode, fromEnv ? 'NTK_GL_POLICY' : 'glPolicy.mode');
114
+ }
115
+ return policy;
116
+ }
117
+
118
+ /** Does this policy ever want the direct backend? */
119
+ export const wantsDirect = (policy) => policy.mode === 'auto' || policy.mode === 'direct';
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // the addon
123
+
124
+ let addon; // undefined = not tried, null = unavailable
125
+
126
+ /**
127
+ * The optional `x11-dri` addon, or null.
128
+ *
129
+ * Loaded through `nodeRequire` rather than imported: it is a native module and
130
+ * an optional dependency, so it is absent on plenty of machines that run ntk
131
+ * perfectly well, and a static import would make it a hard requirement of the
132
+ * package (and of any bundle built from it). Nothing here throws.
133
+ */
134
+ export function loadDriAddon() {
135
+ if (addon !== undefined) return addon;
136
+ addon = null;
137
+ try {
138
+ const require = nodeRequire();
139
+ if (require) addon = require('x11-dri');
140
+ } catch {
141
+ // not installed, wrong platform, or no loadable binary — all "no direct"
142
+ }
143
+ return addon;
144
+ }
145
+
146
+ /** Test seam: swap the addon (or clear the cache with `undefined`). */
147
+ export function setDriAddon(module) {
148
+ addon = module;
149
+ }
150
+
151
+ const INSTALL_HINT = `Direct rendering needs the optional native addon:
152
+
153
+ npm install x11-dri
154
+
155
+ It ships prebuilt binaries for linux x64/arm64 and macOS arm64, so no build
156
+ tools are needed; anything else compiles with node-gyp and a C toolchain. ntk
157
+ does not depend on it — without it, GL runs through indirect GLX.`;
158
+
159
+ /**
160
+ * What the *client side* can do, before any server is asked: the addon, the
161
+ * GPU libraries under it, and a render node to draw on. Never throws.
162
+ *
163
+ * @returns {{ok: boolean, code?: string, message?: string, hint?: string,
164
+ * device?: string, devices?: string[], probe?: object}}
165
+ */
166
+ export function probeDirect(policy = DEFAULT_GL_POLICY) {
167
+ const dri = loadDriAddon();
168
+ if (!dri) {
169
+ return {
170
+ ok: false,
171
+ code: GLError.NO_ADDON,
172
+ message: 'the x11-dri addon is not installed, so there is no way to produce GPU buffers',
173
+ hint: INSTALL_HINT
174
+ };
175
+ }
176
+
177
+ let probe;
178
+ try {
179
+ probe = dri.probe();
180
+ } catch (err) {
181
+ return { ok: false, code: GLError.NO_DRIVER, message: `x11-dri probe() failed: ${err.message}` };
182
+ }
183
+
184
+ // probe() reports each capability as `true` or as the string saying why not
185
+ const missing = ['gbm', 'egl', 'gles'].filter((key) => probe[key] !== true);
186
+ if (missing.length) {
187
+ const detail = missing.map((key) => `${key}: ${probe[key]}`).join('; ');
188
+ return {
189
+ ok: false,
190
+ code: GLError.NO_DRIVER,
191
+ message: `the GPU libraries direct rendering needs are unavailable (${detail})`,
192
+ hint:
193
+ probe.platform && probe.platform !== 'linux'
194
+ ? `Direct rendering is Linux-only — dma-buf, GBM and DRI3 have no equivalent on ${probe.platform}.`
195
+ : 'Install Mesa (libgbm1, libegl1, libgles2 on Debian/Ubuntu). They are dlopen()ed at\nrun time, so no rebuild of x11-dri is needed once they are there.',
196
+ probe
197
+ };
198
+ }
199
+
200
+ let devices = [];
201
+ try {
202
+ devices = dri.listRenderNodes();
203
+ } catch {
204
+ devices = [];
205
+ }
206
+ const device = policy.devicePath ?? devices[0] ?? null;
207
+ if (!device) {
208
+ return {
209
+ ok: false,
210
+ code: GLError.NO_DEVICE,
211
+ message: 'no readable DRM render node (/dev/dri/renderD*) — there is no GPU to render on',
212
+ hint:
213
+ 'A container usually needs the device mapped in (--device /dev/dri), and a bare\nmachine needs the user in the "render" (or "video") group.',
214
+ probe,
215
+ devices
216
+ };
217
+ }
218
+ return { ok: true, device, devices, probe };
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // the connection and the server
223
+
224
+ /**
225
+ * Can this connection carry a descriptor to the server at all?
226
+ *
227
+ * DRI3 hands the server a dma-buf over the socket with SCM_RIGHTS, which only
228
+ * a local unix socket can do. Everything remote — TCP, `ssh -X` — is out
229
+ * before any extension is queried, and this costs nothing to find out.
230
+ */
231
+ export function canPassDescriptors(display) {
232
+ const stream = display?.client?.stream;
233
+ return !!(display?.isLocalSocket && stream?._fdCapable && typeof stream.sendFds === 'function');
234
+ }
235
+
236
+ const displayName = () => globalThis.process?.env?.DISPLAY || 'the X server';
237
+
238
+ const requireExt = (X, name) =>
239
+ new Promise((resolve) => X.require(name, (err, ext) => resolve(err ? null : ext)));
240
+
241
+ /**
242
+ * Everything about `app` that decides the backend, answered once and cached.
243
+ *
244
+ * The two extension queries are the only round trips, and they only happen
245
+ * under a policy that could use direct — `createClient` warms this during the
246
+ * connect handshake for exactly that reason, so `getContext` can decide
247
+ * synchronously afterwards.
248
+ *
249
+ * @returns {Promise<{direct: boolean, indirect: boolean, device: string|null,
250
+ * reason: Error|null, DRI3: object|null, Present: object|null}>}
251
+ */
252
+ export function glCapabilities(app) {
253
+ if (app._glCaps) return app._glCaps;
254
+ app._glCaps = (async () => {
255
+ const policy = app.glPolicy;
256
+ const indirect = !!app.display.GLX;
257
+ const fail = (code, message, hint) => ({
258
+ direct: false,
259
+ indirect,
260
+ device: null,
261
+ reason: glError(code, message, hint),
262
+ DRI3: null,
263
+ Present: null
264
+ });
265
+
266
+ if (policy.mode === 'off') {
267
+ return fail(GLError.DISABLED, "glPolicy is 'off', so no GL context will be created");
268
+ }
269
+ if (!canPassDescriptors(app.display)) {
270
+ return fail(
271
+ GLError.REMOTE_DISPLAY,
272
+ 'this X connection is not a local socket that can pass descriptors, and DRI3 works by passing one',
273
+ 'Direct rendering is local-only by construction. Over a network, indirect GLX is\n' +
274
+ 'the backend that can work at all — leave glPolicy at its default.'
275
+ );
276
+ }
277
+
278
+ const client = probeDirect(policy);
279
+ if (!client.ok) return fail(client.code, client.message, client.hint);
280
+
281
+ const X = app.X;
282
+ const [DRI3, Present] = await Promise.all([requireExt(X, 'dri3'), requireExt(X, 'present')]);
283
+ if (!DRI3 || !Present) {
284
+ const absent = [!DRI3 && 'DRI3', !Present && 'Present'].filter(Boolean).join(' and ');
285
+ return fail(
286
+ GLError.NO_DRI3,
287
+ `${displayName()} does not have ${absent}, so a GPU buffer cannot be turned into a pixmap or shown`,
288
+ 'Xorg with glamor and Xwayland both have them; Xvfb, Xephyr and XQuartz do not.\n' +
289
+ 'Indirect GLX is the backend that can work on such a server.'
290
+ );
291
+ }
292
+ if (!DRI3.fdCapable) {
293
+ return fail(
294
+ GLError.REMOTE_DISPLAY,
295
+ 'the x11 client cannot send descriptors on this connection (DRI3.fdCapable is false)',
296
+ 'x11 builds an fd-capable socket for local displays unless `shm: false` was passed\nto createClient.'
297
+ );
298
+ }
299
+
300
+ return { direct: true, indirect, device: client.device, reason: null, DRI3, Present };
301
+ })();
302
+ return app._glCaps;
303
+ }
304
+
305
+ /**
306
+ * The backend `getContext('opengl')` should use right now, synchronously.
307
+ *
308
+ * Synchronous because `getContext` is, and the async part (two extension
309
+ * queries) is warmed at connect time under any policy that could say
310
+ * `direct`. `null` means "not known yet" — a policy raised to `auto` after
311
+ * connect, before `await app.glCapabilities()` has answered.
312
+ */
313
+ export function backendFor(app) {
314
+ const mode = app.glPolicy.mode;
315
+ if (mode === 'off') return 'off';
316
+ if (mode === 'indirect') return 'indirect';
317
+ const settled = app._glCapsResolved;
318
+ if (!settled) return null;
319
+ if (settled.direct) return 'direct';
320
+ return mode === 'direct' ? 'off' : 'indirect';
321
+ }