ntk 8.3.0 → 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
@@ -1,3 +1,4 @@
1
+ import { connectionGone } from './cleanup.js';
1
2
  import Clipboard from './clipboard.js';
2
3
  import { CursorCache } from './cursor.js';
3
4
  import { GLError, backendFor, glCapabilities, glError, resolveGLPolicy } from './gl.js';
@@ -253,6 +254,12 @@ export default class App {
253
254
  _probeRefreshRate() {
254
255
  this._refreshProbe = null; // in progress; only ever started once
255
256
  const X = this.X;
257
+ // A connection on its way out is one more "no rate to be had". The probe
258
+ // is started lazily by the first window built on this connection, which
259
+ // may be one adopted from an event still arriving as it closes — and
260
+ // node-x11 throws synchronously at a request from then on, where the
261
+ // event dispatch has no caller to catch it (issue #321).
262
+ if (connectionGone(X)) return;
256
263
  X.require('randr', (err, R) => {
257
264
  if (err || !R) return;
258
265
  const root = this.display.screen[0].root;
@@ -596,11 +603,11 @@ export default class App {
596
603
  * `createWindow` and the whole object to `wnd.getContext('opengl', config)`.
597
604
  *
598
605
  * The spec is GLX's attribute vocabulary either way, because a caller
599
- * should not have to write the request twice: `DEPTH_SIZE` becomes the EGL
600
- * depth-buffer size on the direct backend, and `ALPHA_SIZE` picks an ARGB
601
- * visual there rather than an fbconfig with an alpha channel. Direct needs
602
- * no round trip to answer — there are no fbconfigs in it, only a window the
603
- * 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.
604
611
  *
605
612
  * @param {object} [spec] GLX attributes, e.g. `{ DEPTH_SIZE: 24 }`
606
613
  */
@@ -622,6 +629,8 @@ export default class App {
622
629
  }
623
630
  return {
624
631
  backend: 'direct',
632
+ // which direct pipeline this connection runs: 'dri3' or 'appledri'
633
+ flavor: caps.flavor,
625
634
  // depth 24 on the root visual needs no colormap of its own, which is
626
635
  // why an opaque GL window here is an ordinary window
627
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/cleanup.js CHANGED
@@ -1,11 +1,27 @@
1
- // Release a server-side resource, tolerating a connection that is closing or
2
- // already gone — the X server frees all of a client's resources on disconnect,
3
- // so there is nothing left to do and nothing worth throwing about. This
4
- // matters for FinalizationRegistry callbacks, which run after app.close() if
5
- // wrappers get garbage collected late and have no user code around them to
6
- // catch.
1
+ /**
2
+ * Whether a request issued now would throw rather than reach the server.
3
+ * node-x11 throws *synchronously* ("client is in closing state") once
4
+ * `close()` has begun, and a destroyed or ended stream is the same state one
5
+ * step further along.
6
+ *
7
+ * The check matters wherever a request is issued from somewhere with no
8
+ * caller to catch: a FinalizationRegistry callback, a paced frame's timer, or
9
+ * the X event dispatch itself — events already in the read buffer keep being
10
+ * delivered after `close()` (issue #321).
11
+ */
12
+ export function connectionGone(X) {
13
+ return !!(X._closing || !X.stream || X.stream.destroyed || X.stream.writableEnded);
14
+ }
15
+
16
+ // Issue requests — typically releasing a server-side resource — tolerating a
17
+ // connection that is closing or already gone. The X server frees all of a
18
+ // client's resources on disconnect, so there is nothing left to do and
19
+ // nothing worth throwing about. This matters wherever the requests have no
20
+ // caller around them to catch: FinalizationRegistry callbacks, which run
21
+ // after app.close() if wrappers get garbage collected late, a paced frame's
22
+ // timer, and the X event dispatch (issue #321).
7
23
  export function safeRelease(X, fn) {
8
- if (X._closing || !X.stream || X.stream.destroyed || X.stream.writableEnded) return;
24
+ if (connectionGone(X)) return;
9
25
  try {
10
26
  fn();
11
27
  } catch {
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/lib/window.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import x11 from 'x11';
2
2
 
3
3
  import { builtin } from './builtin.js';
4
- import { safeRelease } from './cleanup.js';
4
+ import { connectionGone, safeRelease } from './cleanup.js';
5
5
  import Drawable from './drawable.js';
6
6
  import { packIcons, unpackIcons } from './imagedata.js';
7
7
  import Pixmap from './pixmap.js';
@@ -606,22 +606,34 @@ export default class Window extends Drawable {
606
606
  // client chose the visual for (issue #295). They go out in the same
607
607
  // batch, so the pair costs one round trip, not two, and `ready` is the
608
608
  // wait for both.
609
- this._readyPending = 2;
610
- X.GetGeometry(this.id, (err, res) => {
611
- // A window that was destroyed between the id reaching us and this
612
- // request reaching the server answers BadWindow, and there is
613
- // nothing to record. `ready` still resolves: adopting a window that
614
- // has since gone is ordinary for a window manager, and a wait that
615
- // never ends is worse than one that ends with width undefined.
616
- this._readyPending--;
617
- if (err) this._settleReady();
618
- else this._applyGeometry(unpackGeometry(res));
619
- });
620
- X.GetWindowAttributes(this.id, (err, attrs) => {
621
- this._readyPending--;
622
- if (!err) this.visualId = attrs.visual;
609
+ //
610
+ // Nothing is asked of a connection that is going away: node-x11 throws
611
+ // synchronously at a request once close() has begun, and adoption
612
+ // happens from places with no caller to catch — a routed child-event,
613
+ // XEmbed, the shared glyph cache all build windows from inside the X
614
+ // event dispatch (issue #321). There is nothing to learn there either,
615
+ // so `ready` settles with the geometry unknown, the same outcome as a
616
+ // window destroyed before its reply landed (see the getter).
617
+ this._readyPending = connectionGone(X) ? 0 : 2;
618
+ if (this._readyPending) {
619
+ X.GetGeometry(this.id, (err, res) => {
620
+ // A window that was destroyed between the id reaching us and this
621
+ // request reaching the server answers BadWindow, and there is
622
+ // nothing to record. `ready` still resolves: adopting a window that
623
+ // has since gone is ordinary for a window manager, and a wait that
624
+ // never ends is worse than one that ends with width undefined.
625
+ this._readyPending--;
626
+ if (err) this._settleReady();
627
+ else this._applyGeometry(unpackGeometry(res));
628
+ });
629
+ X.GetWindowAttributes(this.id, (err, attrs) => {
630
+ this._readyPending--;
631
+ if (!err) this.visualId = attrs.visual;
632
+ this._settleReady();
633
+ });
634
+ } else {
623
635
  this._settleReady();
624
- });
636
+ }
625
637
  }
626
638
 
627
639
  Window._cacheFor(app).set(this.id, this);
@@ -779,6 +791,12 @@ export default class Window extends Drawable {
779
791
  this.on('child-event', (ev) => {
780
792
  const eventName = xevents.eventName[ev.type];
781
793
  if (!eventName) return;
794
+ // Events buffered before `close()` keep being dispatched after it, and
795
+ // a window this client already saw destroyed has no substructure left
796
+ // to report. Neither is worth building a child for — and the questions
797
+ // the constructor would ask on the way out are what used to throw into
798
+ // the dispatcher (issue #321).
799
+ if (this._destroyed || connectionGone(X)) return;
782
800
  const child = new Window(app, { id: ev.wid });
783
801
  const ntkev = { ...ev, parent: this, window: child, target: child };
784
802
  // wait until we know that we track correct x,y,w,h values
@@ -788,6 +806,9 @@ export default class Window extends Drawable {
788
806
  });
789
807
 
790
808
  this.on('newListener', (name) => {
809
+ // one of ntk's own handlers going on, not a caller asking for an
810
+ // event: nothing to arm and nothing to select — see _listenInternal
811
+ if (this._internalListen) return;
791
812
  // Listening for 'close' is the opt-in. WM_DELETE_WINDOW only reaches a
792
813
  // client that advertised it in WM_PROTOCOLS — a window manager kills
793
814
  // anyone else outright — and having to know that, on top of decoding a
@@ -814,6 +835,13 @@ export default class Window extends Drawable {
814
835
  const eventMask = xevents.mask[name];
815
836
  if (!eventMask) return;
816
837
  if ((eventMask & this.eventMask) === 0) {
838
+ // A connection on its way out can select nothing, and node-x11 throws
839
+ // synchronously at a request from then on. This hook runs from
840
+ // `.on()` — including the calls made while adopting a window from
841
+ // inside the X event dispatch, where there is no caller to catch it
842
+ // (issue #321). Before the mask is touched, so it goes on saying what
843
+ // this connection holds.
844
+ if (connectionGone(X)) return;
817
845
  this.eventMask |= eventMask;
818
846
  // the selection can legitimately fail — SubstructureRedirect is
819
847
  // one-client-only, so `on('map_request')` is how you find out
@@ -821,14 +849,29 @@ export default class Window extends Drawable {
821
849
  // error hook rather than dropping it; selectInput() is the
822
850
  // explicit form that hands the error straight back.
823
851
  X.ChangeWindowAttributes(this.id, { eventMask: this.eventMask }, (err) => {
824
- if (err) this.app.options.onXError?.(err);
852
+ if (!err) return;
853
+ // the request failed as a whole, so the server kept the mask it
854
+ // had: drop the bits again rather than leave `eventMask` claiming
855
+ // a selection this connection does not hold. selectInput() trusts
856
+ // it to decide whether a write can be skipped.
857
+ this.eventMask &= ~eventMask;
858
+ this.app.options.onXError?.(err);
825
859
  });
826
860
  }
827
861
  });
828
862
 
829
- this.on('map', () => (this._mapped = true));
830
- this.on('unmap', () => (this._mapped = false));
831
- this.on('destroy', () => {
863
+ // ntk's own bookkeeping listens for the same events an app does, and the
864
+ // hook above reads a listener as a caller expressing interest and selects
865
+ // for it. For a window ntk created that is free — StructureNotify is in
866
+ // the CreateWindow value list already — but for an adopted one the
867
+ // tracked mask starts at zero and the write is absolute, so it would
868
+ // replace whatever this connection had selected on a window another
869
+ // client owns (issue #322). Subscribe past the hook: adopting a window
870
+ // costs no requests and changes no server-side state, and a caller that
871
+ // wants these events still asks for them, with on('map') or selectInput.
872
+ this._listenInternal('map', () => (this._mapped = true));
873
+ this._listenInternal('unmap', () => (this._mapped = false));
874
+ this._listenInternal('destroy', () => {
832
875
  this._destroyed = true;
833
876
  this._forget();
834
877
  this._teardownFrame(false); // the server has already taken the window
@@ -842,6 +885,24 @@ export default class Window extends Drawable {
842
885
  }
843
886
  }
844
887
 
888
+ /**
889
+ * Attach one of ntk's own handlers without the `newListener` hook reading
890
+ * it as a caller asking for the event. The hook's job is to turn expressed
891
+ * interest into a server-side selection; ntk's internal bookkeeping
892
+ * expresses none — it only wants the events that arrive anyway — and on an
893
+ * adopted window a selection nobody asked for overwrites another
894
+ * subsystem's (issue #322). The flag is read synchronously by the hook,
895
+ * which EventEmitter emits before the listener is stored.
896
+ */
897
+ _listenInternal(name, fn) {
898
+ this._internalListen = true;
899
+ try {
900
+ this.on(name, fn);
901
+ } finally {
902
+ this._internalListen = false;
903
+ }
904
+ }
905
+
845
906
  /**
846
907
  * Resolves with this window once its geometry is known — immediately for a
847
908
  * window ntk created (it already knows what it asked for), when the
@@ -1098,7 +1159,11 @@ export default class Window extends Drawable {
1098
1159
 
1099
1160
  _handleExpose(ev) {
1100
1161
  if (this._backingValid) {
1101
- this.X.CopyArea(this._backing.id, this.id, this._presentGc, ev.x, ev.y, ev.x, ev.y, ev.width, ev.height);
1162
+ // an expose can arrive after the connection started closing, and this
1163
+ // runs inside the event dispatch where a throw has no caller (#321)
1164
+ safeRelease(this.X, () => {
1165
+ this.X.CopyArea(this._backing.id, this.id, this._presentGc, ev.x, ev.y, ev.x, ev.y, ev.width, ev.height);
1166
+ });
1102
1167
  } else {
1103
1168
  this._requestRedraw();
1104
1169
  }
@@ -3517,13 +3582,26 @@ export default class Window extends Drawable {
3517
3582
  * either makes you the window manager or rejects with BadAccess because
3518
3583
  * something else already is. The mask is OR-ed into whatever handlers
3519
3584
  * have already asked for.
3585
+ *
3586
+ * A mask this connection already holds is a request that would change
3587
+ * nothing, so it resolves without one — `eventMask` is what this
3588
+ * connection selected and nothing else adds to it behind the caller's
3589
+ * back. It resolves rather than returning undefined because the promise is
3590
+ * the whole interface: an awaited no-op still has to mean "you have it".
3520
3591
  */
3521
3592
  selectInput(mask) {
3522
- this.eventMask |= mask;
3593
+ const added = mask & ~this.eventMask;
3594
+ if (added === 0) return Promise.resolve(this);
3595
+ this.eventMask |= added;
3523
3596
  return new Promise((resolve, reject) => {
3524
- this.X.ChangeWindowAttributes(this.id, { eventMask: this.eventMask }, (err) =>
3525
- err ? reject(err) : resolve(this)
3526
- );
3597
+ this.X.ChangeWindowAttributes(this.id, { eventMask: this.eventMask }, (err) => {
3598
+ if (!err) return resolve(this);
3599
+ // a refused write left the server's mask as it was — give back the
3600
+ // bits, or the next call would find them held and skip a request
3601
+ // that has not happened yet
3602
+ this.eventMask &= ~added;
3603
+ reject(err);
3604
+ });
3527
3605
  });
3528
3606
  }
3529
3607
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.3.0",
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",