ntk 8.3.1 → 8.4.1

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,7 +1,7 @@
1
1
  import { connectionGone } from './cleanup.js';
2
2
  import Clipboard from './clipboard.js';
3
3
  import { CursorCache } from './cursor.js';
4
- import { GLError, backendFor, glCapabilities, glError, resolveGLPolicy } from './gl.js';
4
+ import { GLError, backendFor, glCapabilities, glError, nativeRefreshRate, resolveGLPolicy } from './gl.js';
5
5
  import { chooseGLXConfig } from './glx.js';
6
6
  import Picture from './picture.js';
7
7
  import Pixmap from './pixmap.js';
@@ -254,6 +254,19 @@ export default class App {
254
254
  _probeRefreshRate() {
255
255
  this._refreshProbe = null; // in progress; only ever started once
256
256
  const X = this.X;
257
+ // RandR's answer, or the native layer's where RandR has no usable one.
258
+ // An implausible rate is treated as no answer, not clamped: XQuartz
259
+ // synthesizes real timing for its canned mode list but fills the current
260
+ // desktop-sized mode with dot_clock = width * height — exactly 1 Hz — so
261
+ // the number that describes what is actually driving the panel has to
262
+ // come from the OS (x11-dri >= 0.6.0, apple.refreshRate()).
263
+ const plausible = (rate) => rate >= MIN_REFRESH_RATE && rate <= MAX_REFRESH_RATE;
264
+ const settle = (rate) => {
265
+ if (!plausible(rate)) rate = nativeRefreshRate();
266
+ if (!rate || !plausible(rate)) return;
267
+ this._refreshRate = rate;
268
+ this._adoptFrameInterval(1000 / rate);
269
+ };
257
270
  // A connection on its way out is one more "no rate to be had". The probe
258
271
  // is started lazily by the first window built on this connection, which
259
272
  // may be one adopted from an event still arriving as it closes — and
@@ -261,10 +274,10 @@ export default class App {
261
274
  // event dispatch has no caller to catch it (issue #321).
262
275
  if (connectionGone(X)) return;
263
276
  X.require('randr', (err, R) => {
264
- if (err || !R) return;
277
+ if (err || !R) return settle(0);
265
278
  const root = this.display.screen[0].root;
266
279
  R.GetScreenResourcesCurrent(root, (resourcesError, resources) => {
267
- if (resourcesError || !resources?.crtcs?.length) return;
280
+ if (resourcesError || !resources?.crtcs?.length) return settle(0);
268
281
  const modes = new Map(resources.modeinfos.map((mode) => [mode.id, mode]));
269
282
  let pending = resources.crtcs.length;
270
283
  let best = 0;
@@ -273,9 +286,7 @@ export default class App {
273
286
  // a crtc with no mode is one that is switched off
274
287
  if (!crtcError && info) best = Math.max(best, modeRate(modes.get(info.mode)));
275
288
  if (--pending) return;
276
- if (best < MIN_REFRESH_RATE || best > MAX_REFRESH_RATE) return;
277
- this._refreshRate = best;
278
- this._adoptFrameInterval(1000 / best);
289
+ settle(best);
279
290
  });
280
291
  }
281
292
  });
@@ -603,11 +614,11 @@ export default class App {
603
614
  * `createWindow` and the whole object to `wnd.getContext('opengl', config)`.
604
615
  *
605
616
  * 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.
617
+ * should not have to write the request twice: `DEPTH_SIZE` becomes the
618
+ * depth-buffer size of the direct backend's context (EGL on Linux, CGL on
619
+ * macOS), and `ALPHA_SIZE` picks an ARGB visual there rather than an
620
+ * fbconfig with an alpha channel. Direct needs no round trip to answer —
621
+ * there are no fbconfigs in it, only a window the GPU draws for.
611
622
  *
612
623
  * @param {object} [spec] GLX attributes, e.g. `{ DEPTH_SIZE: 24 }`
613
624
  */
@@ -629,6 +640,8 @@ export default class App {
629
640
  }
630
641
  return {
631
642
  backend: 'direct',
643
+ // which direct pipeline this connection runs: 'dri3' or 'appledri'
644
+ flavor: caps.flavor,
632
645
  // depth 24 on the root visual needs no colormap of its own, which is
633
646
  // why an opaque GL window here is an ordinary window
634
647
  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
  };
@@ -152,6 +171,22 @@ export function setDriAddon(module) {
152
171
  addon = module;
153
172
  }
154
173
 
174
+ /**
175
+ * The display's refresh rate asked of the native layer, in Hz, for servers
176
+ * whose RandR carries no usable timing — XQuartz reports the current
177
+ * desktop-sized mode with dot_clock = width * height, exactly 1 Hz.
178
+ * `null` everywhere there is no answer: not darwin, no addon (or one
179
+ * predating 0.6.0), or a session with no display to ask.
180
+ */
181
+ export function nativeRefreshRate() {
182
+ if (globalThis.process?.platform !== 'darwin') return null;
183
+ try {
184
+ return loadDriAddon()?.apple?.refreshRate?.() ?? null;
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
155
190
  const INSTALL_HINT = `Direct rendering needs the optional native addon:
156
191
 
157
192
  npm install x11-dri
@@ -161,11 +196,18 @@ tools are needed; anything else compiles with node-gyp and a C toolchain. ntk
161
196
  does not depend on it — without it, GL runs through indirect GLX.`;
162
197
 
163
198
  /**
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.
199
+ * What the *client side* can do, before any server is asked: the addon and
200
+ * the platform libraries under it — plus, on Linux, a render node to draw
201
+ * on. Never throws.
202
+ *
203
+ * `flavor` on an ok answer says which direct pipeline this machine runs:
204
+ * `'dri3'` (Linux — GBM/EGL, dma-buf to the server) or `'appledri'`
205
+ * (macOS — Apple-DRI surface export, CGL). macOS needs no device scan; the
206
+ * window's surface is the render target, so `device` is `null` there.
166
207
  *
167
208
  * @returns {{ok: boolean, code?: string, message?: string, hint?: string,
168
- * device?: string, devices?: string[], probe?: object}}
209
+ * flavor?: 'dri3'|'appledri', device?: string|null, devices?: string[],
210
+ * probe?: object}}
169
211
  */
170
212
  export function probeDirect(policy = DEFAULT_GL_POLICY) {
171
213
  const dri = loadDriAddon();
@@ -185,6 +227,28 @@ export function probeDirect(policy = DEFAULT_GL_POLICY) {
185
227
  return { ok: false, code: GLError.NO_DRIVER, message: `x11-dri probe() failed: ${err.message}` };
186
228
  }
187
229
 
230
+ if (probe.platform === 'darwin') {
231
+ // The macOS path draws through Apple-DRI + CGL, not GBM/EGL — probe()
232
+ // reports it as `appledri`: `true`, or the string saying why not. An
233
+ // addon from before 0.5.0 has no such key at all.
234
+ if (probe.appledri !== true) {
235
+ return {
236
+ ok: false,
237
+ code: GLError.NO_DRIVER,
238
+ message: `the libraries direct rendering needs on macOS are unavailable (appledri: ${
239
+ probe.appledri ?? 'not reported — this x11-dri predates Apple-DRI support'
240
+ })`,
241
+ hint:
242
+ probe.appledri === undefined
243
+ ? 'Upgrade the addon: npm install x11-dri@latest (Apple-DRI support arrived in 0.5.0).'
244
+ : 'The macOS path needs XQuartz installed (it dlopen()s libXplugin from /opt/X11)\nand the system OpenGL framework — https://www.xquartz.org.',
245
+ probe
246
+ };
247
+ }
248
+ // no render nodes on macOS: the window's own surface is the target
249
+ return { ok: true, flavor: 'appledri', device: null, devices: [], probe };
250
+ }
251
+
188
252
  // probe() reports each capability as `true` or as the string saying why not
189
253
  const missing = ['gbm', 'egl', 'gles'].filter((key) => probe[key] !== true);
190
254
  if (missing.length) {
@@ -195,7 +259,7 @@ export function probeDirect(policy = DEFAULT_GL_POLICY) {
195
259
  message: `the GPU libraries direct rendering needs are unavailable (${detail})`,
196
260
  hint:
197
261
  probe.platform && probe.platform !== 'linux'
198
- ? `Direct rendering is Linux-only — dma-buf, GBM and DRI3 have no equivalent on ${probe.platform}.`
262
+ ? `Direct rendering needs Linux (DRI3/dma-buf) or macOS (Apple-DRI/XQuartz) — ${probe.platform} has neither path.`
199
263
  : '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
264
  probe
201
265
  };
@@ -219,7 +283,7 @@ export function probeDirect(policy = DEFAULT_GL_POLICY) {
219
283
  devices
220
284
  };
221
285
  }
222
- return { ok: true, device, devices, probe };
286
+ return { ok: true, flavor: 'dri3', device, devices, probe };
223
287
  }
224
288
 
225
289
  // ---------------------------------------------------------------------------
@@ -253,13 +317,20 @@ const requireExt = (X, name) =>
253
317
  /**
254
318
  * Everything about `app` that decides the backend, answered once and cached.
255
319
  *
256
- * The two extension queries are the only round trips, and they only happen
320
+ * The extension queries are the only round trips, and they only happen
257
321
  * under a policy that could use direct — `createClient` warms this during the
258
322
  * connect handshake for exactly that reason, so `getContext` can decide
259
323
  * synchronously afterwards.
260
324
  *
261
- * @returns {Promise<{direct: boolean, indirect: boolean, device: string|null,
262
- * reason: Error|null, DRI3: object|null, Present: object|null}>}
325
+ * `flavor` names the direct pipeline where `direct` is true: `'dri3'`
326
+ * carries `DRI3` and `Present`, `'appledri'` carries `AppleDRI` (the
327
+ * lib/appledri.js extension object) and `appleClientId` (this process's
328
+ * WindowServer id, which CreateSurface exports surfaces to).
329
+ *
330
+ * @returns {Promise<{direct: boolean, indirect: boolean,
331
+ * flavor: 'dri3'|'appledri'|null, device: string|null, reason: Error|null,
332
+ * DRI3: object|null, Present: object|null, AppleDRI: object|null,
333
+ * appleClientId: number|null}>}
263
334
  */
264
335
  export function glCapabilities(app) {
265
336
  if (app._glCaps) return app._glCaps;
@@ -269,10 +340,13 @@ export function glCapabilities(app) {
269
340
  const fail = (code, message, hint) => ({
270
341
  direct: false,
271
342
  indirect,
343
+ flavor: null,
272
344
  device: null,
273
345
  reason: glError(code, message, hint),
274
346
  DRI3: null,
275
- Present: null
347
+ Present: null,
348
+ AppleDRI: null,
349
+ appleClientId: null
276
350
  });
277
351
 
278
352
  if (policy.mode === 'off') {
@@ -281,11 +355,67 @@ export function glCapabilities(app) {
281
355
  if (!app.display.isLocalSocket) {
282
356
  return fail(
283
357
  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.'
358
+ '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)',
359
+ 'Over a network, indirect GLX is the backend that can work at all — leave\n' +
360
+ 'glPolicy at its default.'
287
361
  );
288
362
  }
363
+
364
+ const client = probeDirect(policy);
365
+ if (!client.ok) return fail(client.code, client.message, client.hint);
366
+
367
+ if (client.flavor === 'appledri') {
368
+ // No descriptor ever crosses this socket — the fd-passing checks the
369
+ // dri3 flavor needs below do not apply, which is also what lets this
370
+ // path work under runtimes that cannot send one.
371
+ const dri = loadDriAddon();
372
+ let appleClientId;
373
+ try {
374
+ // the WindowServer handshake happens on first call; a session with
375
+ // no WindowServer (SSH into the machine) is where it throws
376
+ appleClientId = dri.apple.clientId();
377
+ } catch (err) {
378
+ return fail(
379
+ GLError.NO_WINDOWSERVER,
380
+ `this process has no WindowServer session to import surfaces into (${err.message})`,
381
+ 'Apple-DRI hands the window surface to the WindowServer connection of this\n' +
382
+ 'process, which an SSH session does not have. Run the app from the logged-in\n' +
383
+ 'GUI session; over SSH, indirect GLX is the backend that can work.'
384
+ );
385
+ }
386
+ const AppleDRI = await requireAppleDRI(app.display);
387
+ if (!AppleDRI) {
388
+ return fail(
389
+ GLError.NO_APPLEDRI,
390
+ `${displayName()} does not have the Apple-DRI extension, so it cannot export a window surface to render into`,
391
+ 'Apple-DRI is XQuartz\'s direct-rendering extension — is this display an\n' +
392
+ 'XQuartz server? Indirect GLX is the backend that can work on any other.'
393
+ );
394
+ }
395
+ const capable = await new Promise((resolve) =>
396
+ AppleDRI.QueryDirectRenderingCapable(0, (err, answer) => resolve(err ? false : answer))
397
+ );
398
+ if (!capable) {
399
+ return fail(
400
+ GLError.NO_APPLEDRI,
401
+ `${displayName()} has Apple-DRI but reports it not direct-rendering capable`,
402
+ 'XQuartz answers this false when its Xplugin backend is not driving a real\n' +
403
+ 'display. Indirect GLX is the backend that can work on such a server.'
404
+ );
405
+ }
406
+ return {
407
+ direct: true,
408
+ indirect,
409
+ flavor: 'appledri',
410
+ device: null,
411
+ reason: null,
412
+ DRI3: null,
413
+ Present: null,
414
+ AppleDRI,
415
+ appleClientId
416
+ };
417
+ }
418
+
289
419
  if (!canPassDescriptors(app.display)) {
290
420
  // The socket is local; what is missing is the ability to send a
291
421
  // descriptor along it. x11 does that through Node's internal
@@ -302,9 +432,6 @@ export function glCapabilities(app) {
302
432
  );
303
433
  }
304
434
 
305
- const client = probeDirect(policy);
306
- if (!client.ok) return fail(client.code, client.message, client.hint);
307
-
308
435
  const X = app.X;
309
436
  const [DRI3, Present] = await Promise.all([requireExt(X, 'dri3'), requireExt(X, 'present')]);
310
437
  if (!DRI3 || !Present) {
@@ -325,7 +452,17 @@ export function glCapabilities(app) {
325
452
  );
326
453
  }
327
454
 
328
- return { direct: true, indirect, device: client.device, reason: null, DRI3, Present };
455
+ return {
456
+ direct: true,
457
+ indirect,
458
+ flavor: 'dri3',
459
+ device: client.device,
460
+ reason: null,
461
+ DRI3,
462
+ Present,
463
+ AppleDRI: null,
464
+ appleClientId: null
465
+ };
329
466
  })();
330
467
  return app._glCaps;
331
468
  }
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
@@ -109,6 +109,16 @@ const REFRESH_QUANTILE = 0.25;
109
109
  const STALL_TIMEOUT = 2000;
110
110
  const STALL_TIMEOUT_FIRST = 250;
111
111
 
112
+ /**
113
+ * MotionNotify detail saying "this is the only motion you get until you ask".
114
+ *
115
+ * The core protocol calls it NotifyHint and puts it in the event's detail
116
+ * byte; node-x11 parses a MotionNotify's detail into `keycode`, the field
117
+ * that carries a button number on ButtonPress — so this is compared against
118
+ * `ev.keycode`, not `ev.detail`, which motion events do not have.
119
+ */
120
+ const MOTION_NOTIFY_HINT = 1;
121
+
112
122
  function rectArea(r) {
113
123
  return Math.max(0, r.w) * Math.max(0, r.h);
114
124
  }
@@ -493,6 +503,15 @@ export default class Window extends Drawable {
493
503
  this._geSinkOpcode = 0;
494
504
  // XI2 state, once a window has selected it (see selectXI2)
495
505
  this._xi2 = null;
506
+ // PointerMotionHint bookkeeping (see setMouseHintOnly and
507
+ // _rearmMotionHint): whether a frame owes the server the QueryPointer
508
+ // that re-arms the hint, whether one is already in flight, the timestamp
509
+ // of the hint that asked for it, and the last position delivered — a
510
+ // reply that repeats it is not an event.
511
+ this._hintRearm = false;
512
+ this._hintPollPending = false;
513
+ this._hintTime = 0;
514
+ this._hintLast = null;
496
515
  // The vblank clock (see _onPresentComplete): the period learnt from
497
516
  // completion events, the samples it is drawn from, and the latch the
498
517
  // watchdog sets when completions stop arriving.
@@ -762,6 +781,21 @@ export default class Window extends Drawable {
762
781
  if (key.codepoint !== undefined) ev.codepoint = key.codepoint;
763
782
  }
764
783
  }
784
+ // the server saying "the pointer moved, and I will say no more about it
785
+ // until you ask": the event still carries the position it was generated
786
+ // at, so it is delivered like any other move, but the conversation has
787
+ // to be picked back up or this is the last one (see _rearmMotionHint)
788
+ if (eventName === 'mousemove' && ev.keycode === MOTION_NOTIFY_HINT) {
789
+ this._hintTime = ev.time;
790
+ this._hintLast = { x: ev.x, y: ev.y };
791
+ this._hintRearm = true;
792
+ this._deliverEvent(eventName, ntkev);
793
+ // the frame that just took the move sends the poll — one per frame,
794
+ // which is the rate a frame's worth of motion is reduced to anyway.
795
+ // An uncoalesced window has no frame to hang it on, so it goes now.
796
+ if (!this._coalesce) this._rearmMotionHint();
797
+ return;
798
+ }
765
799
  // a wheel is a button in the core protocol; say so in the units a
766
800
  // consumer wants. Suppressed once XI2 is delivering the same scroll as
767
801
  // valuators, which is the whole reason to select it (see selectXI2).
@@ -1393,6 +1427,9 @@ export default class Window extends Drawable {
1393
1427
  )
1394
1428
  return;
1395
1429
  this._flushCoalesced();
1430
+ // after the flush: the frame's motion has been delivered, so what the
1431
+ // server is asked for now is what happened since
1432
+ if (this._hintRearm) this._rearmMotionHint();
1396
1433
  if (f.needsRedraw) {
1397
1434
  f.needsRedraw = false;
1398
1435
  const ev = {
@@ -1987,6 +2024,9 @@ export default class Window extends Drawable {
1987
2024
  f.rafCbs = [];
1988
2025
  f.needsRedraw = false;
1989
2026
  this._presentPending = false;
2027
+ // a window with no frames left has nothing to send the poll from
2028
+ this._hintRearm = false;
2029
+ this._hintLast = null;
1990
2030
  }
1991
2031
 
1992
2032
  /**
@@ -3252,11 +3292,45 @@ export default class Window extends Drawable {
3252
3292
  return this;
3253
3293
  }
3254
3294
 
3295
+ /**
3296
+ * Trade motion events for round trips: ask the server to report the pointer
3297
+ * moving *once*, and then wait to be asked where it went.
3298
+ *
3299
+ * PointerMotionHint is a two-party protocol. With it selected the server
3300
+ * may send a single MotionNotify carrying detail NotifyHint and then say
3301
+ * nothing more about the pointer until the client asks — so a window that
3302
+ * only sets the bit hears about one move and then silence (issue #319).
3303
+ * ntk holds up the other end: a hinted move is delivered with the position
3304
+ * it carries, and the frame it lands in sends the QueryPointer that lets
3305
+ * the server speak again (see _rearmMotionHint).
3306
+ *
3307
+ * What that buys, and what it costs, are the same thing. Motion stops being
3308
+ * paced by the input device and starts being paced by the connection: one
3309
+ * event per round trip instead of one per hardware sample, which is a large
3310
+ * saving on a link where bytes are scarce — X over ssh, a tunnel, a slow
3311
+ * network — and no saving at all on a local socket, where the poll costs
3312
+ * more bytes than the events it replaced. It also means a handler acts on a
3313
+ * position that is up to one round trip old. Reach for it when bandwidth is
3314
+ * the problem; leave it alone when latency is.
3315
+ *
3316
+ * Two things it is not:
3317
+ *
3318
+ * - not a substitute for selecting motion. The hint modifies
3319
+ * PointerMotion, it does not imply it — a window with no `mousemove`
3320
+ * listener and no PointerMotion in its mask receives nothing either way.
3321
+ * - not an XI2 control. A window that called `selectXI2(['Motion'])` is
3322
+ * off the core motion stream entirely (see lib/xi2.js), and the core
3323
+ * hint has nothing left to thin.
3324
+ */
3255
3325
  setMouseHintOnly(isOn) {
3256
3326
  if (isOn && !(this.eventMask & x11.eventMask.PointerMotionHint)) {
3257
3327
  this.eventMask |= x11.eventMask.PointerMotionHint;
3258
3328
  } else if (!isOn && this.eventMask & x11.eventMask.PointerMotionHint) {
3259
3329
  this.eventMask &= ~x11.eventMask.PointerMotionHint;
3330
+ // the server will report motion on its own again, so no frame owes it a
3331
+ // question. A poll already in flight is left alone: its answer is a
3332
+ // real position, and it is deduped against the last one like any other.
3333
+ this._hintRearm = false;
3260
3334
  } else {
3261
3335
  return this;
3262
3336
  }
@@ -3265,6 +3339,63 @@ export default class Window extends Drawable {
3265
3339
  return this;
3266
3340
  }
3267
3341
 
3342
+ /**
3343
+ * Answer a NotifyHint: ask where the pointer is, which is what re-arms the
3344
+ * server, and deliver the answer if it moved somewhere no event reported.
3345
+ *
3346
+ * The reply is not redundant with the hint that asked for it. Between the
3347
+ * hint being generated and the server processing this request the pointer
3348
+ * may have moved again, and that motion produces no event — it is exactly
3349
+ * what the hint suppressed. Dropped, the last position of a gesture that
3350
+ * ends mid-flight is lost and the window's idea of where the pointer is
3351
+ * stays wrong until it moves again. So the poll's position is delivered as
3352
+ * a motion event when it differs from the last one delivered, and nothing
3353
+ * is emitted when the pointer sat still.
3354
+ *
3355
+ * One poll at a time, and one per frame: a burst of motion cannot queue a
3356
+ * round trip each, and the flag stays raised if a poll is already out, so
3357
+ * the next frame picks it up.
3358
+ */
3359
+ _rearmMotionHint() {
3360
+ if (this._hintPollPending) return;
3361
+ this._hintRearm = false;
3362
+ if (this._destroyed || connectionGone(this.X)) return;
3363
+ this._hintPollPending = true;
3364
+ const time = this._hintTime;
3365
+ this.X.QueryPointer(this.id, (err, pointer) => {
3366
+ this._hintPollPending = false;
3367
+ if (err || this._destroyed || connectionGone(this.X)) return;
3368
+ // the pointer is on another screen: `child` and the window-relative
3369
+ // coordinates are zero by protocol, not a position (core QueryPointer)
3370
+ if (!pointer.sameScreen) return;
3371
+ const last = this._hintLast;
3372
+ if (last && last.x === pointer.childX && last.y === pointer.childY) return;
3373
+ this._hintLast = { x: pointer.childX, y: pointer.childY };
3374
+ this._deliverEvent('mousemove', {
3375
+ type: 6,
3376
+ name: 'MotionNotify',
3377
+ // the poll has no timestamp of its own — QueryPointer's reply carries
3378
+ // no time — so the event is stamped with the hint that prompted it
3379
+ time,
3380
+ keycode: 0,
3381
+ root: pointer.root,
3382
+ wid: this.id,
3383
+ child: pointer.child,
3384
+ rootx: pointer.rootX,
3385
+ rooty: pointer.rootY,
3386
+ x: pointer.childX,
3387
+ y: pointer.childY,
3388
+ buttons: pointer.keyMask,
3389
+ sameScreen: pointer.sameScreen,
3390
+ // built from a reply rather than read off the wire, the way a paced
3391
+ // window's redraw events are
3392
+ synthetic: true,
3393
+ window: this,
3394
+ target: this
3395
+ });
3396
+ });
3397
+ }
3398
+
3268
3399
  queryPointer(callback) {
3269
3400
  this.X.QueryPointer(this.id, callback);
3270
3401
  return this;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.3.1",
3
+ "version": "8.4.1",
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",