react-x11 2.11.0 → 2.13.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.
Files changed (100) hide show
  1. package/README.md +278 -129
  2. package/package.json +12 -4
  3. package/src/Reconciler.js +19 -31
  4. package/src/a11y.js +2 -2
  5. package/src/anchor.js +7 -5
  6. package/src/appcontext.js +59 -30
  7. package/src/bootstrap.js +14 -0
  8. package/src/clientmessage.js +1 -1
  9. package/src/cocoa/app.js +303 -49
  10. package/src/cocoa/bezels.js +175 -30
  11. package/src/cocoa/dnd.js +27 -13
  12. package/src/cocoa/fonts.js +3 -3
  13. package/src/cocoa/glarea.js +24 -5
  14. package/src/cocoa/main.d.ts +8 -0
  15. package/src/cocoa/main.js +43 -0
  16. package/src/cocoa/overlay.js +159 -0
  17. package/src/cocoa/panehost.js +15 -5
  18. package/src/cocoa/presenter.js +13 -9
  19. package/src/cocoa/promotion.js +17 -7
  20. package/src/cocoa/relaunch.js +207 -0
  21. package/src/cocoa/threaded.js +246 -0
  22. package/src/cocoa/window.js +256 -42
  23. package/src/components/Select.js +2 -2
  24. package/src/components/anchor.js +3 -3
  25. package/src/components/native.js +12 -7
  26. package/src/components/theme.js +2 -2
  27. package/src/debug.js +1 -1
  28. package/src/decorations.js +1 -1
  29. package/src/editmenu.js +2 -2
  30. package/src/embedding.js +31 -0
  31. package/src/errors.js +46 -0
  32. package/src/events.js +78 -18
  33. package/src/foreignnodes.js +59 -5
  34. package/src/frames.js +2 -2
  35. package/src/glnodes.js +172 -41
  36. package/src/gloverlay.js +383 -0
  37. package/src/grid.js +1653 -0
  38. package/src/host.d.ts +230 -1
  39. package/src/host.js +11 -3
  40. package/src/imagesource.js +1 -1
  41. package/src/index.d.ts +34 -4
  42. package/src/index.js +9 -1
  43. package/src/layouts.js +721 -0
  44. package/src/node.d.ts +16 -3
  45. package/src/node.js +19 -21
  46. package/src/nodes/animation.js +644 -0
  47. package/src/nodes/box.js +21 -0
  48. package/src/nodes/boxpaint.js +473 -0
  49. package/src/nodes/canvas.js +269 -0
  50. package/src/nodes/cascade.js +600 -0
  51. package/src/nodes/damage.js +183 -0
  52. package/src/nodes/edithistory.js +124 -0
  53. package/src/nodes/editmenupopup.js +260 -0
  54. package/src/nodes/hittest.js +185 -0
  55. package/src/nodes/image.js +266 -0
  56. package/src/nodes/install.js +75 -0
  57. package/src/nodes/invalidate.js +465 -0
  58. package/src/nodes/kinds.js +31 -0
  59. package/src/nodes/layout.js +439 -0
  60. package/src/nodes/layouthost.js +949 -0
  61. package/src/nodes/node.js +868 -0
  62. package/src/nodes/paint.js +466 -0
  63. package/src/nodes/position.js +366 -0
  64. package/src/nodes/preedit.js +127 -0
  65. package/src/nodes/queries.js +330 -0
  66. package/src/nodes/rects.js +102 -0
  67. package/src/nodes/scrollable.js +891 -0
  68. package/src/nodes/scrollbars.js +138 -0
  69. package/src/nodes/scrollblit.js +1034 -0
  70. package/src/nodes/selectable.js +142 -0
  71. package/src/nodes/styling.js +225 -0
  72. package/src/nodes/text.js +649 -0
  73. package/src/nodes/textarea.js +391 -0
  74. package/src/nodes/textinput.js +1146 -0
  75. package/src/nodes/util.js +17 -0
  76. package/src/nodes/window/anchoring.js +161 -0
  77. package/src/nodes/window/capabilities.js +190 -0
  78. package/src/nodes/window/debugpaint.js +83 -0
  79. package/src/nodes/window/droptarget.js +145 -0
  80. package/src/nodes/window/floors.js +577 -0
  81. package/src/nodes/window/flush.js +369 -0
  82. package/src/nodes/window/hints.js +482 -0
  83. package/src/nodes/window/listeners.js +222 -0
  84. package/src/nodes/window/popup.js +71 -0
  85. package/src/nodes/window/size.js +591 -0
  86. package/src/nodes/window/window.js +954 -0
  87. package/src/palette.js +1 -1
  88. package/src/registry.js +7 -3
  89. package/src/styles.js +137 -15
  90. package/src/svgnodes.js +2 -1
  91. package/src/testing/harness.js +2 -2
  92. package/src/textselection.js +5 -3
  93. package/src/trace-registry.js +1 -1
  94. package/src/types/components.d.ts +38 -6
  95. package/src/types/elements.d.ts +26 -14
  96. package/src/types/nodes.d.ts +17 -2
  97. package/src/types/style.d.ts +94 -3
  98. package/src/windowstate.js +1 -1
  99. package/src/yoga.js +1 -1
  100. package/src/nodes.js +0 -13120
@@ -26,12 +26,9 @@
26
26
  // knowing.
27
27
  import { cssColorStraight } from 'ntk';
28
28
 
29
- import {
30
- Node,
31
- addDamageRect,
32
- damageToPaint,
33
- intersectRects,
34
- } from '../nodes.js';
29
+ import { Node } from '../nodes/node.js';
30
+ import { addDamageRect, damageToPaint } from '../nodes/damage.js';
31
+ import { intersectRects } from '../nodes/rects.js';
35
32
  import { EASING_CONTROL_POINTS, TRANSITION_CONTROL_POINTS } from '../styles.js';
36
33
  import { CocoaContext2D } from './context2d.js';
37
34
 
@@ -489,7 +486,7 @@ export function propBoxProps(node, app, scale, parentOrigin, order) {
489
486
  // --- animations the render server runs ---------------------------------------
490
487
  //
491
488
  // The node model keeps deciding what is animating and when it ends
492
- // (nodes.js `_retarget` / `_updateLoops`); what moves here is who
489
+ // (nodes/animation.js `_retarget` / `_updateLoops`); what moves here is who
493
490
  // interpolates. Taken means the node's style goes to its target — the
494
491
  // layer's model value, sent by the next frame's property diff — and that
495
492
  // frame attaches an explicit animation carrying the pixels there; no frame
@@ -594,7 +591,12 @@ export class LayerAnimations {
594
591
  // transition reverses from where it got to" means here. A colour
595
592
  // cannot be additive, so the one before it is replaced.
596
593
  this._removeLive(node, prop);
597
- const shown = this.native.presentationValue?.(layer, map.keyPath);
594
+ // On a worker the render server's value is an answer that comes
595
+ // later (windowkit/appkit#52), and the animation is added now: it
596
+ // runs from the declared value instead.
597
+ const shown = this.app._threaded
598
+ ? null
599
+ : this.native.presentationValue?.(layer, map.keyPath);
598
600
  opts.from = Array.isArray(shown) ? shown : this._value(map, entry.from);
599
601
  opts.to = this._value(map, entry.to);
600
602
  opts.timing = TRANSITION_CONTROL_POINTS;
@@ -806,7 +808,9 @@ export class CocoaLayerPresenter {
806
808
  }
807
809
  presented = true;
808
810
  } finally {
809
- native.txCommit();
811
+ // the size this frame was laid out at, which a worker's live resize
812
+ // waits for (CocoaWindow._flip); the main thread's commit ignores it
813
+ native.txCommit(this.window._frameSize?.());
810
814
  this._claims = null;
811
815
  // a frame that threw half-way presents what it got to; the claims it
812
816
  // was answering are still owed, so the next frame answers them again
@@ -32,12 +32,9 @@
32
32
  // clock. Overlays, toasts, drag ghosts, spinners and floating cards pass by
33
33
  // construction; a hover fade on a row in the middle of a list does not,
34
34
  // and stays on the clock. docs/macos.md §"Layer promotion" is the account.
35
- import {
36
- BoxNode,
37
- addDamageRect,
38
- damageToPaint,
39
- intersectRects,
40
- } from '../nodes.js';
35
+ import { BoxNode } from '../nodes/box.js';
36
+ import { addDamageRect, damageToPaint } from '../nodes/damage.js';
37
+ import { intersectRects } from '../nodes/rects.js';
41
38
  import { resolveBorderWidths } from '../styles.js';
42
39
  import {
43
40
  LayerAnimations,
@@ -145,6 +142,18 @@ function paintsSomething(node) {
145
142
  return Boolean(node.isScroller?.());
146
143
  }
147
144
 
145
+ /**
146
+ * Inside a `<glarea>`: drawn on a pane above the surface (src/gloverlay.js).
147
+ * A layer of its own would sit on the root layer *under* the GL layer, so a
148
+ * promoted node there would vanish behind the surface it is drawn over.
149
+ */
150
+ function insideGlArea(node) {
151
+ for (let n = node.parent; n && !n.isWindow; n = n.parent) {
152
+ if (n.isGlArea) return true;
153
+ }
154
+ return false;
155
+ }
156
+
148
157
  /**
149
158
  * Can this node be a property box on a layer at all — the static half of
150
159
  * the answer, the same whatever the scene around it does: a plain box by
@@ -153,6 +162,7 @@ function paintsSomething(node) {
153
162
  */
154
163
  function promotableNode(node) {
155
164
  if (node.destroyed || !plainBox(node)) return false;
165
+ if (insideGlArea(node)) return false;
156
166
  if (!stylePaintsPlain(node, node._targetStyle ?? node.style)) return false;
157
167
  if (node.isScroller?.()) return false;
158
168
  return !paintsOutline(node);
@@ -181,7 +191,7 @@ function scrollbarStrip(bar, scale) {
181
191
  * The surface window's promoted nodes: which ones have a layer, what each
182
192
  * layer shows, and the animations the render server runs on them.
183
193
  * `frame()` is the whole of the per-frame work, called by the window from
184
- * nodes.js's `prepareFrame` seam — after layout, before the damage is taken.
194
+ * the `prepareFrame` seam in nodes/window/flush.js — after layout, before the damage is taken.
185
195
  */
186
196
  export class CocoaPromotion {
187
197
  constructor(window) {
@@ -0,0 +1,207 @@
1
+ // Threaded mode by default (docs/macos.md §"JS on a worker"). On macOS the
2
+ // process main thread is AppKit's — the only thread it lets own a window —
3
+ // and while the app's JS runs there, every menu, drag, live resize and
4
+ // modal panel stops it. So the first import of react-x11 on the main thread
5
+ // moves the app onto a `worker_threads` Worker before the app's own code has
6
+ // run, and parks the main thread for AppKit.
7
+ //
8
+ // It can be that early because of how modules evaluate: an entry's imports
9
+ // finish before its own body starts, so when react-x11 is being evaluated
10
+ // the entry has not run a line. The worker is started on that entry, and
11
+ // the main thread never returns to it — `process.exit` is the only way out.
12
+ // Nothing here is a top-level await, which would cost the CommonJS builds
13
+ // (docs/packaging.md, tier 3) and `require()` of the package.
14
+ //
15
+ // The main thread waits without AppKit, on shared memory, until the worker
16
+ // says what it needs: the first cocoa `createRoot` asks for it
17
+ // (`requestAppKit`, src/cocoa/threaded.js), and an app that never makes one
18
+ // — an X11 app on XQuartz, a script — never launches AppKit and never gets
19
+ // a Dock tile.
20
+ //
21
+ // `react-x11/cocoa-main` (src/cocoa/main.js) is the same move, asked for by
22
+ // name: it runs before the entry is even loaded, and skips the checks.
23
+ import fs from 'node:fs';
24
+ import { createRequire } from 'node:module';
25
+ import { isMainThread, SHARE_ENV, Worker } from 'node:worker_threads';
26
+
27
+ import { loadNative } from './native.js';
28
+ import {
29
+ bindRelaunchState,
30
+ forwardSignals,
31
+ installStdio,
32
+ onWorkerEnd,
33
+ openThreadedChannel,
34
+ printUncaught,
35
+ RELAUNCH,
36
+ routeExit,
37
+ signalEnded,
38
+ } from './threaded.js';
39
+
40
+ // Set once the worker is set up, process-wide: the explicit launcher and
41
+ // the package's own import can both reach `bootstrapWorker`, and from two
42
+ // copies of the package the bridge would refuse the second `connect`.
43
+ const BOOTED = Symbol.for('react-x11.cocoa.workerBooted');
44
+
45
+ // How long the main thread waits, once AppKit's run is over, for the
46
+ // worker's own exit handlers to finish — they run to the end in a process
47
+ // of their own, and should here; a handler that hangs must not hang the
48
+ // process for ever.
49
+ const WORKER_END_MS = 5000;
50
+
51
+ /**
52
+ * Why this import should not move the app onto a worker, or null when it
53
+ * should. A pure answer over what the process says about itself, so that
54
+ * each rule is testable on any OS.
55
+ */
56
+ export function relaunchVeto({
57
+ isMainThread: main,
58
+ platform,
59
+ env,
60
+ entry,
61
+ compiled,
62
+ late,
63
+ }) {
64
+ if (!main) return 'not the main thread';
65
+ if (platform !== 'darwin') return 'not macOS';
66
+ if (env.REACT_X11_THREADED === '0') return 'REACT_X11_THREADED=0';
67
+ if (env.REACT_X11_BACKEND && env.REACT_X11_BACKEND !== 'cocoa') {
68
+ return `REACT_X11_BACKEND=${env.REACT_X11_BACKEND}`;
69
+ }
70
+ // a REPL, `node -e`, or an entry that is not a file a worker can load
71
+ if (!entry) return 'no entry script';
72
+ // a single executable carries its entry inside the binary, where a worker
73
+ // cannot load it from (docs/packaging.md)
74
+ if (compiled) return 'a single executable';
75
+ // a test runner's file is a test, not an app: node --test, bun test,
76
+ // vitest, jest
77
+ if (
78
+ env.NODE_TEST_CONTEXT ||
79
+ env.VITEST ||
80
+ env.JEST_WORKER_ID ||
81
+ env.NODE_ENV === 'test'
82
+ ) {
83
+ return 'a test runner';
84
+ }
85
+ // Imported after the app started running — a dynamic import from a
86
+ // timer, say. Relaunching would run again, on the worker, everything the
87
+ // entry has already done on this thread.
88
+ if (late) return 'imported after the app started running';
89
+ return null;
90
+ }
91
+
92
+ /** What `relaunchVeto` reads, from this process. */
93
+ export function describeProcess(moduleUrl) {
94
+ const bun = globalThis.Bun;
95
+ const entry = bun ? bun.main : process.argv[1];
96
+ return {
97
+ isMainThread,
98
+ platform: process.platform,
99
+ env: process.env,
100
+ entry: typeof entry === 'string' && fs.existsSync(entry) ? entry : null,
101
+ compiled: compiledExecutable(bun),
102
+ late: startedRunning(bun, moduleUrl, entry),
103
+ };
104
+ }
105
+
106
+ function compiledExecutable(bun) {
107
+ // `bun build --compile` serves its modules from a virtual filesystem
108
+ if (bun) return /^\/\$bunfs\/|~BUN/.test(String(bun.main));
109
+ try {
110
+ return createRequire(process.execPath)('node:sea').isSea();
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Whether the entry's own code has started. Node's event loop has not
118
+ * turned while the entry's static imports evaluate —
119
+ * `performance.nodeTiming.loopStart` is -1 until it does. Bun reports 1
120
+ * there from the first line; what tells in Bun is the entry's record in the
121
+ * module cache, which is there once the entry has been evaluated and
122
+ * absent while its imports are (both measured, Node 26 and Bun 1.4).
123
+ */
124
+ function startedRunning(bun, moduleUrl, entry) {
125
+ if (!bun) return performance.nodeTiming?.loopStart > 0;
126
+ try {
127
+ return Boolean(createRequire(moduleUrl).cache?.[entry]?.loaded);
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * The import-time move (src/bootstrap.js): relaunch when nothing vetoes it
135
+ * and a bridge that can run AppKit's loop is installed. Returns why it did
136
+ * not, for anyone asking; when it does, it does not return.
137
+ */
138
+ export function relaunchOnImport(moduleUrl) {
139
+ const veto = relaunchVeto(describeProcess(moduleUrl));
140
+ if (veto) return veto;
141
+ let native;
142
+ try {
143
+ native = loadNative();
144
+ } catch {
145
+ // no bridge: `createRoot` falls back to X11 the way it always has
146
+ return 'no cocoa bridge';
147
+ }
148
+ if (typeof native.runMain !== 'function') return 'the bridge has no runMain';
149
+ return relaunch(describeProcess(moduleUrl).entry, native);
150
+ }
151
+
152
+ /**
153
+ * The worker's side, set up the moment it imports react-x11: after it the
154
+ * app cannot tell it is on a worker, short of asking — its logs reach the
155
+ * terminal, `process.exit` ends the process, a crash is printed, and a
156
+ * Ctrl-C reaches its `process.on('SIGINT')` (src/cocoa/threaded.js). What
157
+ * the app imports before react-x11 runs before this, so a module that logs
158
+ * as it loads should come after it.
159
+ */
160
+ export function bootstrapWorker(state) {
161
+ if (globalThis[BOOTED]) return;
162
+ globalThis[BOOTED] = true;
163
+ const native = loadNative();
164
+ installStdio();
165
+ bindRelaunchState(state);
166
+ printUncaught();
167
+ routeExit(native);
168
+ onWorkerEnd(signalEnded);
169
+ openThreadedChannel(native).subscribe(forwardSignals());
170
+ }
171
+
172
+ /**
173
+ * Start the app's entry on a worker and park this thread — waiting, then
174
+ * in AppKit's run once the worker asks for it — until the app is done.
175
+ * Never returns.
176
+ */
177
+ export function relaunch(entry, native) {
178
+ const state = new Int32Array(new SharedArrayBuffer(8));
179
+ // The worker's entry is the app's own, so nothing has to ship beside it —
180
+ // a bundle (docs/packaging.md, tier 2) is one file, and a worker entry of
181
+ // the package's own would be a second one the bundler never emits. The
182
+ // worker sets itself up when it imports react-x11 (`bootstrapWorker`,
183
+ // src/bootstrap.js), recognising the shared state in its workerData.
184
+ // execArgv carries a loader like `--import tsx`, which is what lets the
185
+ // worker load a .jsx entry at all.
186
+ new Worker(entry, {
187
+ argv: process.argv.slice(2),
188
+ execArgv: process.execArgv,
189
+ env: SHARE_ENV,
190
+ workerData: { reactX11State: state.buffer },
191
+ });
192
+ for (;;) {
193
+ Atomics.wait(state, 0, RELAUNCH.WAITING);
194
+ const now = Atomics.load(state, 0);
195
+ if (now === RELAUNCH.APPKIT) break;
196
+ if (now === RELAUNCH.ENDED) process.exit(Atomics.load(state, 1));
197
+ }
198
+ native.initApp();
199
+ const code = native.runMain();
200
+ // Back from AppKit: the app asked to exit — its `process.exit`, a crash,
201
+ // a signal nobody listened for — and `code` is what it asked for, or the
202
+ // worker ended without asking. Its exit handlers may still be running;
203
+ // the worker says when they are done.
204
+ Atomics.wait(state, 0, RELAUNCH.APPKIT, WORKER_END_MS);
205
+ const ended = Atomics.load(state, 0) === RELAUNCH.ENDED;
206
+ process.exit(code ?? (ended ? Atomics.load(state, 1) : 0));
207
+ }
@@ -0,0 +1,246 @@
1
+ // Threaded mode's worker half (docs/macos.md §"JS on a worker: a UI thread
2
+ // of the bridge's own"): with the launcher (src/cocoa/main.js) holding the
3
+ // process main thread in the bridge's `runMain()`, the app's JS runs on a
4
+ // `worker_threads` Worker, and this module is what that worker needs to
5
+ // talk to the parked thread — the one event channel the bridge allows, and
6
+ // the three things a Worker does differently when its main thread never
7
+ // returns to its event loop.
8
+ //
9
+ // The channel's existence is the switch: `createCocoaApp` asks
10
+ // `threadedChannel()`, and null is pump mode.
11
+ import { Console } from 'node:console';
12
+ import fs from 'node:fs';
13
+ import { constants } from 'node:os';
14
+ import { Writable } from 'node:stream';
15
+ import tty from 'node:tty';
16
+ import { inspect } from 'node:util';
17
+
18
+ let channel = null;
19
+
20
+ // The relaunch's shared state (src/cocoa/relaunch.js), [state, exit code]:
21
+ // the main thread waits on it, without AppKit, until the worker says what
22
+ // it needs — AppKit, or nothing more because it is done.
23
+ export const RELAUNCH = Object.freeze({ WAITING: 0, APPKIT: 1, ENDED: 2 });
24
+ let relaunchState = null;
25
+
26
+ /** The worker's end of the relaunch's shared state. */
27
+ export function bindRelaunchState(buffer) {
28
+ relaunchState = buffer ? new Int32Array(buffer) : null;
29
+ }
30
+
31
+ /**
32
+ * Ask the main thread for AppKit, and wait until its run has started. The
33
+ * main thread waits without it, so an app that never makes a cocoa root
34
+ * never launches it; the first cocoa `createRoot` does. Until `runMain`
35
+ * runs the bridge has published nothing — an app that asked `listScreens()`
36
+ * then got `[]` and took scale 1 on a 2x panel (measured) — so the wait is
37
+ * not optional.
38
+ */
39
+ export async function requestAppKit(native, state = relaunchState) {
40
+ if (state && Atomics.load(state, 0) === RELAUNCH.WAITING) {
41
+ Atomics.store(state, 0, RELAUNCH.APPKIT);
42
+ Atomics.notify(state, 0);
43
+ }
44
+ while (!native.threaded()) await new Promise((r) => setTimeout(r, 1));
45
+ }
46
+
47
+ /** The worker is done, with `code`: the main thread ends the process. */
48
+ export function signalEnded(code, state = relaunchState) {
49
+ if (!state) return;
50
+ Atomics.store(state, 1, Number.parseInt(code, 10) || 0);
51
+ Atomics.store(state, 0, RELAUNCH.ENDED);
52
+ Atomics.notify(state, 0);
53
+ }
54
+
55
+ /**
56
+ * Call `fn(code)` when the worker exits, after every exit listener the app
57
+ * has — its last word. The main thread ends the process on it, and an app's
58
+ * `process.on('exit')` handler must have finished by then, as it has in a
59
+ * process of its own. Kept last by re-adding it whenever the app adds one:
60
+ * Node runs a worker's exit listeners through `process.emit` and Bun calls
61
+ * them directly (measured), so wrapping the emit would cover only Node.
62
+ */
63
+ export function onWorkerEnd(fn, proc = process) {
64
+ const add = proc.on;
65
+ const last = (code) => fn(code ?? proc.exitCode ?? 0);
66
+ add.call(proc, 'exit', last);
67
+ for (const name of [
68
+ 'on',
69
+ 'addListener',
70
+ 'once',
71
+ 'prependListener',
72
+ 'prependOnceListener',
73
+ ]) {
74
+ const method = proc[name];
75
+ proc[name] = function (event, listener) {
76
+ const out = method.call(this, event, listener);
77
+ if (event === 'exit' && listener !== last) {
78
+ proc.removeListener('exit', last);
79
+ add.call(proc, 'exit', last);
80
+ }
81
+ return out;
82
+ };
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Print an uncaught error the way Node prints one, from the worker itself.
88
+ * Node hands a worker's uncaught error to its parent as an event, and the
89
+ * parent here is parked for good — it ends the process on the worker's
90
+ * word and never runs its event loop again to hear it. Only when nothing
91
+ * handles the error: an app's own `uncaughtException` handler means it is
92
+ * not uncaught.
93
+ */
94
+ export function printUncaught(
95
+ proc = process,
96
+ write = (s) => proc.stderr.write(s),
97
+ ) {
98
+ proc.on('uncaughtExceptionMonitor', (err) => {
99
+ if (proc.listenerCount('uncaughtException') > 0) return;
100
+ write(`${inspect(err)}\n`);
101
+ });
102
+ }
103
+
104
+ /** The open channel, or null outside threaded mode. */
105
+ export function threadedChannel() {
106
+ return channel;
107
+ }
108
+
109
+ /**
110
+ * `connect` once, and fan each batch out to whoever subscribed: the
111
+ * bootstrap for signals, `CocoaApp` for everything else. The bridge takes
112
+ * one connection per environment, and the bootstrap needs it before the
113
+ * app's entry has run — a Ctrl-C during startup is already an event.
114
+ *
115
+ * What a subscriber throws leaves the delivery once every subscriber has
116
+ * had the batch, and the bridge makes it the worker's uncaught exception
117
+ * (windowkit/appkit#65): the launcher prints it and the process exits 1, as
118
+ * a throw from a click handler ends an X11 app.
119
+ */
120
+ export function openThreadedChannel(native) {
121
+ if (channel) return channel;
122
+ const listeners = new Set();
123
+ native.connect((batch) => {
124
+ let failed = false;
125
+ let failure;
126
+ for (const fn of [...listeners]) {
127
+ try {
128
+ fn(batch);
129
+ } catch (err) {
130
+ if (!failed) failure = err;
131
+ failed = true;
132
+ }
133
+ }
134
+ if (failed) throw failure;
135
+ });
136
+ channel = {
137
+ native,
138
+ subscribe(fn) {
139
+ listeners.add(fn);
140
+ return () => listeners.delete(fn);
141
+ },
142
+ };
143
+ return channel;
144
+ }
145
+
146
+ /** Forget the channel — the tests' way to run pump and threaded apps in one
147
+ * process. */
148
+ export function resetThreadedChannelForTests() {
149
+ channel = null;
150
+ }
151
+
152
+ /**
153
+ * A stream that writes straight to `fd`, synchronously. A Worker's own
154
+ * `process.stdout` forwards every write through its parent's event loop,
155
+ * and the parent here is parked in `[NSApp run]`: without this, everything
156
+ * the app logs is lost (measured). Synchronous, so a line written just
157
+ * before an exit is not lost either; a pipe the main thread opened
158
+ * non-blocking answers EAGAIN when full, and the write waits it out.
159
+ */
160
+ export function fdStream(fd, write = fs.writeSync) {
161
+ const stream = new Writable({
162
+ write(chunk, encoding, done) {
163
+ try {
164
+ for (let off = 0; off < chunk.length;) {
165
+ try {
166
+ off += write(fd, chunk, off);
167
+ } catch (err) {
168
+ if (err.code !== 'EAGAIN') throw err;
169
+ }
170
+ }
171
+ } catch (err) {
172
+ done(err);
173
+ return;
174
+ }
175
+ done();
176
+ },
177
+ });
178
+ stream.fd = fd;
179
+ stream.isTTY = tty.isatty(fd);
180
+ // what a colouring library asks a TTY stream, answered as a TTY's would be
181
+ if (stream.isTTY) {
182
+ stream.getColorDepth = tty.WriteStream.prototype.getColorDepth;
183
+ stream.hasColors = tty.WriteStream.prototype.hasColors;
184
+ }
185
+ return stream;
186
+ }
187
+
188
+ /**
189
+ * Stdout and stderr on fds 1 and 2, and a console over them — the console
190
+ * too, because Bun's writes past `process.stdout` altogether.
191
+ */
192
+ export function installStdio(proc = process, global = globalThis) {
193
+ const stdout = fdStream(1);
194
+ const stderr = fdStream(2);
195
+ Object.defineProperty(proc, 'stdout', {
196
+ configurable: true,
197
+ get: () => stdout,
198
+ });
199
+ Object.defineProperty(proc, 'stderr', {
200
+ configurable: true,
201
+ get: () => stderr,
202
+ });
203
+ Object.defineProperty(global, 'console', {
204
+ configurable: true,
205
+ writable: true,
206
+ value: new Console({ stdout, stderr }),
207
+ });
208
+ }
209
+
210
+ /**
211
+ * `process.exit` inside a Worker ends the worker and nothing else: the main
212
+ * thread stays in `[NSApp run]` with nothing left to end it, a Dock tile
213
+ * for an app that is gone (measured). So an exit asks the main thread
214
+ * first — `requestExit` makes `runMain` return the code, and the launcher
215
+ * ends the process with it — and then ends the worker as before, so the
216
+ * code after an exit still never runs. Node's own handling of an uncaught
217
+ * error calls `process.exit()` too, which is how a crash reaches the
218
+ * launcher with its code.
219
+ */
220
+ export function routeExit(native, proc = process) {
221
+ const exitWorker = proc.exit.bind(proc);
222
+ proc.exit = (code) => {
223
+ const n = Number.parseInt(code ?? proc.exitCode ?? 0, 10);
224
+ native.requestExit(Number.isFinite(n) ? n : 0);
225
+ return exitWorker(code);
226
+ };
227
+ }
228
+
229
+ /**
230
+ * A handler for the channel that gives the worker its signals back. Node
231
+ * delivers SIGINT, SIGTERM and SIGHUP to the main thread only, which is
232
+ * parked, so the bridge reads them there and sends each as an event; this
233
+ * re-emits it on the worker's `process`, where the app's
234
+ * `process.on('SIGINT')` is. A signal nobody listens for ends the process
235
+ * the way the signal would have: 128 + its number.
236
+ */
237
+ export function forwardSignals(proc = process) {
238
+ return (batch) => {
239
+ for (const ev of batch) {
240
+ if (ev.type !== 'signal') continue;
241
+ const name = ev.signal;
242
+ if (proc.listenerCount(name) > 0) proc.emit(name, name);
243
+ else proc.exit(128 + (constants.signals[name] ?? 0));
244
+ }
245
+ };
246
+ }