react-x11 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,557 @@
1
+ // The Cocoa application object — what `createRoot({ backend: 'cocoa' })`
2
+ // hands the renderer instead of an ntk connection. It answers the same
3
+ // contract the headless mock proves closed (src/testing/mock-app.js):
4
+ // createWindow / fonts / clipboard / X-stub / close, plus the event pump
5
+ // that stands in for the X socket.
6
+ //
7
+ // The run loop is deliberately the simple version (docs/macos.md §"Input
8
+ // and the run loop", option 1): Node's loop is master and a timer pumps
9
+ // AppKit. Input latency is the pump cadence; AppKit's internal modal loops
10
+ // (live resize, menu tracking) stall JS timers — but not the delegate
11
+ // callbacks, which is why live resize still relayouts: the resize event
12
+ // flushes the frame synchronously on its way through (`flushPendingFrames`,
13
+ // the same early-flush a click gets). The CFRunLoop drain (option 2) is the
14
+ // measured upgrade, not the first version.
15
+ import { cssColorStraight } from 'ntk';
16
+
17
+ import { flushPendingFrames } from '../frames.js';
18
+ import { setCompositingForTests } from '../compositing.js';
19
+ import { setScreensForTests } from '../screens.js';
20
+ import { setScaleForTests } from '../scale.js';
21
+ import { BezelStore } from './bezels.js';
22
+ import { CocoaGLArea, cocoaGLConfig, resolveCocoaGLRuntime } from './glarea.js';
23
+ import { CocoaGlobalMenuExport } from './globalmenu.js';
24
+ import { CocoaPaneHost } from './panehost.js';
25
+ import { CocoaPaneWindow } from './panewindow.js';
26
+ import { CocoaFontManager } from './fonts.js';
27
+ import { CocoaWindow } from './window.js';
28
+ import { decodeKey, modifierMask } from './keymap.js';
29
+ import { loadNative } from './native.js';
30
+
31
+ const RAF_INTERVAL_MS = 16;
32
+
33
+ class CocoaApp {
34
+ constructor(native, options = {}) {
35
+ this._native = native;
36
+ this.options = options;
37
+ this._windows = new Map(); // windowNumber -> CocoaWindow
38
+ this._grabWindow = null;
39
+ this._rafQueue = [];
40
+ this._rafLast = 0;
41
+ this._shadowStale = new Set();
42
+ this._pump = null;
43
+ this._closed = false;
44
+
45
+ const screens = native.listScreens();
46
+ this.scale = screens[0]?.scale ?? 1;
47
+ this._screens = screens;
48
+
49
+ // 'surface' (the measured default) or 'layers' — the retained CALayer
50
+ // presenter, opt-in while docs/macos.md's measure-first gate is open.
51
+ this._presenterMode =
52
+ options.cocoa?.presenter ??
53
+ process.env.REACT_X11_COCOA_PRESENTER ??
54
+ 'surface';
55
+
56
+ this.fonts = new CocoaFontManager();
57
+
58
+ // AppKit-rendered control bezels. Its *presence* is the capability:
59
+ // `useSupports('nativeControls')` and the widget set's `controls:
60
+ // 'auto'` policy both test for this property, so a backend without it
61
+ // (X11, the headless mock) draws the themed controls with no further
62
+ // branching.
63
+ this.nativeBezels = new BezelStore(native);
64
+
65
+ // The GL policy, glbackend.js's shape. No GLX exists here, so the
66
+ // default is 'auto' (the direct backend where the runtime loads);
67
+ // useSupports('shaders') stays false until the first <glarea> resolves
68
+ // the runtime and settles _glCapsResolved.
69
+ this.glPolicy = { mode: options.glPolicy ?? 'auto' };
70
+ this._cocoaGL = null;
71
+
72
+ // the global-menu exports, newest active: the macOS menu bar is one per
73
+ // app, so the most recent MenuBar owns it (per-focused-window switching
74
+ // is the follow-up — docs/macos.md §Menus)
75
+ this._globalMenus = [];
76
+ this._activeGlobalMenu = null;
77
+
78
+ // Frame-pane mode: this process renders a pane whose pixels a host
79
+ // composites (REACT_X11_FRAME is what the Frame host sets on the fork).
80
+ this._paneMode = options.pane ?? process.env.REACT_X11_FRAME === '1';
81
+ this._paneSend = null;
82
+
83
+ // The X stub: just enough for the modules that carry an X escape hatch
84
+ // to no-op the way they do against the headless mock.
85
+ const listeners = {};
86
+ this.X = {
87
+ display: { screen: [{ root: 1 }] },
88
+ keycode2keysyms: {},
89
+ InternAtom: (onlyIfExists, name, cb) => {
90
+ if (!this._atoms.has(name)) {
91
+ this._atoms.set(name, 1000 + this._atoms.size);
92
+ }
93
+ cb(null, this._atoms.get(name));
94
+ },
95
+ ConfigureWindow() {},
96
+ SendClientMessage() {},
97
+ on(event, fn) {
98
+ (listeners[event] ??= []).push(fn);
99
+ },
100
+ emit(event, ...args) {
101
+ for (const fn of listeners[event] ?? []) fn(...args);
102
+ },
103
+ };
104
+ this._atoms = new Map();
105
+
106
+ this.clipboard = {
107
+ write: (data) => {
108
+ const text =
109
+ typeof data === 'string'
110
+ ? data
111
+ : (data?.UTF8_STRING ?? data?.STRING ?? '');
112
+ native.pasteboardWriteText(String(text));
113
+ return Promise.resolve();
114
+ },
115
+ clear: () => {
116
+ native.pasteboardClear();
117
+ return Promise.resolve();
118
+ },
119
+ targets: () => {
120
+ const text = native.pasteboardReadText();
121
+ return Promise.resolve(text == null ? [] : ['UTF8_STRING', 'STRING']);
122
+ },
123
+ read: ({ target } = {}) => {
124
+ const text = native.pasteboardReadText();
125
+ if (text == null) {
126
+ return Promise.reject(
127
+ new Error('clipboard: nothing to paste — the pasteboard is empty'),
128
+ );
129
+ }
130
+ if (target === undefined) return Promise.resolve(text);
131
+ if (target === 'UTF8_STRING' || target === 'STRING') {
132
+ return Promise.resolve(Buffer.from(text, 'utf8'));
133
+ }
134
+ return Promise.reject(
135
+ new Error(`clipboard: cannot convert the pasteboard to ${target}`),
136
+ );
137
+ },
138
+ watch: () => Promise.resolve(() => {}),
139
+ };
140
+ }
141
+
142
+ _parseColor(value) {
143
+ return typeof value === 'string' ? cssColorStraight(value) : null;
144
+ }
145
+
146
+ findArgbVisual() {
147
+ // every Cocoa window composites; the "visual" is a formality
148
+ return { visual: 1, depth: 32 };
149
+ }
150
+
151
+ createWindow(attributes = {}) {
152
+ // A frame pane's window: no NSWindow at all — the pane paints into
153
+ // shared IOSurfaces and the HOST composites them (src/cocoa/
154
+ // panewindow.js). Chosen by the same prop the X11 pane uses.
155
+ if (this._paneMode && attributes.embeddable) {
156
+ return new CocoaPaneWindow(this, attributes);
157
+ }
158
+ if (attributes.parent) {
159
+ // A parented "window" here is a GL child surface — GlAreaNode's
160
+ // contract, the one child-window consumer this backend has. It is a
161
+ // sublayer, not an NSWindow; nested <window> elements proper are
162
+ // still not supported.
163
+ if (attributes.parent instanceof CocoaWindow) {
164
+ return new CocoaGLArea(this, attributes);
165
+ }
166
+ throw new Error(
167
+ 'react-x11: nested <window> elements are not supported on the ' +
168
+ 'cocoa backend yet — track docs/macos.md.',
169
+ );
170
+ }
171
+ return new CocoaWindow(this, attributes);
172
+ }
173
+
174
+ /**
175
+ * The `<glarea>` config seam (src/glnodes.js): resolve the GL runtime and
176
+ * answer which rung of the API ladder this area draws through. See
177
+ * src/cocoa/glarea.js for the ladder.
178
+ */
179
+ chooseGLConfig(spec) {
180
+ return cocoaGLConfig(this, spec);
181
+ }
182
+
183
+ /**
184
+ * The Frame host seam (src/frame/index.js): a pane's composited region
185
+ * in this window. Its presence is what routes <Frame> to the shared-
186
+ * memory pane path instead of the X foreign-window embed.
187
+ */
188
+ createPaneHost(wnd) {
189
+ return new CocoaPaneHost(this, wnd);
190
+ }
191
+
192
+ /**
193
+ * The `useGlobalMenu` transport seam: same owner shape as the D-Bus
194
+ * GlobalMenuExport (start/stop/update), pointed at the macOS menu bar.
195
+ */
196
+ createGlobalMenuExport(options) {
197
+ return new CocoaGlobalMenuExport(this, options);
198
+ }
199
+
200
+ _registerGlobalMenu(exporter) {
201
+ this._globalMenus.push(exporter);
202
+ this._activeGlobalMenu = exporter;
203
+ }
204
+
205
+ _unregisterGlobalMenu(exporter) {
206
+ this._globalMenus = this._globalMenus.filter((e) => e !== exporter);
207
+ if (this._activeGlobalMenu === exporter) {
208
+ this._activeGlobalMenu = this._globalMenus.at(-1) ?? null;
209
+ if (this._activeGlobalMenu) this._activeGlobalMenu._install();
210
+ else this._native.setMainMenu([{ title: 'App', items: [] }]);
211
+ }
212
+ }
213
+
214
+ /**
215
+ * What the machine can do, `useSupports('shaders')`'s slow half: ntk
216
+ * settles this during its connect handshake, and here it settles on the
217
+ * first ask — watchDirectGL calls it exactly when a component subscribes
218
+ * before any <glarea> forced the probe (src/glbackend.js).
219
+ */
220
+ glCapabilities() {
221
+ return resolveCocoaGLRuntime(this).then(
222
+ () => this._glCapsResolved,
223
+ () => this._glCapsResolved,
224
+ );
225
+ }
226
+
227
+ _registerWindow(wnd) {
228
+ this._windows.set(wnd.windowNumber, wnd);
229
+ }
230
+
231
+ /**
232
+ * The pane's end of the frame channel (childmain hands it over,
233
+ * feature-detected so the X11 pane path never notices): geometry and
234
+ * input come in, pane-present goes out.
235
+ */
236
+ attachPaneChannel(channel) {
237
+ if (!this._paneMode) return;
238
+ this._paneSend = (msg) => {
239
+ try {
240
+ channel.send(msg);
241
+ } catch {
242
+ // the host is going away; its shutdown owns the rest
243
+ }
244
+ };
245
+ channel.onMessage((msg) => {
246
+ const wnd = [...this._windows.values()][0];
247
+ if (!wnd) return;
248
+ if (msg?.type === 'pane-rect') {
249
+ wnd.setPaneSize(msg.width, msg.height, msg.scale);
250
+ this._afterInput();
251
+ } else if (msg?.type === 'pane-event') {
252
+ wnd.emit(msg.name, msg.ev);
253
+ this._afterInput();
254
+ }
255
+ });
256
+ }
257
+
258
+ _unregisterWindow(wnd) {
259
+ this._windows.delete(wnd.windowNumber);
260
+ if (this._grabWindow === wnd) this._grabWindow = null;
261
+ }
262
+
263
+ // --- the pump ------------------------------------------------------------
264
+
265
+ start({ pumpInterval = 8 } = {}) {
266
+ if (this._pump) return;
267
+ const native = this._native;
268
+ // A pane process has no NSApplication to pump — no windows, no events,
269
+ // no dock presence. Its loop is frames and presents only.
270
+ if (this._paneMode) {
271
+ this._pump = setInterval(() => {
272
+ this._tickFrames();
273
+ this._presentAll();
274
+ }, pumpInterval);
275
+ return;
276
+ }
277
+ native.initApp();
278
+ native.setBackendEventCallback((ev) => this._route(ev));
279
+ this._pump = setInterval(() => {
280
+ native.pump2(); // flushes the previous tick's CATransaction
281
+ if (this._shadowStale.size) {
282
+ for (const wnd of this._shadowStale) {
283
+ if (!wnd.destroyed) native.invalidateWindowShadow(wnd._h);
284
+ }
285
+ this._shadowStale.clear();
286
+ }
287
+ this._tickFrames();
288
+ this._presentAll();
289
+ }, pumpInterval);
290
+ }
291
+
292
+ _requestFrame(cb) {
293
+ this._rafQueue.push(cb);
294
+ return this._rafQueue.length;
295
+ }
296
+
297
+ _tickFrames() {
298
+ if (!this._rafQueue.length) return;
299
+ const now = Date.now();
300
+ if (now - this._rafLast < RAF_INTERVAL_MS) return;
301
+ this._rafLast = now;
302
+ const queue = this._rafQueue;
303
+ this._rafQueue = [];
304
+ for (const cb of queue) {
305
+ try {
306
+ cb(now);
307
+ } catch (err) {
308
+ queueMicrotask(() => {
309
+ throw err;
310
+ });
311
+ }
312
+ }
313
+ }
314
+
315
+ _presentAll() {
316
+ for (const wnd of this._windows.values()) wnd.present();
317
+ }
318
+
319
+ /** After any synchronously dispatched input: paint the response now (the
320
+ * same early flush a discrete event gets on X11) and put it on glass. */
321
+ _afterInput() {
322
+ flushPendingFrames();
323
+ this._presentAll();
324
+ }
325
+
326
+ // --- event routing -------------------------------------------------------
327
+
328
+ _route(ev) {
329
+ if (this._closed) return;
330
+ switch (ev.type) {
331
+ case 'mousedown':
332
+ case 'mouseup':
333
+ return this._routeButton(ev);
334
+ case 'mousemove':
335
+ return this._routeMotion(ev, 'mousemove');
336
+ case 'mouseleave':
337
+ return this._routeMotion(ev, 'mouseout');
338
+ case 'mouseenter':
339
+ return this._routeMotion(ev, 'mousemove');
340
+ case 'wheel':
341
+ return this._routeWheel(ev);
342
+ case 'keydown':
343
+ case 'keyup':
344
+ return this._routeKey(ev);
345
+ case 'window-resize':
346
+ case 'window-move':
347
+ return this._routeGeometry(ev);
348
+ case 'window-close-request':
349
+ return this._routeClose(ev);
350
+ case 'window-focus':
351
+ return this._routeFocus(ev, 'focus');
352
+ case 'window-blur':
353
+ return this._routeFocus(ev, 'blur');
354
+ case 'menu-activate':
355
+ this._activeGlobalMenu?.activate(ev.id);
356
+ return this._afterInput();
357
+ default:
358
+ return undefined;
359
+ }
360
+ }
361
+
362
+ _window(ev) {
363
+ return ev.windowNumber != null
364
+ ? (this._windows.get(ev.windowNumber) ?? null)
365
+ : null;
366
+ }
367
+
368
+ /**
369
+ * The grab rule, X-shaped: while a popup holds the "pointer grab", a press
370
+ * in any other window of ours is delivered to the grab holder in the grab
371
+ * holder's coordinates — landing outside its bounds, which is what its
372
+ * event manager reads as a dismissal (events.js `_pressOutside`).
373
+ */
374
+ _grabTarget(ev, wnd) {
375
+ const grab = this._grabWindow;
376
+ if (!grab || grab.destroyed || grab === wnd) return null;
377
+ const s = this.scale;
378
+ return {
379
+ wnd: grab,
380
+ x: Math.round(ev.gx * s) - grab._screenOrigin.x,
381
+ y: Math.round(ev.gy * s) - grab._screenOrigin.y,
382
+ };
383
+ }
384
+
385
+ _routeButton(ev) {
386
+ const wnd = this._window(ev);
387
+ if (!wnd) return;
388
+ const s = this.scale;
389
+ const buttons = modifierMask(ev);
390
+ const redirect = ev.type === 'mousedown' ? this._grabTarget(ev, wnd) : null;
391
+ const target = redirect?.wnd ?? wnd;
392
+ target.emit(ev.type, {
393
+ x: redirect ? redirect.x : Math.round(ev.x * s),
394
+ y: redirect ? redirect.y : Math.round(ev.y * s),
395
+ rootx: Math.round(ev.gx * s),
396
+ rooty: Math.round(ev.gy * s),
397
+ keycode: ev.button,
398
+ buttons,
399
+ time: ev.time,
400
+ });
401
+ this._afterInput();
402
+ }
403
+
404
+ _routeMotion(ev, name) {
405
+ const wnd = this._window(ev);
406
+ if (!wnd) return;
407
+ const s = this.scale;
408
+ wnd.emit(name, {
409
+ x: Math.round(ev.x * s),
410
+ y: Math.round(ev.y * s),
411
+ rootx: Math.round(ev.gx * s),
412
+ rooty: Math.round(ev.gy * s),
413
+ buttons: modifierMask(ev),
414
+ time: ev.time,
415
+ });
416
+ // motion is paced on the frame clock, not flushed per event
417
+ }
418
+
419
+ _routeWheel(ev) {
420
+ const wnd = this._window(ev);
421
+ if (!wnd) return;
422
+ const s = this.scale;
423
+ // AppKit: positive deltas scroll toward the top (natural direction
424
+ // already folded in); the renderer's notches are the X convention,
425
+ // positive = content advancing downward. Precise deltas are points —
426
+ // 48 device pixels is one notch, so a swipe maps 1:1 onto pixels.
427
+ const toNotches = (delta) =>
428
+ ev.precise
429
+ ? (-delta * s) / 48
430
+ : -Math.sign(delta) * Math.ceil(Math.abs(delta));
431
+ const deltaX = toNotches(ev.dx);
432
+ const deltaY = toNotches(ev.dy);
433
+ if (!deltaX && !deltaY) return;
434
+ wnd.emit('wheel', {
435
+ name: 'wheel',
436
+ x: Math.round(ev.x * s),
437
+ y: Math.round(ev.y * s),
438
+ rootx: Math.round(ev.gx * s),
439
+ rooty: Math.round(ev.gy * s),
440
+ buttons: modifierMask(ev),
441
+ deltaX,
442
+ deltaY,
443
+ deltaMode: 'line',
444
+ smooth: Boolean(ev.precise),
445
+ source: ev.precise ? 'valuator' : 'button',
446
+ });
447
+ }
448
+
449
+ _routeKey(ev) {
450
+ // keys go to the key window; without one (all popups) the last focused
451
+ // toplevel keeps the keyboard, which matches the focus model upstairs
452
+ const wnd = this._window(ev) ?? this._lastKeyWindow;
453
+ if (!wnd) return;
454
+ const decoded = decodeKey(ev);
455
+ wnd.emit(ev.type, {
456
+ keycode: ev.keyCode,
457
+ keysym: decoded.keysym,
458
+ baseKeysym: decoded.baseKeysym,
459
+ codepoint: decoded.codepoint,
460
+ buttons: modifierMask(ev),
461
+ group: 0,
462
+ time: ev.time,
463
+ });
464
+ this._afterInput();
465
+ }
466
+
467
+ _routeGeometry(ev) {
468
+ const wnd = this._window(ev);
469
+ if (!wnd || wnd.destroyed) return;
470
+ wnd._nativeResized(ev);
471
+ wnd.emit('resize', {
472
+ width: wnd.width,
473
+ height: wnd.height,
474
+ x: wnd.x,
475
+ y: wnd.y,
476
+ moved: true,
477
+ resized: ev.type === 'window-resize',
478
+ });
479
+ // During a live resize AppKit's modal loop owns the thread and Node
480
+ // timers stall; flushing here is what keeps layout tracking the drag.
481
+ this._afterInput();
482
+ // The React HALF of the response — an anchored popup following the
483
+ // window, an onResize setState — commits on a microtask AFTER this
484
+ // handler returns, and the pump that would paint it is the thing the
485
+ // modal loop stalled. A second flush queued BEHIND that commit is what
486
+ // lets an open menu resize with the drag instead of on release.
487
+ queueMicrotask(() => {
488
+ if (!this._closed) this._afterInput();
489
+ });
490
+ }
491
+
492
+ _routeClose(ev) {
493
+ const wnd = this._window(ev);
494
+ if (!wnd) return;
495
+ wnd.emit('close', {
496
+ preventDefault() {},
497
+ });
498
+ this._afterInput();
499
+ }
500
+
501
+ _routeFocus(ev, name) {
502
+ const wnd = this._window(ev);
503
+ if (!wnd) return;
504
+ if (name === 'focus') this._lastKeyWindow = wnd;
505
+ wnd.emit(name, { buttons: 0, time: ev.time });
506
+ this._afterInput();
507
+ }
508
+
509
+ // --- teardown ------------------------------------------------------------
510
+
511
+ close() {
512
+ if (this._closed) return Promise.resolve();
513
+ this._closed = true;
514
+ if (this._pump) clearInterval(this._pump);
515
+ this._pump = null;
516
+ this._cocoaGL?.destroy();
517
+ this._cocoaGL = null;
518
+ this._native.setBackendEventCallback(null);
519
+ for (const wnd of [...this._windows.values()]) wnd.destroy();
520
+ return Promise.resolve();
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Build the app and seed the platform stores the way the mock seeds them —
526
+ * `beginScale`/`beginScreens`/`beginCompositing` find a session already
527
+ * open and leave it alone, so `createRoot`'s shared flow runs unchanged.
528
+ */
529
+ export async function createCocoaApp(options = {}) {
530
+ const native = loadNative();
531
+ const app = new CocoaApp(native, options);
532
+
533
+ setScaleForTests(app, app.scale, 'cocoa');
534
+ const s = app.scale;
535
+ const monitors = app._screens.map((screen) => ({
536
+ x: Math.round(screen.x * s),
537
+ y: Math.round(screen.y * s),
538
+ width: Math.round(screen.width * s),
539
+ height: Math.round(screen.height * s),
540
+ }));
541
+ const primary = app._screens[0];
542
+ setScreensForTests(app, {
543
+ monitors,
544
+ workArea: primary
545
+ ? {
546
+ x: Math.round(primary.visible.x * s),
547
+ y: Math.round(primary.visible.y * s),
548
+ width: Math.round(primary.visible.width * s),
549
+ height: Math.round(primary.visible.height * s),
550
+ }
551
+ : null,
552
+ });
553
+ setCompositingForTests(app, true);
554
+
555
+ app.start(options.cocoa ?? {});
556
+ return app;
557
+ }