ntk 8.3.1 → 8.4.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 CHANGED
@@ -122,9 +122,16 @@ Server-side resources support `using` / `await using` (Node 24+):
122
122
 
123
123
  ## 3d graphics
124
124
 
125
- Only indirect GLX is supported, with most of the OpenGL 1.4 api implemented.
126
- Note that on many systems indirect GLX is disabled by default —
127
- [you'll need to enable it for gl to work](https://github.com/sidorares/node-x11/issues/117#issuecomment-214762185).
125
+ Two backends, chosen by `glPolicy`
126
+ ([docs/context-gles.md](docs/context-gles.md)):
127
+
128
+ - **direct** (opt-in) — shader GL on the real GPU with no pixels on the
129
+ socket: OpenGL ES 2 over DRI3 + Present on Linux, CGL over the Apple-DRI
130
+ extension on macOS/XQuartz. Needs the optional `x11-dri` addon.
131
+ - **indirect GLX** (default) — most of the OpenGL 1.4 api, serialized into
132
+ the X connection. Note that on many systems indirect GLX is disabled by
133
+ default —
134
+ [you'll need to enable it for gl to work](https://github.com/sidorares/node-x11/issues/117#issuecomment-214762185).
128
135
 
129
136
  ```js
130
137
  import { createClient } from 'ntk';
package/lib/app.js CHANGED
@@ -603,11 +603,11 @@ export default class App {
603
603
  * `createWindow` and the whole object to `wnd.getContext('opengl', config)`.
604
604
  *
605
605
  * The spec is GLX's attribute vocabulary either way, because a caller
606
- * should not have to write the request twice: `DEPTH_SIZE` becomes the EGL
607
- * depth-buffer size on the direct backend, and `ALPHA_SIZE` picks an ARGB
608
- * visual there rather than an fbconfig with an alpha channel. Direct needs
609
- * no round trip to answer — there are no fbconfigs in it, only a window the
610
- * GPU's buffers can be copied or flipped into.
606
+ * should not have to write the request twice: `DEPTH_SIZE` becomes the
607
+ * depth-buffer size of the direct backend's context (EGL on Linux, CGL on
608
+ * macOS), and `ALPHA_SIZE` picks an ARGB visual there rather than an
609
+ * fbconfig with an alpha channel. Direct needs no round trip to answer —
610
+ * there are no fbconfigs in it, only a window the GPU draws for.
611
611
  *
612
612
  * @param {object} [spec] GLX attributes, e.g. `{ DEPTH_SIZE: 24 }`
613
613
  */
@@ -629,6 +629,8 @@ export default class App {
629
629
  }
630
630
  return {
631
631
  backend: 'direct',
632
+ // which direct pipeline this connection runs: 'dri3' or 'appledri'
633
+ flavor: caps.flavor,
632
634
  // depth 24 on the root visual needs no colormap of its own, which is
633
635
  // why an opaque GL window here is an ordinary window
634
636
  visual: argb?.visual ?? screen.root_visual,
@@ -0,0 +1,177 @@
1
+ // Apple-DRI: XQuartz's direct-rendering extension — the macOS counterpart of
2
+ // DRI3. Where DRI3 passes dma-buf descriptors from client to server,
3
+ // Apple-DRI runs the other way: the *server* owns a WindowServer surface for
4
+ // the drawable and exports it to the client's WindowServer connection,
5
+ // identified by (client_id, key[2]). What the client does with the key —
6
+ // import it and attach an OpenGL context through the Xplugin/CGL system
7
+ // libraries — is native-code territory: the x11-dri addon's `apple`
8
+ // namespace (lib/renderingcontext_cgl.js is the consumer).
9
+ //
10
+ // The protocol half lives here rather than in node-x11 because node-x11 does
11
+ // not ship it yet; the binding is written in node-x11's lib/ext style so it
12
+ // can move there verbatim when it does (docs/context-gles.md#macos).
13
+ //
14
+ // Protocol source (there is no shipped header — the extension is defined in
15
+ // the XQuartz server tree):
16
+ // https://github.com/XQuartz/xorg-server/blob/master/hw/xquartz/xpr/appledristr.h
17
+ // https://github.com/XQuartz/xorg-server/blob/master/hw/xquartz/xpr/appledri.h
18
+
19
+ /*
20
+ #define X_AppleDRIQueryVersion 0
21
+ #define X_AppleDRIQueryDirectRenderingCapable 1
22
+ #define X_AppleDRICreateSurface 2
23
+ #define X_AppleDRIDestroySurface 3
24
+ (4..8: AuthConnection and the shm pixmap requests — the accelerated path
25
+ needs none of them, so they are not bound)
26
+ */
27
+
28
+ /** Sub-codes of the AppleDRISurfaceNotify event's `kind`. */
29
+ export const NotifyKind = {
30
+ /** the window moved or resized under the surface: `ctx.update()` */
31
+ Changed: 0,
32
+ /** the surface is gone (window unmapped, frame recreated): CreateSurface + attach anew */
33
+ Destroyed: 1
34
+ };
35
+
36
+ /**
37
+ * Bind the Apple-DRI extension on a node-x11 display.
38
+ *
39
+ * Resolves the extension object — with the requests, `events`, `NotifyKind`
40
+ * and `errors` below on it — or `null` where the server has no Apple-DRI,
41
+ * which is every server that is not XQuartz. Asked once per connection and
42
+ * cached; the event and error parsers are registered on first resolution.
43
+ *
44
+ * The requests are callback-style, like every node-x11 extension:
45
+ *
46
+ * - `QueryVersion(cb)` -> `{ major, minor, patch }`
47
+ * - `QueryDirectRenderingCapable(screen, cb)` -> `boolean`
48
+ * - `CreateSurface(screen, drawable, clientId, cb)` -> `{ key: [k0, k1], uid }`
49
+ * - `DestroySurface(screen, drawable)` (void)
50
+ *
51
+ * `CreateSurface` makes the server create (or reference) a WindowServer
52
+ * surface for the drawable and export it to the process whose WindowServer
53
+ * id is `clientId` (x11-dri: `dri.apple.clientId()`). `key` is what
54
+ * `AppleContext.attach()` consumes; `uid` is the server-side surface id that
55
+ * SurfaceNotify events carry as `arg` — route by uid, not by window id.
56
+ *
57
+ * The `AppleDRISurfaceNotify` event is *classic* (type `firstEvent + 3`),
58
+ * not a GenericEvent, and is sent unsolicited to the client that created the
59
+ * surface — there is no event mask to select. It reaches node-x11's
60
+ * client-level `'event'` stream only (no `wid` field for per-window
61
+ * routing), parsed as `{ name: 'AppleDRISurfaceNotify', kind, time, arg }`.
62
+ *
63
+ * @param {object} display node-x11 display
64
+ * @returns {Promise<object|null>}
65
+ */
66
+ export function requireAppleDRI(display) {
67
+ const X = display.client;
68
+ if (X._appleDRIPromise) return X._appleDRIPromise;
69
+ X._appleDRIPromise = new Promise((resolve) => {
70
+ X.QueryExtension('Apple-DRI', (err, ext) => {
71
+ if (err || !ext.present) return resolve(null);
72
+
73
+ // -> { major, minor, patch }
74
+ ext.QueryVersion = (cb) => {
75
+ X.seq_num++;
76
+ const b = Buffer.alloc(4);
77
+ b.writeUInt8(ext.majorOpcode, 0);
78
+ b.writeUInt8(0, 1);
79
+ b.writeUInt16LE(1, 2);
80
+ X.pack_stream.put(b);
81
+ X.replies[X.seq_num] = [
82
+ (buf) => ({
83
+ major: buf.readUInt16LE(0),
84
+ minor: buf.readUInt16LE(2),
85
+ patch: buf.readUInt32LE(4)
86
+ }),
87
+ cb
88
+ ];
89
+ X.pack_stream.submit(true);
90
+ };
91
+
92
+ // -> boolean
93
+ ext.QueryDirectRenderingCapable = (screen, cb) => {
94
+ X.seq_num++;
95
+ const b = Buffer.alloc(8);
96
+ b.writeUInt8(ext.majorOpcode, 0);
97
+ b.writeUInt8(1, 1);
98
+ b.writeUInt16LE(2, 2);
99
+ b.writeUInt32LE(screen >>> 0, 4);
100
+ X.pack_stream.put(b);
101
+ X.replies[X.seq_num] = [(buf) => buf.readUInt8(0) !== 0, cb];
102
+ X.pack_stream.submit(true);
103
+ };
104
+
105
+ // -> { key: [key0, key1], uid }
106
+ ext.CreateSurface = (screen, drawable, clientId, cb) => {
107
+ X.seq_num++;
108
+ const b = Buffer.alloc(16);
109
+ b.writeUInt8(ext.majorOpcode, 0);
110
+ b.writeUInt8(2, 1);
111
+ b.writeUInt16LE(4, 2);
112
+ b.writeUInt32LE(screen >>> 0, 4);
113
+ b.writeUInt32LE(drawable >>> 0, 8);
114
+ b.writeUInt32LE(clientId >>> 0, 12);
115
+ X.pack_stream.put(b);
116
+ X.replies[X.seq_num] = [
117
+ (buf) => ({
118
+ key: [buf.readUInt32LE(0), buf.readUInt32LE(4)],
119
+ uid: buf.readUInt32LE(8)
120
+ }),
121
+ cb
122
+ ];
123
+ X.pack_stream.submit(true);
124
+ };
125
+
126
+ ext.DestroySurface = (screen, drawable) => {
127
+ X.seq_num++;
128
+ const b = Buffer.alloc(12);
129
+ b.writeUInt8(ext.majorOpcode, 0);
130
+ b.writeUInt8(3, 1);
131
+ b.writeUInt16LE(3, 2);
132
+ b.writeUInt32LE(screen >>> 0, 4);
133
+ b.writeUInt32LE(drawable >>> 0, 8);
134
+ X.pack_stream.put(b);
135
+ X.pack_stream.submit(false);
136
+ };
137
+
138
+ ext.events = {
139
+ AppleDRISurfaceNotify: 3 // 0..2 are obsolete
140
+ };
141
+ ext.NotifyKind = NotifyKind;
142
+
143
+ X.eventParsers[ext.firstEvent + ext.events.AppleDRISurfaceNotify] = (
144
+ type,
145
+ seq,
146
+ extra,
147
+ code,
148
+ raw
149
+ ) => ({
150
+ type,
151
+ seq,
152
+ name: 'AppleDRISurfaceNotify',
153
+ kind: code, // NotifyKind
154
+ time: extra,
155
+ arg: raw.readUInt32LE(4) // the surface uid, NOT a window id
156
+ });
157
+
158
+ ext.errors = {
159
+ ClientNotLocal: 0,
160
+ OperationNotSupported: 1
161
+ };
162
+ // node-x11 names an extension error it cannot decode after a core one;
163
+ // give both of Apple-DRI's a message that says what to do instead
164
+ X.errorParsers[ext.firstError + ext.errors.ClientNotLocal] = (error) => {
165
+ error.message =
166
+ 'Apple-DRI: the client is not local — surfaces can only be exported to a process on the same machine as the X server';
167
+ };
168
+ X.errorParsers[ext.firstError + ext.errors.OperationNotSupported] = (error) => {
169
+ error.message =
170
+ 'Apple-DRI: operation not supported — the server refused the request for this drawable (a root or already-destroyed window, or a server running without the Xplugin backend)';
171
+ };
172
+
173
+ resolve(ext);
174
+ });
175
+ });
176
+ return X._appleDRIPromise;
177
+ }
package/lib/gl.js CHANGED
@@ -7,11 +7,17 @@
7
7
  // into the X connection. Reaches any server that allows indirect contexts,
8
8
  // including over a network, and is a fixed-function OpenGL 1.x pipeline
9
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.
10
+ // - **direct** — the GPU draws the frame and no pixels cross the socket;
11
+ // real shaders; local connections only; needs the optional `x11-dri`
12
+ // addon. It comes in two *flavors*, one per platform, behind the same
13
+ // context contract:
14
+ // - `dri3` (Linux, lib/renderingcontext_gles.js): OpenGL ES 2 on a DRM
15
+ // render node, finished buffers handed to the server as dma-buf
16
+ // descriptors over DRI3 + Present.
17
+ // - `appledri` (macOS/XQuartz, lib/renderingcontext_cgl.js): the server
18
+ // exports the window's WindowServer surface over the Apple-DRI
19
+ // extension, and a CGL context draws straight into it — desktop GL
20
+ // with ES2 compatibility, so the same shaders compile.
15
21
  //
16
22
  // Which one runs is `glPolicy`, and the default is `indirect` — the backend
17
23
  // that has always run. See docs/context-gles.md.
@@ -20,6 +26,7 @@
20
26
  // caller asked for, and what that resolves to. Neither context imports the
21
27
  // other, and the direct one is only ever loaded when the answer is `direct`.
22
28
 
29
+ import { requireAppleDRI } from './appledri.js';
23
30
  import { nodeRequire } from './builtin.js';
24
31
 
25
32
  /**
@@ -29,18 +36,28 @@ import { nodeRequire } from './builtin.js';
29
36
  * - `GL_DISABLED` — `glPolicy: 'off'`; nothing tried.
30
37
  * - `GL_NO_ADDON` — the optional `x11-dri` addon is not installed or would
31
38
  * 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` — a TCP or forwarded display, so there is no local
36
- * socket to hand the server a buffer down.
39
+ * - `GL_NO_DRIVER` — the addon loaded but the platform libraries it needs
40
+ * are missing: `libgbm`/`libEGL`/`libGLESv2` on Linux,
41
+ * libXplugin/OpenGL.framework on macOS — or the platform has no direct
42
+ * path at all.
43
+ * - `GL_NO_DEVICE` — no readable DRM render node (`/dev/dri/renderD*`);
44
+ * Linux only, macOS needs no device node.
45
+ * - `GL_REMOTE_DISPLAY` — a TCP or forwarded display; both flavors are
46
+ * local-only by construction.
37
47
  * - `GL_NO_FD_PASSING` — the display is local, but this JavaScript runtime
38
48
  * cannot send a descriptor over the socket. Bun is the case in the field;
39
- * x11 does it through a Node internal Bun does not implement.
40
- * - `GL_NO_DRI3` — the server has no DRI3/Present (Xvfb, Xephyr, XQuartz).
49
+ * x11 does it through a Node internal Bun does not implement. Linux only —
50
+ * Apple-DRI passes no descriptors.
51
+ * - `GL_NO_DRI3` — the server has no DRI3/Present (Xvfb, Xephyr, XQuartz —
52
+ * though XQuartz has its own path, see `GL_NO_APPLEDRI`).
53
+ * - `GL_NO_APPLEDRI` — macOS, and the server has no Apple-DRI extension:
54
+ * not XQuartz, or an XQuartz running without its Xplugin backend.
55
+ * - `GL_NO_WINDOWSERVER` — macOS, but this process has no WindowServer
56
+ * session to import surfaces into — an SSH session. Run from the
57
+ * logged-in GUI session.
41
58
  * - `GL_IMPORT_FAILED` — the server refused the buffer; usually client and
42
59
  * server on different DRM devices.
43
- * - `GL_CONTEXT_FAILED` — GBM/EGL setup failed for some other reason.
60
+ * - `GL_CONTEXT_FAILED` — GPU context setup failed for some other reason.
44
61
  */
45
62
  export const GLError = {
46
63
  DISABLED: 'GL_DISABLED',
@@ -50,6 +67,8 @@ export const GLError = {
50
67
  REMOTE_DISPLAY: 'GL_REMOTE_DISPLAY',
51
68
  NO_FD_PASSING: 'GL_NO_FD_PASSING',
52
69
  NO_DRI3: 'GL_NO_DRI3',
70
+ NO_APPLEDRI: 'GL_NO_APPLEDRI',
71
+ NO_WINDOWSERVER: 'GL_NO_WINDOWSERVER',
53
72
  IMPORT_FAILED: 'GL_IMPORT_FAILED',
54
73
  CONTEXT_FAILED: 'GL_CONTEXT_FAILED'
55
74
  };
@@ -161,11 +180,18 @@ tools are needed; anything else compiles with node-gyp and a C toolchain. ntk
161
180
  does not depend on it — without it, GL runs through indirect GLX.`;
162
181
 
163
182
  /**
164
- * What the *client side* can do, before any server is asked: the addon, the
165
- * GPU libraries under it, and a render node to draw on. Never throws.
183
+ * What the *client side* can do, before any server is asked: the addon and
184
+ * the platform libraries under it — plus, on Linux, a render node to draw
185
+ * on. Never throws.
186
+ *
187
+ * `flavor` on an ok answer says which direct pipeline this machine runs:
188
+ * `'dri3'` (Linux — GBM/EGL, dma-buf to the server) or `'appledri'`
189
+ * (macOS — Apple-DRI surface export, CGL). macOS needs no device scan; the
190
+ * window's surface is the render target, so `device` is `null` there.
166
191
  *
167
192
  * @returns {{ok: boolean, code?: string, message?: string, hint?: string,
168
- * device?: string, devices?: string[], probe?: object}}
193
+ * flavor?: 'dri3'|'appledri', device?: string|null, devices?: string[],
194
+ * probe?: object}}
169
195
  */
170
196
  export function probeDirect(policy = DEFAULT_GL_POLICY) {
171
197
  const dri = loadDriAddon();
@@ -185,6 +211,28 @@ export function probeDirect(policy = DEFAULT_GL_POLICY) {
185
211
  return { ok: false, code: GLError.NO_DRIVER, message: `x11-dri probe() failed: ${err.message}` };
186
212
  }
187
213
 
214
+ if (probe.platform === 'darwin') {
215
+ // The macOS path draws through Apple-DRI + CGL, not GBM/EGL — probe()
216
+ // reports it as `appledri`: `true`, or the string saying why not. An
217
+ // addon from before 0.5.0 has no such key at all.
218
+ if (probe.appledri !== true) {
219
+ return {
220
+ ok: false,
221
+ code: GLError.NO_DRIVER,
222
+ message: `the libraries direct rendering needs on macOS are unavailable (appledri: ${
223
+ probe.appledri ?? 'not reported — this x11-dri predates Apple-DRI support'
224
+ })`,
225
+ hint:
226
+ probe.appledri === undefined
227
+ ? 'Upgrade the addon: npm install x11-dri@latest (Apple-DRI support arrived in 0.5.0).'
228
+ : 'The macOS path needs XQuartz installed (it dlopen()s libXplugin from /opt/X11)\nand the system OpenGL framework — https://www.xquartz.org.',
229
+ probe
230
+ };
231
+ }
232
+ // no render nodes on macOS: the window's own surface is the target
233
+ return { ok: true, flavor: 'appledri', device: null, devices: [], probe };
234
+ }
235
+
188
236
  // probe() reports each capability as `true` or as the string saying why not
189
237
  const missing = ['gbm', 'egl', 'gles'].filter((key) => probe[key] !== true);
190
238
  if (missing.length) {
@@ -195,7 +243,7 @@ export function probeDirect(policy = DEFAULT_GL_POLICY) {
195
243
  message: `the GPU libraries direct rendering needs are unavailable (${detail})`,
196
244
  hint:
197
245
  probe.platform && probe.platform !== 'linux'
198
- ? `Direct rendering is Linux-only — dma-buf, GBM and DRI3 have no equivalent on ${probe.platform}.`
246
+ ? `Direct rendering needs Linux (DRI3/dma-buf) or macOS (Apple-DRI/XQuartz) — ${probe.platform} has neither path.`
199
247
  : '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.',
200
248
  probe
201
249
  };
@@ -219,7 +267,7 @@ export function probeDirect(policy = DEFAULT_GL_POLICY) {
219
267
  devices
220
268
  };
221
269
  }
222
- return { ok: true, device, devices, probe };
270
+ return { ok: true, flavor: 'dri3', device, devices, probe };
223
271
  }
224
272
 
225
273
  // ---------------------------------------------------------------------------
@@ -253,13 +301,20 @@ const requireExt = (X, name) =>
253
301
  /**
254
302
  * Everything about `app` that decides the backend, answered once and cached.
255
303
  *
256
- * The two extension queries are the only round trips, and they only happen
304
+ * The extension queries are the only round trips, and they only happen
257
305
  * under a policy that could use direct — `createClient` warms this during the
258
306
  * connect handshake for exactly that reason, so `getContext` can decide
259
307
  * synchronously afterwards.
260
308
  *
261
- * @returns {Promise<{direct: boolean, indirect: boolean, device: string|null,
262
- * reason: Error|null, DRI3: object|null, Present: object|null}>}
309
+ * `flavor` names the direct pipeline where `direct` is true: `'dri3'`
310
+ * carries `DRI3` and `Present`, `'appledri'` carries `AppleDRI` (the
311
+ * lib/appledri.js extension object) and `appleClientId` (this process's
312
+ * WindowServer id, which CreateSurface exports surfaces to).
313
+ *
314
+ * @returns {Promise<{direct: boolean, indirect: boolean,
315
+ * flavor: 'dri3'|'appledri'|null, device: string|null, reason: Error|null,
316
+ * DRI3: object|null, Present: object|null, AppleDRI: object|null,
317
+ * appleClientId: number|null}>}
263
318
  */
264
319
  export function glCapabilities(app) {
265
320
  if (app._glCaps) return app._glCaps;
@@ -269,10 +324,13 @@ export function glCapabilities(app) {
269
324
  const fail = (code, message, hint) => ({
270
325
  direct: false,
271
326
  indirect,
327
+ flavor: null,
272
328
  device: null,
273
329
  reason: glError(code, message, hint),
274
330
  DRI3: null,
275
- Present: null
331
+ Present: null,
332
+ AppleDRI: null,
333
+ appleClientId: null
276
334
  });
277
335
 
278
336
  if (policy.mode === 'off') {
@@ -281,11 +339,67 @@ export function glCapabilities(app) {
281
339
  if (!app.display.isLocalSocket) {
282
340
  return fail(
283
341
  GLError.REMOTE_DISPLAY,
284
- 'this X connection is not a local socket, and DRI3 works by passing a descriptor down one',
285
- 'Direct rendering is local-only by construction. Over a network, indirect GLX is\n' +
286
- 'the backend that can work at all — leave glPolicy at its default.'
342
+ 'this X connection is not a local socket, and direct rendering is same-machine by construction (DRI3 passes a descriptor down the socket; Apple-DRI exports a surface to a local process)',
343
+ 'Over a network, indirect GLX is the backend that can work at all — leave\n' +
344
+ 'glPolicy at its default.'
345
+ );
346
+ }
347
+
348
+ const client = probeDirect(policy);
349
+ if (!client.ok) return fail(client.code, client.message, client.hint);
350
+
351
+ if (client.flavor === 'appledri') {
352
+ // No descriptor ever crosses this socket — the fd-passing checks the
353
+ // dri3 flavor needs below do not apply, which is also what lets this
354
+ // path work under runtimes that cannot send one.
355
+ const dri = loadDriAddon();
356
+ let appleClientId;
357
+ try {
358
+ // the WindowServer handshake happens on first call; a session with
359
+ // no WindowServer (SSH into the machine) is where it throws
360
+ appleClientId = dri.apple.clientId();
361
+ } catch (err) {
362
+ return fail(
363
+ GLError.NO_WINDOWSERVER,
364
+ `this process has no WindowServer session to import surfaces into (${err.message})`,
365
+ 'Apple-DRI hands the window surface to the WindowServer connection of this\n' +
366
+ 'process, which an SSH session does not have. Run the app from the logged-in\n' +
367
+ 'GUI session; over SSH, indirect GLX is the backend that can work.'
368
+ );
369
+ }
370
+ const AppleDRI = await requireAppleDRI(app.display);
371
+ if (!AppleDRI) {
372
+ return fail(
373
+ GLError.NO_APPLEDRI,
374
+ `${displayName()} does not have the Apple-DRI extension, so it cannot export a window surface to render into`,
375
+ 'Apple-DRI is XQuartz\'s direct-rendering extension — is this display an\n' +
376
+ 'XQuartz server? Indirect GLX is the backend that can work on any other.'
377
+ );
378
+ }
379
+ const capable = await new Promise((resolve) =>
380
+ AppleDRI.QueryDirectRenderingCapable(0, (err, answer) => resolve(err ? false : answer))
287
381
  );
382
+ if (!capable) {
383
+ return fail(
384
+ GLError.NO_APPLEDRI,
385
+ `${displayName()} has Apple-DRI but reports it not direct-rendering capable`,
386
+ 'XQuartz answers this false when its Xplugin backend is not driving a real\n' +
387
+ 'display. Indirect GLX is the backend that can work on such a server.'
388
+ );
389
+ }
390
+ return {
391
+ direct: true,
392
+ indirect,
393
+ flavor: 'appledri',
394
+ device: null,
395
+ reason: null,
396
+ DRI3: null,
397
+ Present: null,
398
+ AppleDRI,
399
+ appleClientId
400
+ };
288
401
  }
402
+
289
403
  if (!canPassDescriptors(app.display)) {
290
404
  // The socket is local; what is missing is the ability to send a
291
405
  // descriptor along it. x11 does that through Node's internal
@@ -302,9 +416,6 @@ export function glCapabilities(app) {
302
416
  );
303
417
  }
304
418
 
305
- const client = probeDirect(policy);
306
- if (!client.ok) return fail(client.code, client.message, client.hint);
307
-
308
419
  const X = app.X;
309
420
  const [DRI3, Present] = await Promise.all([requireExt(X, 'dri3'), requireExt(X, 'present')]);
310
421
  if (!DRI3 || !Present) {
@@ -325,7 +436,17 @@ export function glCapabilities(app) {
325
436
  );
326
437
  }
327
438
 
328
- return { direct: true, indirect, device: client.device, reason: null, DRI3, Present };
439
+ return {
440
+ direct: true,
441
+ indirect,
442
+ flavor: 'dri3',
443
+ device: client.device,
444
+ reason: null,
445
+ DRI3,
446
+ Present,
447
+ AppleDRI: null,
448
+ appleClientId: null
449
+ };
329
450
  })();
330
451
  return app._glCaps;
331
452
  }
package/lib/index.js CHANGED
@@ -55,13 +55,15 @@ import { TextLayout } from './text/layout.js';
55
55
  import SvgView from './widgets/svgview.js';
56
56
  import { cssColor, cssColorStraight, premultiply } from './color.js';
57
57
 
58
- // rendering context modules register themselves on Drawable. The direct one
59
- // comes last on purpose: it wraps the 'opengl' factory the indirect one just
60
- // registered, so that the backend-neutral name can dispatch on glPolicy.
58
+ // rendering context modules register themselves on Drawable. The direct ones
59
+ // come last on purpose: each wraps the 'opengl' factory the one before it
60
+ // registered, so that the backend-neutral name can dispatch on glPolicy —
61
+ // indirect underneath, then the dri3 flavor, then the appledri one on top.
61
62
  import './renderingcontext_x11.js';
62
63
  import { CanvasGradient, CanvasPattern } from './renderingcontext_2d.js';
63
64
  import './renderingcontext_opengl.js';
64
65
  import './renderingcontext_gles.js';
66
+ import './renderingcontext_cgl.js';
65
67
 
66
68
  // One socket write per frame instead of one per request (x11 >= 3.6). A frame
67
69
  // is emitted in one synchronous run of _runFrame() and ends with the frame
@@ -0,0 +1,439 @@
1
+ // Direct rendering context, macOS/XQuartz flavor: OpenGL on the GPU through
2
+ // CGL, drawing straight into the window's WindowServer surface, which the
3
+ // server exports over the Apple-DRI extension (lib/appledri.js).
4
+ //
5
+ // The mirror image of the DRI3 flavor (lib/renderingcontext_gles.js): DRI3
6
+ // is client-allocates-and-pushes — GBM buffers travel to the server as
7
+ // dma-buf descriptors and are shown with Present — where Apple-DRI is
8
+ // server-exports-and-client-attaches:
9
+ //
10
+ // this process X server (XQuartz)
11
+ // ------------ ------------------
12
+ // apple.clientId() --- AppleDRICreateSurface(win, cid) ---> exports the
13
+ // <-------------- key[2] ---------------- window's surface
14
+ // ctx.attach(key) (import surface + bind CGL context)
15
+ // gl draws straight into the window's backing store
16
+ // ctx.flush() (CGLFlushDrawable — WindowServer composites)
17
+ // <--- AppleDRISurfaceNotify ------------ moved/resized/
18
+ // destroyed
19
+ //
20
+ // After attach, no pixels and no per-frame requests cross the X socket at
21
+ // all. The GL the context speaks is the same WebGL-shaped camelCase table
22
+ // the DRI3 flavor installs — the addon serves both from one `gl` object, and
23
+ // on Apple's GL (4.1 core on Metal, ES2-compatible) the same GLSL ES 1.00
24
+ // shaders compile unchanged — so draw code written against `gl.backend ===
25
+ // 'direct'` runs on either. See docs/context-gles.md#macos.
26
+ //
27
+ // Two structural differences from the GLES flavor, both consequences of the
28
+ // server owning the buffer:
29
+ //
30
+ // - **The surface exists only while the physical (Quartz) window does**, so
31
+ // setup is not synchronous: the CGL context is created in the
32
+ // constructor (and `gl.*` works immediately — an unattached context
33
+ // compiles shaders and renders to FBOs), but the attach waits for
34
+ // MapNotify, and `SurfaceNotify(destroyed)` (unmap, frame recreated)
35
+ // means export-and-attach again. `ready`/`canRender()` report it, and
36
+ // the existing `onFrameAvailable` contract absorbs the wait.
37
+ // - **No backpressure from the server**: `flush()` always succeeds
38
+ // immediately, so a loop that draws whenever it can would spin at 100%
39
+ // CPU. Rather than a second pacing model, the swap itself closes the
40
+ // `canRender()` gate for one display period and a timer reopens it —
41
+ // the same gate IdleNotify drives on Linux, so a draw loop written for
42
+ // one flavor paces correctly on the other.
43
+
44
+ import { connectionGone } from './cleanup.js';
45
+ import Drawable from './drawable.js';
46
+ import { GLError, backendFor, glError, loadDriAddon } from './gl.js';
47
+
48
+ /** pacing fallback where the display's rate is not known (see App#frameInterval) */
49
+ const FALLBACK_FRAME_INTERVAL = 1000 / 60;
50
+
51
+ /**
52
+ * The app-level uid -> context routes for AppleDRISurfaceNotify.
53
+ *
54
+ * The event is classic (not a GenericEvent, so `_setGenericEventSink` never
55
+ * sees it), unsolicited (no mask to select), and names its surface by the
56
+ * **uid** from the CreateSurface reply rather than by window id — so the
57
+ * per-window `event_consumers` dispatch cannot route it either. One
58
+ * client-level listener per connection, keyed by uid, is the whole scheme.
59
+ */
60
+ function appleSurfaceRoutes(app) {
61
+ if (app._appleSurfaces) return app._appleSurfaces;
62
+ const routes = new Map();
63
+ app._appleSurfaces = routes;
64
+ app.X.on('event', (ev) => {
65
+ if (ev.name !== 'AppleDRISurfaceNotify') return;
66
+ routes.get(ev.arg)?._surfaceNotify(ev);
67
+ });
68
+ return routes;
69
+ }
70
+
71
+ class RenderingContextCGL {
72
+ constructor(window, config = {}) {
73
+ const app = window.app;
74
+ const caps = app._glCapsResolved;
75
+ if (!caps) {
76
+ throw glError(
77
+ GLError.CONTEXT_FAILED,
78
+ "getContext('cgl') needs the direct-rendering probe to have answered, and it has not",
79
+ `createClient() runs the probe during the handshake when glPolicy could pick the
80
+ direct backend, so a context can be created synchronously afterwards. Under the
81
+ default policy ('indirect') it does not run, and asking for this context by name
82
+ does not make it retroactive. Either:
83
+
84
+ const app = await createClient({ glPolicy: 'auto' }); // probe at connect
85
+ await app.glCapabilities(); // or ask, once, later`
86
+ );
87
+ }
88
+ if (!caps.direct) throw caps.reason;
89
+ if (caps.flavor !== 'appledri') {
90
+ throw glError(
91
+ GLError.CONTEXT_FAILED,
92
+ `this connection's direct backend is ${caps.flavor}, not Apple-DRI/CGL`,
93
+ "getContext('opengl') is the backend-neutral name and picks the right flavor;\n'cgl' asks for this one by name and only exists on macOS/XQuartz."
94
+ );
95
+ }
96
+
97
+ const dri = loadDriAddon();
98
+ this.window = window;
99
+ this.app = app;
100
+ this.X = window.X;
101
+ this.dri = dri;
102
+ this.AppleDRI = caps.AppleDRI;
103
+ /** which backend this is, for code that runs on either */
104
+ this.backend = 'direct';
105
+ /** which direct pipeline, for the curious ('dri3' on Linux) */
106
+ this.flavor = 'appledri';
107
+ this.error = null;
108
+
109
+ const depth = window.depth || app.display.screen[0].root_depth || 24;
110
+ if (depth !== 24 && depth !== 32) {
111
+ throw glError(
112
+ GLError.CONTEXT_FAILED,
113
+ `direct rendering needs a 24- or 32-bit window, and this one is ${depth}-bit`,
114
+ 'Create the window with depth 24 (opaque) or 32 (per-pixel alpha, via\napp.findArgbVisual()).'
115
+ );
116
+ }
117
+ this.depth = depth;
118
+ this._screen = config.screen ?? 0;
119
+ this._clientId = caps.appleClientId ?? dri.apple.clientId();
120
+
121
+ // One CGL context per context object, not a shared one per app: a CGL
122
+ // context can be attached to exactly one surface, and contexts are cheap
123
+ // where EGL ones are not. The visible consequence is that GL resources
124
+ // (programs, textures) are per-window on this flavor where the GLES one
125
+ // shares them — key caches by the `gl` object identity and both are
126
+ // covered (docs/context-gles.md#macos).
127
+ try {
128
+ this.ctx = new dri.apple.Context({
129
+ depthSize: config.depthSize ?? config.DEPTH_SIZE ?? 16
130
+ });
131
+ } catch (err) {
132
+ throw glError(
133
+ GLError.CONTEXT_FAILED,
134
+ `could not create a CGL context: ${err.message}`,
135
+ null,
136
+ err
137
+ );
138
+ }
139
+
140
+ this._routes = appleSurfaceRoutes(app);
141
+ this._uid = undefined;
142
+ this._attached = false;
143
+ this._pendingSurface = false;
144
+ this._throttled = false;
145
+ this._timer = null;
146
+ this._frameWanted = null;
147
+ this._destroyed = false;
148
+
149
+ /**
150
+ * Resolves once the window's surface has been exported and attached —
151
+ * the whole path proven — and rejects with a coded error if it cannot
152
+ * be. Settles only after the window is mapped: the physical Quartz
153
+ * window has to exist before the server has a surface to export, so
154
+ * `map()` first, then `await gl.ready`.
155
+ */
156
+ this.ready = new Promise((resolve, reject) => {
157
+ this._settleReady = (err) => {
158
+ this._settleReady = null;
159
+ if (err) reject(err);
160
+ else resolve(this);
161
+ };
162
+ });
163
+ // a rejection nobody is listening for must not take the process down;
164
+ // `error` is the other way to find out
165
+ this.ready.catch(() => {});
166
+
167
+ // GL entry points and constants, bound so that this context is the
168
+ // current one when they run
169
+ this._installGL();
170
+ this.makeCurrent();
171
+
172
+ // The attach dance: surface after MapNotify, again after a Destroyed
173
+ // notify once the window is viewable again. `on('map')` also selects
174
+ // StructureNotify where the window does not have it yet (adopted ids).
175
+ this._onMap = () => {
176
+ if (!this._attached && !this._pendingSurface) this._createSurface();
177
+ };
178
+ window.on('map', this._onMap);
179
+ if (window._mapped) this._createSurface();
180
+ }
181
+
182
+ /**
183
+ * Copy the addon's GL namespace onto this context.
184
+ *
185
+ * Same shape as the GLES flavor: every function is wrapped with a currency
186
+ * check, because another context may have made itself current since the
187
+ * last call here — `app._glCurrent` is one slot shared by every direct
188
+ * context on the connection, whichever flavor.
189
+ */
190
+ _installGL() {
191
+ const table = this.dri.gl;
192
+ for (const key in table) {
193
+ const value = table[key];
194
+ if (typeof value !== 'function') {
195
+ this[key] = value; // GL constants
196
+ continue;
197
+ }
198
+ this[key] = (...args) => {
199
+ if (this.app._glCurrent !== this) this._bind();
200
+ return value(...args);
201
+ };
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Ask the server to export the window's surface, then attach to it.
207
+ *
208
+ * Called from MapNotify (the physical window now exists) and from the
209
+ * SurfaceNotify(destroyed) recovery path. Attaching again on the same CGL
210
+ * context replaces whatever surface it held.
211
+ */
212
+ _createSurface() {
213
+ if (this._destroyed || this.error || this.window._destroyed || connectionGone(this.X)) return;
214
+ this._pendingSurface = true;
215
+ this.AppleDRI.CreateSurface(this._screen, this.window.id, this._clientId, (err, surf) => {
216
+ this._pendingSurface = false;
217
+ if (this._destroyed || this.window._destroyed) return;
218
+ if (err) {
219
+ return this._fail(
220
+ glError(
221
+ GLError.CONTEXT_FAILED,
222
+ `Apple-DRI could not export a surface for this window: ${err.message}`,
223
+ null,
224
+ err
225
+ )
226
+ );
227
+ }
228
+ if (this._uid !== undefined) this._routes.delete(this._uid);
229
+ this._uid = surf.uid;
230
+ this._routes.set(surf.uid, this);
231
+ try {
232
+ this.ctx.attach(surf.key);
233
+ } catch (attachErr) {
234
+ return this._fail(
235
+ glError(
236
+ GLError.CONTEXT_FAILED,
237
+ `could not attach the CGL context to the exported surface: ${attachErr.message}`,
238
+ null,
239
+ attachErr
240
+ )
241
+ );
242
+ }
243
+ this.app._glCurrent = this; // attach leaves the context current
244
+ this._attached = true;
245
+ this._settleReady?.(null);
246
+ this._onFrameAvailable();
247
+ });
248
+ this.X.flush?.();
249
+ }
250
+
251
+ /** the app-level route target for this context's SurfaceNotify events */
252
+ _surfaceNotify(ev) {
253
+ if (this._destroyed || this.error) return;
254
+ if (ev.kind === this.AppleDRI.NotifyKind.Changed) {
255
+ // moved or resized under the surface: refresh the context's idea of it
256
+ if (this._attached) {
257
+ try {
258
+ this.ctx.update();
259
+ } catch {
260
+ // a surface torn down between the event and now; the Destroyed
261
+ // notify that follows is what handles it
262
+ }
263
+ }
264
+ return;
265
+ }
266
+ // Destroyed: the Quartz window went away — unmapped, or its frame was
267
+ // recreated. The context survives; the surface has to be exported and
268
+ // attached again once there is a window to export.
269
+ this._attached = false;
270
+ if (this._uid !== undefined) {
271
+ this._routes.delete(this._uid);
272
+ this._uid = undefined;
273
+ }
274
+ if (this.window._mapped && !this._pendingSurface) this._createSurface();
275
+ // not mapped: the 'map' listener picks it up when it is again
276
+ }
277
+
278
+ /**
279
+ * Make this context current, and pick up a resize.
280
+ *
281
+ * Call it at the top of a frame, as on the GLES flavor. A size change
282
+ * needs no new buffers here — the surface is the window's backing store
283
+ * and tracks it — but the context has to be told to re-read the geometry.
284
+ */
285
+ makeCurrent() {
286
+ if (this.error || this._destroyed || this.window._destroyed) return this;
287
+ this._bind();
288
+ const width = this.window.width;
289
+ const height = this.window.height;
290
+ if (width !== this._width || height !== this._height) {
291
+ this._width = width;
292
+ this._height = height;
293
+ if (this._attached) {
294
+ try {
295
+ this.ctx.update();
296
+ } catch {
297
+ // surface gone; the Destroyed notify re-attaches
298
+ }
299
+ }
300
+ }
301
+ return this;
302
+ }
303
+
304
+ _bind() {
305
+ if (this.error || this._destroyed) return;
306
+ this.ctx.makeCurrent();
307
+ this.app._glCurrent = this;
308
+ }
309
+
310
+ /**
311
+ * Is a frame worth drawing right now?
312
+ *
313
+ * False until the surface is attached (nowhere to draw to), and false for
314
+ * one display period after each swap (the pacing gate). `onFrameAvailable`
315
+ * fires when either turns true.
316
+ */
317
+ canRender() {
318
+ if (this.error || this._destroyed) return false;
319
+ return this._attached && !this._throttled;
320
+ }
321
+
322
+ /** Called when `canRender()` became true again — attach settled, or the pacing gate reopened. */
323
+ set onFrameAvailable(fn) {
324
+ this._frameWanted = fn;
325
+ }
326
+
327
+ get onFrameAvailable() {
328
+ return this._frameWanted;
329
+ }
330
+
331
+ _onFrameAvailable() {
332
+ this._frameWanted?.();
333
+ }
334
+
335
+ /**
336
+ * Show the frame just drawn (CGLFlushDrawable — the WindowServer
337
+ * composites the surface; no X request is involved).
338
+ *
339
+ * Returns false when there is no attached surface yet or the pacing gate
340
+ * is closed — the same contract as the GLES flavor's "every buffer is with
341
+ * the server", and `onFrameAvailable` reopens it the same way.
342
+ */
343
+ SwapBuffers() {
344
+ if (this.error || this._destroyed || this.window._destroyed) return false;
345
+ if (!this._attached || this._throttled) return false;
346
+ try {
347
+ this.ctx.flush();
348
+ } catch (err) {
349
+ this._fail(glError(GLError.CONTEXT_FAILED, `CGL flush failed: ${err.message}`, null, err));
350
+ return false;
351
+ }
352
+ // The pacing gate: flush() never blocks and the server sends no
353
+ // done-with-it event, so an unthrottled loop would spin. Close the gate
354
+ // for most of a display period; the timer reopens it and fires
355
+ // onFrameAvailable, which is exactly what IdleNotify does on Linux.
356
+ // Most of one rather than a full one because callers paced by
357
+ // requestAnimationFrame already wait a display period of their own, and
358
+ // a full gate in series with it would drop below display rate; at 3/4
359
+ // the frame clock stays the pacer and this gate only stops the spin.
360
+ // (ctx.setSwapInterval(1) — a blocking vsync'd flush — remains available
361
+ // on the raw context for callers who want real vsync.)
362
+ this._throttled = true;
363
+ this._timer = setTimeout(() => {
364
+ this._timer = null;
365
+ this._throttled = false;
366
+ this._onFrameAvailable();
367
+ }, (this.app.frameInterval ?? FALLBACK_FRAME_INTERVAL) * 0.75);
368
+ return true;
369
+ }
370
+
371
+ swapBuffers() {
372
+ return this.SwapBuffers();
373
+ }
374
+
375
+ /** The GL renderer string — "Apple M1 Pro" and the like, handy in bug reports. */
376
+ get renderer() {
377
+ try {
378
+ return this.dri.gl.getString(this.dri.GL.RENDERER);
379
+ } catch {
380
+ return null;
381
+ }
382
+ }
383
+
384
+ _fail(err) {
385
+ if (this.error) return;
386
+ this.error = err;
387
+ this._settleReady?.(err);
388
+ }
389
+
390
+ destroy() {
391
+ if (this._destroyed) return;
392
+ this._destroyed = true;
393
+ if (this._timer) {
394
+ clearTimeout(this._timer);
395
+ this._timer = null;
396
+ }
397
+ this.window.removeListener('map', this._onMap);
398
+ if (this._uid !== undefined) {
399
+ this._routes.delete(this._uid);
400
+ this._uid = undefined;
401
+ }
402
+ // release the server's reference to the surface; with the window (or the
403
+ // connection) already gone the server has done it for us
404
+ if (!this.window._destroyed && !connectionGone(this.X)) {
405
+ try {
406
+ this.AppleDRI.DestroySurface(this._screen, this.window.id);
407
+ } catch {
408
+ // the connection is closing; the server frees everything with it
409
+ }
410
+ }
411
+ if (this.app._glCurrent === this) this.app._glCurrent = null;
412
+ try {
413
+ this.ctx.destroy();
414
+ } catch {
415
+ // already torn down
416
+ }
417
+ }
418
+
419
+ [Symbol.dispose]() {
420
+ this.destroy();
421
+ }
422
+ }
423
+
424
+ Drawable.renderingContextFactory['cgl'] = (window, config) => new RenderingContextCGL(window, config);
425
+
426
+ // The 'opengl' dispatcher was registered by renderingcontext_gles.js (which
427
+ // index.js imports first); this wrap slots the Apple flavor in ahead of it.
428
+ // The chain: appledri flavor -> this context; everything else -> the GLES
429
+ // module's dispatch, which handles dri3, indirect, off and not-yet-probed.
430
+ const dispatchBelow = Drawable.renderingContextFactory['opengl'];
431
+ Drawable.renderingContextFactory['opengl'] = (window, config) => {
432
+ const app = window.app;
433
+ if (backendFor(app) === 'direct' && app._glCapsResolved?.flavor === 'appledri') {
434
+ return new RenderingContextCGL(window, config);
435
+ }
436
+ return dispatchBelow(window, config);
437
+ };
438
+
439
+ export default RenderingContextCGL;
@@ -67,6 +67,13 @@ does not make it retroactive. Either:
67
67
  );
68
68
  }
69
69
  if (!caps.direct) throw caps.reason;
70
+ if (caps.flavor && caps.flavor !== 'dri3') {
71
+ throw glError(
72
+ GLError.CONTEXT_FAILED,
73
+ `this connection's direct backend is ${caps.flavor}, not DRI3/GLES`,
74
+ "getContext('opengl') is the backend-neutral name and picks the right flavor;\n'gles' asks for the Linux one by name. On macOS/XQuartz the flavor is 'cgl'\n(lib/renderingcontext_cgl.js), same contract and the same gl surface."
75
+ );
76
+ }
70
77
 
71
78
  const dri = loadDriAddon();
72
79
  this.window = window;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.3.1",
3
+ "version": "8.4.0",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",
@@ -47,7 +47,7 @@
47
47
  "x11": "^4.0.1"
48
48
  },
49
49
  "optionalDependencies": {
50
- "x11-dri": ">=0.2.0 <1"
50
+ "x11-dri": ">=0.5.0 <1"
51
51
  },
52
52
  "scripts": {
53
53
  "test": "NTK_STRICT_COLORS=1 node --test",