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.
- package/README.md +278 -129
- package/package.json +12 -4
- package/src/Reconciler.js +19 -31
- package/src/a11y.js +2 -2
- package/src/anchor.js +7 -5
- package/src/appcontext.js +59 -30
- package/src/bootstrap.js +14 -0
- package/src/clientmessage.js +1 -1
- package/src/cocoa/app.js +303 -49
- package/src/cocoa/bezels.js +175 -30
- package/src/cocoa/dnd.js +27 -13
- package/src/cocoa/fonts.js +3 -3
- package/src/cocoa/glarea.js +24 -5
- package/src/cocoa/main.d.ts +8 -0
- package/src/cocoa/main.js +43 -0
- package/src/cocoa/overlay.js +159 -0
- package/src/cocoa/panehost.js +15 -5
- package/src/cocoa/presenter.js +13 -9
- package/src/cocoa/promotion.js +17 -7
- package/src/cocoa/relaunch.js +207 -0
- package/src/cocoa/threaded.js +246 -0
- package/src/cocoa/window.js +256 -42
- package/src/components/Select.js +2 -2
- package/src/components/anchor.js +3 -3
- package/src/components/native.js +12 -7
- package/src/components/theme.js +2 -2
- package/src/debug.js +1 -1
- package/src/decorations.js +1 -1
- package/src/editmenu.js +2 -2
- package/src/embedding.js +31 -0
- package/src/errors.js +46 -0
- package/src/events.js +78 -18
- package/src/foreignnodes.js +59 -5
- package/src/frames.js +2 -2
- package/src/glnodes.js +172 -41
- package/src/gloverlay.js +383 -0
- package/src/grid.js +1653 -0
- package/src/host.d.ts +230 -1
- package/src/host.js +11 -3
- package/src/imagesource.js +1 -1
- package/src/index.d.ts +34 -4
- package/src/index.js +9 -1
- package/src/layouts.js +721 -0
- package/src/node.d.ts +16 -3
- package/src/node.js +19 -21
- package/src/nodes/animation.js +644 -0
- package/src/nodes/box.js +21 -0
- package/src/nodes/boxpaint.js +473 -0
- package/src/nodes/canvas.js +269 -0
- package/src/nodes/cascade.js +600 -0
- package/src/nodes/damage.js +183 -0
- package/src/nodes/edithistory.js +124 -0
- package/src/nodes/editmenupopup.js +260 -0
- package/src/nodes/hittest.js +185 -0
- package/src/nodes/image.js +266 -0
- package/src/nodes/install.js +75 -0
- package/src/nodes/invalidate.js +465 -0
- package/src/nodes/kinds.js +31 -0
- package/src/nodes/layout.js +439 -0
- package/src/nodes/layouthost.js +949 -0
- package/src/nodes/node.js +868 -0
- package/src/nodes/paint.js +466 -0
- package/src/nodes/position.js +366 -0
- package/src/nodes/preedit.js +127 -0
- package/src/nodes/queries.js +330 -0
- package/src/nodes/rects.js +102 -0
- package/src/nodes/scrollable.js +891 -0
- package/src/nodes/scrollbars.js +138 -0
- package/src/nodes/scrollblit.js +1034 -0
- package/src/nodes/selectable.js +142 -0
- package/src/nodes/styling.js +225 -0
- package/src/nodes/text.js +649 -0
- package/src/nodes/textarea.js +391 -0
- package/src/nodes/textinput.js +1146 -0
- package/src/nodes/util.js +17 -0
- package/src/nodes/window/anchoring.js +161 -0
- package/src/nodes/window/capabilities.js +190 -0
- package/src/nodes/window/debugpaint.js +83 -0
- package/src/nodes/window/droptarget.js +145 -0
- package/src/nodes/window/floors.js +577 -0
- package/src/nodes/window/flush.js +369 -0
- package/src/nodes/window/hints.js +482 -0
- package/src/nodes/window/listeners.js +222 -0
- package/src/nodes/window/popup.js +71 -0
- package/src/nodes/window/size.js +591 -0
- package/src/nodes/window/window.js +954 -0
- package/src/palette.js +1 -1
- package/src/registry.js +7 -3
- package/src/styles.js +137 -15
- package/src/svgnodes.js +2 -1
- package/src/testing/harness.js +2 -2
- package/src/textselection.js +5 -3
- package/src/trace-registry.js +1 -1
- package/src/types/components.d.ts +38 -6
- package/src/types/elements.d.ts +26 -14
- package/src/types/nodes.d.ts +17 -2
- package/src/types/style.d.ts +94 -3
- package/src/windowstate.js +1 -1
- package/src/yoga.js +1 -1
- package/src/nodes.js +0 -13120
package/src/cocoa/presenter.js
CHANGED
|
@@ -26,12 +26,9 @@
|
|
|
26
26
|
// knowing.
|
|
27
27
|
import { cssColorStraight } from 'ntk';
|
|
28
28
|
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/cocoa/promotion.js
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
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
|
-
*
|
|
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
|
+
}
|