react-x11 1.2.0 → 2.0.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 +330 -81
- package/package.json +132 -14
- package/src/ClickToComponent.js +9 -2
- package/src/DevToolsIntegration.js +440 -32
- package/src/Reconciler.js +463 -165
- package/src/a11y.js +1299 -0
- package/src/acceleratorhooks.js +86 -0
- package/src/accelerators.js +128 -0
- package/src/activate.js +168 -0
- package/src/anchor.js +360 -0
- package/src/appcontext.js +161 -0
- package/src/appearance.js +748 -0
- package/src/appearancehooks.js +96 -0
- package/src/apphooks.js +73 -0
- package/src/application.js +748 -0
- package/src/atspi.js +1953 -0
- package/src/bus.js +545 -0
- package/src/bushooks.js +136 -0
- package/src/clientmessage.js +140 -0
- package/src/clipboard.js +121 -0
- package/src/components/Button.js +148 -33
- package/src/components/Checkbox.js +66 -32
- package/src/components/Dialog.js +96 -29
- package/src/components/FileDialog.js +491 -0
- package/src/components/Icon.js +329 -0
- package/src/components/Menu.js +797 -133
- package/src/components/PasswordInput.js +434 -0
- package/src/components/ProgressBar.js +91 -12
- package/src/components/Radio.js +78 -35
- package/src/components/Select.js +255 -65
- package/src/components/Slider.js +102 -41
- package/src/components/SplitPane.js +206 -0
- package/src/components/Switch.js +100 -22
- package/src/components/Table.js +529 -0
- package/src/components/Tabs.js +211 -0
- package/src/components/Tooltip.js +528 -54
- package/src/components/anchor.js +154 -107
- package/src/components/change.js +34 -0
- package/src/components/dnd.js +103 -0
- package/src/components/index.js +16 -3
- package/src/components/keys.js +17 -20
- package/src/components/scribble.js +247 -0
- package/src/components/theme.js +350 -42
- package/src/components/typeahead.js +12 -2
- package/src/compose.js +868 -0
- package/src/compositing.js +224 -0
- package/src/dbusmenu.js +384 -0
- package/src/debug.d.ts +63 -0
- package/src/debug.js +642 -0
- package/src/decorations.js +486 -0
- package/src/desktopsettings.js +209 -0
- package/src/desktopsettingshooks.js +62 -0
- package/src/dnd.js +1725 -0
- package/src/editmenu.js +272 -0
- package/src/errors.js +98 -0
- package/src/events.js +1406 -162
- package/src/extensions.js +45 -0
- package/src/filedialog.js +375 -0
- package/src/filedialoghooks.js +132 -0
- package/src/fonthooks.js +64 -0
- package/src/fonts.js +301 -0
- package/src/foreignnodes.js +519 -0
- package/src/frame/child.js +33 -0
- package/src/frame/childmain.js +274 -0
- package/src/frame/env.js +140 -0
- package/src/frame/index.js +444 -0
- package/src/frame/lifecycle.js +67 -0
- package/src/frame/protocol.js +179 -0
- package/src/frames.js +73 -0
- package/src/glbackend.js +114 -0
- package/src/glnodes.js +125 -67
- package/src/globalmenu.js +683 -0
- package/src/host.d.ts +88 -0
- package/src/host.js +42 -0
- package/src/idle.js +490 -0
- package/src/idlehooks.js +100 -0
- package/src/imagesource.js +349 -0
- package/src/index.d.ts +475 -0
- package/src/index.js +83 -9
- package/src/inputtime.js +206 -0
- package/src/jsx-dev-runtime.d.ts +21 -0
- package/src/jsx-dev-runtime.js +2 -0
- package/src/jsx-runtime.d.ts +39 -0
- package/src/jsx-runtime.js +6 -0
- package/src/keyboard.js +256 -0
- package/src/keyboardstate.js +278 -0
- package/src/keyboardstatehooks.js +58 -0
- package/src/keysyms.d.ts +149 -0
- package/src/keysyms.js +270 -0
- package/src/locale.js +170 -0
- package/src/localehooks.js +47 -0
- package/src/menuitem.js +223 -0
- package/src/node.d.ts +597 -0
- package/src/node.js +44 -0
- package/src/nodes.js +9546 -690
- package/src/ntk.d.ts +44 -0
- package/src/ntk.js +25 -0
- package/src/paintcache.js +366 -0
- package/src/palette.js +380 -0
- package/src/pastestate.js +66 -0
- package/src/portal.js +461 -0
- package/src/priority.js +26 -0
- package/src/refresh/index.d.ts +40 -0
- package/src/refresh/index.js +122 -0
- package/src/refresh/loader.d.ts +37 -0
- package/src/refresh/loader.js +401 -0
- package/src/refresh/register.d.ts +5 -0
- package/src/refresh/register.js +13 -0
- package/src/registry.js +232 -0
- package/src/scale.js +626 -0
- package/src/scalehooks.js +27 -0
- package/src/screencolor.js +640 -0
- package/src/screencolorhooks.js +101 -0
- package/src/screens.js +754 -0
- package/src/screenshooks.js +137 -0
- package/src/startup.js +302 -0
- package/src/style.d.ts +126 -0
- package/src/style.js +33 -0
- package/src/styles.js +1482 -6
- package/src/svgnodes.js +306 -0
- package/src/testing/a11y.js +484 -0
- package/src/testing/components.js +414 -0
- package/src/testing/events.js +407 -0
- package/src/testing/harness.js +455 -0
- package/src/testing/index.d.ts +558 -0
- package/src/testing/index.js +85 -0
- package/src/testing/mock-app.js +463 -0
- package/src/testing/pixels.js +152 -0
- package/src/testing/queries.js +224 -0
- package/src/textrange.js +83 -0
- package/src/textselection.js +439 -0
- package/src/trace-registry.js +63 -0
- package/src/transfer.js +93 -0
- package/src/types/appearance.d.ts +84 -0
- package/src/types/application.d.ts +173 -0
- package/src/types/components.d.ts +820 -0
- package/src/types/dbus.d.ts +177 -0
- package/src/types/elements.d.ts +897 -0
- package/src/types/events.d.ts +549 -0
- package/src/types/filedialog.d.ts +260 -0
- package/src/types/fonts.d.ts +124 -0
- package/src/types/frame.d.ts +146 -0
- package/src/types/globalmenu.d.ts +42 -0
- package/src/types/nodes.d.ts +199 -0
- package/src/types/screencolor.d.ts +84 -0
- package/src/types/style.d.ts +434 -0
- package/src/types/system.d.ts +287 -0
- package/src/windowid.js +151 -0
- package/src/windowstate.js +393 -0
- package/src/xsettings.js +336 -0
- package/src/yoga.d.ts +55 -0
- package/src/yoga.js +135 -0
- package/src/components/Canvas3D.js +0 -28
- package/src/geometry3d.js +0 -223
- package/src/pointer3d.js +0 -158
- package/src/raycast3d.js +0 -146
- package/src/richnodes.js +0 -436
- package/src/scene3d.js +0 -683
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// <Frame>: a pane of this application, running in its own process.
|
|
2
|
+
//
|
|
3
|
+
// The composition is deliberately thin, because both halves already exist:
|
|
4
|
+
// the window boundary is `<foreign>` (the pane's window, embedded and laid
|
|
5
|
+
// out like any child — docs/embedding.md), and the process boundary is a
|
|
6
|
+
// forked node with `src/frame/child.js` as its entry. What this file owns is
|
|
7
|
+
// the contract between them, and the contract is narrow on purpose:
|
|
8
|
+
//
|
|
9
|
+
// - **`props` is a bag of data**, snapshotted per parent commit and sent
|
|
10
|
+
// whole (structured clone; `serialization: 'advanced'`). Functions in it
|
|
11
|
+
// become RPC stubs — fire and forget, one direction (src/frame/protocol.js).
|
|
12
|
+
// - **Context crosses only through the bridge** (src/frame/env.js):
|
|
13
|
+
// `createFrameContext` values, and the theme by default, because a pane
|
|
14
|
+
// that silently loses the app's palette looks broken in a way that
|
|
15
|
+
// indicts the whole feature. `bridge={false}` (or an allowlist) is the
|
|
16
|
+
// off switch that default owes.
|
|
17
|
+
// - **The pane owns its data and its animation.** Props are for queries,
|
|
18
|
+
// ids, ranges — an update is an IPC hop plus a child commit, the right
|
|
19
|
+
// cost for "show this now", the wrong one for driving a spinner at 60fps.
|
|
20
|
+
//
|
|
21
|
+
// What a process boundary is here, and is not: an uncaught throw, a leak or
|
|
22
|
+
// a GC pause in the pane stays in the pane, and the shell's event loop never
|
|
23
|
+
// waits for it. It is **not a security boundary** — the pane holds a
|
|
24
|
+
// full-privilege connection to the same X server (docs/security.md), so
|
|
25
|
+
// `<Frame>` contains a pane's *failures*, not its intentions.
|
|
26
|
+
//
|
|
27
|
+
// Lifecycle, from both sides: the child reports `ready` (→ `onStarted`,
|
|
28
|
+
// and the embed), `fatal` (why the exit about to happen is one) and exit
|
|
29
|
+
// (→ `onExit`, and `fallback` when the host did not ask for it). The host
|
|
30
|
+
// closes a pane by message first — `useFrameClose` handlers run there —
|
|
31
|
+
// and only escalates to signals when asked nicely stops working.
|
|
32
|
+
|
|
33
|
+
import { fork } from 'node:child_process';
|
|
34
|
+
import { isAbsolute } from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
|
|
37
|
+
import React, {
|
|
38
|
+
useCallback,
|
|
39
|
+
useContext,
|
|
40
|
+
useEffect,
|
|
41
|
+
useImperativeHandle,
|
|
42
|
+
useRef,
|
|
43
|
+
useState,
|
|
44
|
+
} from 'react';
|
|
45
|
+
|
|
46
|
+
import { FrameEnv } from './env.js';
|
|
47
|
+
import { CallbackTable, PROTOCOL } from './protocol.js';
|
|
48
|
+
|
|
49
|
+
const h = React.createElement;
|
|
50
|
+
|
|
51
|
+
/** The forked entry's path — resolved at first spawn, not at import. The
|
|
52
|
+
* website playground bundles this module with throw-on-call stubs for the
|
|
53
|
+
* node builtins, and a top-level `fileURLToPath` would throw on *load*;
|
|
54
|
+
* lazily, `<Frame>` imports everywhere and says "needs a desktop" only
|
|
55
|
+
* when something actually spawns. */
|
|
56
|
+
let childEntry = null;
|
|
57
|
+
const childPath = () =>
|
|
58
|
+
(childEntry ??= fileURLToPath(new URL('./child.js', import.meta.url)));
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @typedef {object} FrameTransport The parent's end of the wire to a pane.
|
|
62
|
+
* @property {(msg: object) => void} send may throw when the channel is gone
|
|
63
|
+
* @property {(cb: (msg: object) => void) => () => void} onMessage
|
|
64
|
+
* @property {(cb: (info: { code: number|null, signal: string|null }) => void) => () => void} onExit
|
|
65
|
+
* @property {(signal: string) => void} [kill] escalation; absent on
|
|
66
|
+
* transports with nothing to kill
|
|
67
|
+
* @property {number} [pid]
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/** The default transport: fork this package's child entry. `execArgv` is
|
|
71
|
+
* inherited, which is what carries a dev loader (tsx, the refresh loader)
|
|
72
|
+
* into the pane; a bundled app forks plain JS and needs none. */
|
|
73
|
+
function forkTransport({ src, display }) {
|
|
74
|
+
const child = fork(childPath(), [], {
|
|
75
|
+
serialization: 'advanced',
|
|
76
|
+
// dev loaders (tsx, the refresh loader) follow into the pane; the test
|
|
77
|
+
// runner's own flags must not — `--test` in execArgv would run the pane
|
|
78
|
+
// entry as a test file
|
|
79
|
+
execArgv: process.execArgv.filter(
|
|
80
|
+
(a) => a !== '--test' && !a.startsWith('--test-'),
|
|
81
|
+
),
|
|
82
|
+
env: {
|
|
83
|
+
...process.env,
|
|
84
|
+
REACT_X11_FRAME: '1',
|
|
85
|
+
...(display ? { DISPLAY: display } : {}),
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
void src; // resolved by the child from the hello, not from argv
|
|
89
|
+
return {
|
|
90
|
+
send: (msg) => {
|
|
91
|
+
if (!child.connected) throw new Error('the pane process is gone');
|
|
92
|
+
child.send(msg);
|
|
93
|
+
},
|
|
94
|
+
onMessage: (cb) => {
|
|
95
|
+
child.on('message', cb);
|
|
96
|
+
return () => child.off('message', cb);
|
|
97
|
+
},
|
|
98
|
+
onExit: (cb) => {
|
|
99
|
+
const onExit = (code, signal) => cb({ code, signal });
|
|
100
|
+
const onError = (err) =>
|
|
101
|
+
cb({ code: null, signal: null, error: err ?? null });
|
|
102
|
+
child.on('exit', onExit);
|
|
103
|
+
child.on('error', onError);
|
|
104
|
+
return () => {
|
|
105
|
+
child.off('exit', onExit);
|
|
106
|
+
child.off('error', onError);
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
kill: (signal) => {
|
|
110
|
+
try {
|
|
111
|
+
child.kill(signal);
|
|
112
|
+
} catch {
|
|
113
|
+
// already gone, which is what the signal was for
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
pid: child.pid,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function srcString(src) {
|
|
121
|
+
if (src !== null && typeof src === 'object' && typeof src.href === 'string') {
|
|
122
|
+
return src.href;
|
|
123
|
+
}
|
|
124
|
+
if (
|
|
125
|
+
typeof src === 'string' &&
|
|
126
|
+
(/^[a-z][a-z0-9+.-]*:/i.test(src) || isAbsolute(src))
|
|
127
|
+
) {
|
|
128
|
+
return src;
|
|
129
|
+
}
|
|
130
|
+
throw new Error(
|
|
131
|
+
'react-x11: <Frame src> must be a URL or an absolute path — a relative ' +
|
|
132
|
+
"one would resolve against react-x11's own files, not yours. Pass " +
|
|
133
|
+
"src={new URL('./pane.js', import.meta.url)}.",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Bridged values are data; here is where that is enforced. A value that
|
|
138
|
+
* cannot cross is dropped with its key named — once — rather than taking
|
|
139
|
+
* the whole update down with a DataCloneError deep in a send. */
|
|
140
|
+
const cloneable = new WeakSet();
|
|
141
|
+
const warnedKeys = new Set();
|
|
142
|
+
function bridgedEnv(env, bridge) {
|
|
143
|
+
if (bridge === false) return new Map();
|
|
144
|
+
const allow = Array.isArray(bridge) ? new Set(bridge) : null;
|
|
145
|
+
const out = new Map();
|
|
146
|
+
for (const [key, value] of env) {
|
|
147
|
+
if (allow && !allow.has(key)) continue;
|
|
148
|
+
if (value !== null && typeof value === 'object' && !cloneable.has(value)) {
|
|
149
|
+
try {
|
|
150
|
+
structuredClone(value);
|
|
151
|
+
cloneable.add(value);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
if (!warnedKeys.has(key)) {
|
|
154
|
+
warnedKeys.add(key);
|
|
155
|
+
console.warn(
|
|
156
|
+
`react-x11: <Frame> cannot bridge context '${key}' ` +
|
|
157
|
+
`(${err.message}) — bridged values are data only; dispatchers ` +
|
|
158
|
+
'and other functions travel in props (docs/frame.md)',
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
} else if (typeof value === 'function') {
|
|
164
|
+
if (!warnedKeys.has(key)) {
|
|
165
|
+
warnedKeys.add(key);
|
|
166
|
+
console.warn(
|
|
167
|
+
`react-x11: <Frame> cannot bridge context '${key}': it is a ` +
|
|
168
|
+
'function — bridged values are data only (docs/frame.md)',
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
out.set(key, value);
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** One `Object.is` pass over the bag, so a parent that re-renders without
|
|
179
|
+
* touching the pane's inputs does not wake the pane. Inline handlers defeat
|
|
180
|
+
* it — a new closure is a new value — and that only costs the send. */
|
|
181
|
+
function shallowEqual(a, b) {
|
|
182
|
+
if (a === b) return true;
|
|
183
|
+
const ak = Object.keys(a);
|
|
184
|
+
const bk = Object.keys(b);
|
|
185
|
+
if (ak.length !== bk.length) return false;
|
|
186
|
+
for (const k of ak) if (!Object.is(a[k], b[k])) return false;
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const EMPTY_PROPS = {};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A module of this application, mounted in its own process, its window
|
|
194
|
+
* embedded here.
|
|
195
|
+
*
|
|
196
|
+
* ```jsx
|
|
197
|
+
* <Frame
|
|
198
|
+
* src={new URL('./charts.pane.js', import.meta.url)}
|
|
199
|
+
* props={{ rows, range, onPick }}
|
|
200
|
+
* style={{ flexGrow: 1, backgroundColor: '$surface' }}
|
|
201
|
+
* fallback={({ error, restart }) => <Crashed error={error} onRetry={restart} />}
|
|
202
|
+
* />
|
|
203
|
+
* ```
|
|
204
|
+
*
|
|
205
|
+
* The pane module's default export is the component; it receives `props`
|
|
206
|
+
* (functions arrive as fire-and-forget stubs), under the bridged context
|
|
207
|
+
* providers. `ref` exposes `{ restart(), pid }`.
|
|
208
|
+
*/
|
|
209
|
+
export function Frame({
|
|
210
|
+
src,
|
|
211
|
+
props = EMPTY_PROPS,
|
|
212
|
+
style,
|
|
213
|
+
display,
|
|
214
|
+
bridge = true,
|
|
215
|
+
fallback,
|
|
216
|
+
focusable,
|
|
217
|
+
onStarted,
|
|
218
|
+
onExit,
|
|
219
|
+
transport,
|
|
220
|
+
ref,
|
|
221
|
+
}) {
|
|
222
|
+
const env = useContext(FrameEnv);
|
|
223
|
+
const [state, setState] = useState({
|
|
224
|
+
phase: 'starting',
|
|
225
|
+
windowId: null,
|
|
226
|
+
error: null,
|
|
227
|
+
});
|
|
228
|
+
const [generation, setGeneration] = useState(0);
|
|
229
|
+
const session = useRef(null);
|
|
230
|
+
// the latest render's callbacks and bags, for the long-lived effect
|
|
231
|
+
const current = useRef(null);
|
|
232
|
+
current.current = { props, env, bridge, onStarted, onExit };
|
|
233
|
+
|
|
234
|
+
const restart = useCallback(() => {
|
|
235
|
+
setState({ phase: 'starting', windowId: null, error: null });
|
|
236
|
+
setGeneration((g) => g + 1);
|
|
237
|
+
}, []);
|
|
238
|
+
useImperativeHandle(ref, () => ({
|
|
239
|
+
restart,
|
|
240
|
+
get pid() {
|
|
241
|
+
return session.current?.transport?.pid ?? null;
|
|
242
|
+
},
|
|
243
|
+
}));
|
|
244
|
+
|
|
245
|
+
const source = srcString(src);
|
|
246
|
+
const makeTransport = transport ?? forkTransport;
|
|
247
|
+
const containerRef = useRef(null);
|
|
248
|
+
|
|
249
|
+
// The session: one child process per (src, display, transport, restart).
|
|
250
|
+
// Declared before the update effect so that on the mounting commit the
|
|
251
|
+
// hello goes out first and the update pass sees itself already sent.
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
let alive = true;
|
|
254
|
+
const fail = (error) => {
|
|
255
|
+
if (!alive) return;
|
|
256
|
+
setState({ phase: 'failed', windowId: null, error });
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
let t;
|
|
260
|
+
try {
|
|
261
|
+
t = makeTransport({ src: source, display });
|
|
262
|
+
} catch (err) {
|
|
263
|
+
fail(Object.assign(err, { phase: 'spawn' }));
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
const s = {
|
|
267
|
+
transport: t,
|
|
268
|
+
table: new CallbackTable(),
|
|
269
|
+
closing: false,
|
|
270
|
+
fatal: null,
|
|
271
|
+
sent: null,
|
|
272
|
+
shutdown: null,
|
|
273
|
+
};
|
|
274
|
+
session.current = s;
|
|
275
|
+
|
|
276
|
+
const trySend = (msg) => {
|
|
277
|
+
try {
|
|
278
|
+
t.send(msg);
|
|
279
|
+
return true;
|
|
280
|
+
} catch (err) {
|
|
281
|
+
// hello or an update that would not serialize (or a channel that
|
|
282
|
+
// closed mid-send): the pane cannot follow the app from here
|
|
283
|
+
if (!s.closing) fail(Object.assign(err, { phase: 'send' }));
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
s.trySend = trySend;
|
|
288
|
+
|
|
289
|
+
// Both listeners live until the *exit*, not until the effect cleanup: a
|
|
290
|
+
// `useFrameClose` handler flushing through a callback prop sends its
|
|
291
|
+
// `invoke` after the unmount message, and dropping it would make the
|
|
292
|
+
// close hook useless for the one thing it is for.
|
|
293
|
+
let offMessage = t.onMessage((msg) => {
|
|
294
|
+
if (msg?.type === 'ready') {
|
|
295
|
+
if (!alive || s.closing) return;
|
|
296
|
+
setState({ phase: 'running', windowId: msg.windowId, error: null });
|
|
297
|
+
current.current.onStarted?.({
|
|
298
|
+
pid: t.pid ?? null,
|
|
299
|
+
windowId: msg.windowId,
|
|
300
|
+
});
|
|
301
|
+
} else if (msg?.type === 'invoke') {
|
|
302
|
+
s.table.invoke(msg.id, msg.args);
|
|
303
|
+
} else if (msg?.type === 'fatal') {
|
|
304
|
+
s.fatal = msg;
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
let clearEscalation = null;
|
|
309
|
+
let offExit = null;
|
|
310
|
+
const detach = () => {
|
|
311
|
+
offMessage?.();
|
|
312
|
+
offMessage = null;
|
|
313
|
+
offExit?.();
|
|
314
|
+
offExit = null;
|
|
315
|
+
};
|
|
316
|
+
offExit = t.onExit(({ code = null, signal = null, error } = {}) => {
|
|
317
|
+
clearEscalation?.();
|
|
318
|
+
detach();
|
|
319
|
+
const expected = s.closing;
|
|
320
|
+
if (alive && !expected) {
|
|
321
|
+
const f = s.fatal;
|
|
322
|
+
const err = Object.assign(
|
|
323
|
+
new Error(
|
|
324
|
+
f?.message ??
|
|
325
|
+
error?.message ??
|
|
326
|
+
`the pane exited (${signal ?? `code ${code}`})`,
|
|
327
|
+
),
|
|
328
|
+
{ phase: f?.phase ?? 'exit', code, signal },
|
|
329
|
+
);
|
|
330
|
+
if (f?.stack) err.stack = f.stack;
|
|
331
|
+
fail(err);
|
|
332
|
+
}
|
|
333
|
+
// Deliberately not gated on `alive`: the exit a graceful close ends in
|
|
334
|
+
// arrives *after* the unmount that asked for it, and `expected: true`
|
|
335
|
+
// would otherwise be an event no one can ever receive. The exit
|
|
336
|
+
// belongs to the session, not to the mounted lifetime.
|
|
337
|
+
current.current.onExit?.({ code, signal, expected });
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Ask first, signal later. The message lets `useFrameClose` handlers
|
|
341
|
+
// run and the root unmount cleanly; the signals are for a pane that
|
|
342
|
+
// stopped listening — which a crashed or wedged one has.
|
|
343
|
+
s.shutdown = () => {
|
|
344
|
+
if (s.closing) return;
|
|
345
|
+
s.closing = true;
|
|
346
|
+
let sent = false;
|
|
347
|
+
try {
|
|
348
|
+
t.send({ type: 'unmount' });
|
|
349
|
+
sent = true;
|
|
350
|
+
} catch {
|
|
351
|
+
// never came up, or already gone — straight to the signal
|
|
352
|
+
}
|
|
353
|
+
const term = setTimeout(() => t.kill?.('SIGTERM'), sent ? 1500 : 0);
|
|
354
|
+
const kill = setTimeout(() => t.kill?.('SIGKILL'), 4000);
|
|
355
|
+
term.unref?.();
|
|
356
|
+
kill.unref?.();
|
|
357
|
+
clearEscalation = () => {
|
|
358
|
+
clearTimeout(term);
|
|
359
|
+
clearTimeout(kill);
|
|
360
|
+
};
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// The hello carries everything the first paint needs — above all the
|
|
364
|
+
// bridged theme, so the pane's first frame is in the app's palette
|
|
365
|
+
// rather than one frame of default before an update lands.
|
|
366
|
+
const { props: p, env: e, bridge: b } = current.current;
|
|
367
|
+
const node = containerRef.current;
|
|
368
|
+
const rect = {
|
|
369
|
+
width: Math.max(1, Math.round(node?.abs?.width || 0)) || 400,
|
|
370
|
+
height: Math.max(1, Math.round(node?.abs?.height || 0)) || 300,
|
|
371
|
+
};
|
|
372
|
+
s.sent = { props: p, env: e, bridge: b };
|
|
373
|
+
trySend({
|
|
374
|
+
type: 'hello',
|
|
375
|
+
protocol: PROTOCOL,
|
|
376
|
+
src: source,
|
|
377
|
+
display,
|
|
378
|
+
rect,
|
|
379
|
+
props: s.table.snapshot(p),
|
|
380
|
+
env: bridgedEnv(e, b),
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
return () => {
|
|
384
|
+
alive = false;
|
|
385
|
+
s.shutdown();
|
|
386
|
+
// the listeners and escalation timers outlive the effect on purpose:
|
|
387
|
+
// the listeners deliver what the close handlers still send and clear
|
|
388
|
+
// the escalation on exit, the timers bound a pane that ignores the
|
|
389
|
+
// ask, and everything is unref'd so nothing holds the host open
|
|
390
|
+
if (session.current === s) session.current = null;
|
|
391
|
+
};
|
|
392
|
+
}, [source, display, generation, makeTransport]);
|
|
393
|
+
|
|
394
|
+
// One update per commit that changed the pane's inputs, props and env in
|
|
395
|
+
// the same message — so a theme flip and the state change that caused it
|
|
396
|
+
// land in the child as one commit, not a torn pair.
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
const s = session.current;
|
|
399
|
+
if (!s || s.closing || !s.sent) return;
|
|
400
|
+
if (
|
|
401
|
+
shallowEqual(s.sent.props, props) &&
|
|
402
|
+
s.sent.env === env &&
|
|
403
|
+
s.sent.bridge === bridge
|
|
404
|
+
) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
s.sent = { props, env, bridge };
|
|
408
|
+
s.trySend({
|
|
409
|
+
type: 'update',
|
|
410
|
+
props: s.table.snapshot(props),
|
|
411
|
+
env: bridgedEnv(env, bridge),
|
|
412
|
+
});
|
|
413
|
+
}, [props, env, bridge, generation]);
|
|
414
|
+
|
|
415
|
+
if (state.phase === 'running') {
|
|
416
|
+
return h('foreign', {
|
|
417
|
+
ref: containerRef,
|
|
418
|
+
windowId: state.windowId,
|
|
419
|
+
style,
|
|
420
|
+
...(focusable === undefined ? {} : { focusable }),
|
|
421
|
+
onError: (err) => {
|
|
422
|
+
session.current?.shutdown?.();
|
|
423
|
+
setState({
|
|
424
|
+
phase: 'failed',
|
|
425
|
+
windowId: null,
|
|
426
|
+
error: Object.assign(err, { phase: 'embed' }),
|
|
427
|
+
});
|
|
428
|
+
},
|
|
429
|
+
// the exit path owns the state change; the window vanishing first is
|
|
430
|
+
// just the order X delivers the same death in
|
|
431
|
+
onClientGone: () => {},
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
const showFallback = state.phase === 'failed' && fallback !== undefined;
|
|
435
|
+
return h(
|
|
436
|
+
'box',
|
|
437
|
+
{ ref: containerRef, style },
|
|
438
|
+
showFallback
|
|
439
|
+
? typeof fallback === 'function'
|
|
440
|
+
? fallback({ error: state.error, restart })
|
|
441
|
+
: fallback
|
|
442
|
+
: null,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The pane's side of the frame lifecycle — the two things a module loaded
|
|
2
|
+
// into a `<Frame>` can ask about the world it landed in. Its own file so a
|
|
3
|
+
// pane (and the components it imports) can use these without pulling in the
|
|
4
|
+
// parent-side supervisor or the child bootstrap, neither of which belongs in
|
|
5
|
+
// a bundle of UI code.
|
|
6
|
+
|
|
7
|
+
import { useEffect } from 'react';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether this process is a `<Frame>` pane. An environment fact rather
|
|
11
|
+
* than a hook, so a module can branch at load time — the usual reason to
|
|
12
|
+
* ask is "am I my own application?", and a pane module's
|
|
13
|
+
* `import.meta.main`-style autorun guard is where that question is asked.
|
|
14
|
+
*
|
|
15
|
+
* The env var is what the fork sets and what load-time code reads; the
|
|
16
|
+
* flag beside it is `runFrameChild` saying so directly, which is what
|
|
17
|
+
* makes a pane run behind a custom transport — no fork, no env — answer
|
|
18
|
+
* the same.
|
|
19
|
+
*/
|
|
20
|
+
let framed = false;
|
|
21
|
+
export function isFramed() {
|
|
22
|
+
return framed || process.env.REACT_X11_FRAME === '1';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `runFrameChild`'s mark. Internal. */
|
|
26
|
+
export function markFramed() {
|
|
27
|
+
framed = true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What `runCloseHandlers` drains. Module state, like the compose table:
|
|
31
|
+
* one pane process has one close, however many components listen for it. */
|
|
32
|
+
const closeHandlers = new Set();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `handler` runs when the host is letting this pane go — the `<Frame>`
|
|
36
|
+
* unmounted, the host app is exiting — and the pane has a moment to flush
|
|
37
|
+
* what it would mind losing. It may return a promise; the host's patience
|
|
38
|
+
* is bounded (the parent escalates to SIGTERM), so "a moment" is meant
|
|
39
|
+
* literally: hundreds of milliseconds, not a sync.
|
|
40
|
+
*
|
|
41
|
+
* Not called when the pane crashes, and not called when the host is killed
|
|
42
|
+
* outright — a close handler is a courtesy, and anything that must survive
|
|
43
|
+
* a crash belongs on disk before the close.
|
|
44
|
+
*
|
|
45
|
+
* Outside a frame it registers nothing and the handler never runs.
|
|
46
|
+
*/
|
|
47
|
+
export function useFrameClose(handler) {
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!isFramed() || typeof handler !== 'function') return undefined;
|
|
50
|
+
closeHandlers.add(handler);
|
|
51
|
+
return () => closeHandlers.delete(handler);
|
|
52
|
+
}, [handler]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Run every close handler, bounded by `timeout`. The bootstrap calls this
|
|
57
|
+
* on the `unmount` message, before unmounting the root — so a handler still
|
|
58
|
+
* has its tree, its connection and its state.
|
|
59
|
+
*/
|
|
60
|
+
export async function runCloseHandlers(timeout = 800) {
|
|
61
|
+
if (closeHandlers.size === 0) return;
|
|
62
|
+
const pending = [...closeHandlers].map(async (handler) => handler());
|
|
63
|
+
await Promise.race([
|
|
64
|
+
Promise.allSettled(pending),
|
|
65
|
+
new Promise((resolve) => setTimeout(resolve, timeout).unref?.()),
|
|
66
|
+
]);
|
|
67
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// The wire between a `<Frame>` and its pane process. Pure data — no React,
|
|
2
|
+
// no X11, no `child_process` — so the same protocol runs over node's IPC
|
|
3
|
+
// channel (the fork transport), a loopback pair (the tests), or whatever a
|
|
4
|
+
// custom transport is built on.
|
|
5
|
+
//
|
|
6
|
+
// Six messages, all objects with a `type`:
|
|
7
|
+
//
|
|
8
|
+
// parent → child
|
|
9
|
+
// hello { protocol, src, display, rect, props, env } first, once
|
|
10
|
+
// update { props, env } full snapshots, one per parent commit
|
|
11
|
+
// unmount {} run close handlers, unmount, exit
|
|
12
|
+
//
|
|
13
|
+
// child → parent
|
|
14
|
+
// ready { windowId } the pane mounted; embed this
|
|
15
|
+
// invoke { id, args } a bridged callback fired
|
|
16
|
+
// fatal { phase, message, stack } why the exit about to happen is one
|
|
17
|
+
//
|
|
18
|
+
// Values cross by **structured clone** (fork uses `serialization:
|
|
19
|
+
// 'advanced'`), so Dates, Maps, TypedArrays and cycles survive — and
|
|
20
|
+
// functions do not, which is what the callback bridge is for: the parent
|
|
21
|
+
// swaps each function in `props` for a `{ [CALLBACK]: id }` marker and keeps
|
|
22
|
+
// the function; the child revives markers as stubs that send `invoke` back.
|
|
23
|
+
// Fire and forget — a stub returns undefined, because a return value would
|
|
24
|
+
// make every event handler an await and every await a frame of latency.
|
|
25
|
+
//
|
|
26
|
+
// Ids are **monotonic across updates**, never reused. The table keeps the
|
|
27
|
+
// current snapshot's entries and the previous one's: React recreates handler
|
|
28
|
+
// closures render to render, so a click that raced a props update arrives
|
|
29
|
+
// with the previous snapshot's id and must still land — one snapshot of
|
|
30
|
+
// grace covers the in-flight window, while an id from two updates ago is
|
|
31
|
+
// dropped (with a warning) rather than delivered to the wrong function.
|
|
32
|
+
// Functions the caller keeps stable (`useCallback`) keep their id, which is
|
|
33
|
+
// what makes the grace window about racing messages rather than about how
|
|
34
|
+
// the app was written.
|
|
35
|
+
|
|
36
|
+
export const PROTOCOL = 1;
|
|
37
|
+
|
|
38
|
+
/** The marker key a function in `props` becomes on the wire. */
|
|
39
|
+
export const CALLBACK = '$$reactX11FrameCallback';
|
|
40
|
+
|
|
41
|
+
const isPlainObject = (v) =>
|
|
42
|
+
v !== null &&
|
|
43
|
+
typeof v === 'object' &&
|
|
44
|
+
(Object.getPrototypeOf(v) === Object.prototype ||
|
|
45
|
+
Object.getPrototypeOf(v) === null);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The parent's side of the callback bridge: give out wire snapshots of a
|
|
49
|
+
* props bag, answer `invoke` messages with the function each id stood for.
|
|
50
|
+
*/
|
|
51
|
+
export class CallbackTable {
|
|
52
|
+
constructor({ warn = console.warn } = {}) {
|
|
53
|
+
this._ids = new WeakMap(); // fn → id, stable across snapshots
|
|
54
|
+
this._seq = 0;
|
|
55
|
+
this._live = new Map(); // id → fn, the latest snapshot
|
|
56
|
+
this._prev = new Map(); // …and the one before it: the grace window
|
|
57
|
+
this._warn = warn;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Deep-copy `props` with every function replaced by its marker. Walks
|
|
62
|
+
* plain objects and arrays — a function buried in a `Map` value is not
|
|
63
|
+
* found, and the send will refuse the clone, which is the honest failure.
|
|
64
|
+
* Cycle-safe: a value already walked keeps one wire identity.
|
|
65
|
+
*/
|
|
66
|
+
snapshot(props) {
|
|
67
|
+
const found = new Map();
|
|
68
|
+
const seen = new Map();
|
|
69
|
+
const walk = (value) => {
|
|
70
|
+
if (typeof value === 'function') {
|
|
71
|
+
let id = this._ids.get(value);
|
|
72
|
+
if (id === undefined) {
|
|
73
|
+
id = this._seq++;
|
|
74
|
+
this._ids.set(value, id);
|
|
75
|
+
}
|
|
76
|
+
found.set(id, value);
|
|
77
|
+
return { [CALLBACK]: id };
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(value)) {
|
|
80
|
+
if (seen.has(value)) return seen.get(value);
|
|
81
|
+
const out = [];
|
|
82
|
+
seen.set(value, out);
|
|
83
|
+
for (const item of value) out.push(walk(item));
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
if (isPlainObject(value)) {
|
|
87
|
+
if (seen.has(value)) return seen.get(value);
|
|
88
|
+
const out = {};
|
|
89
|
+
seen.set(value, out);
|
|
90
|
+
for (const key of Object.keys(value)) out[key] = walk(value[key]);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
};
|
|
95
|
+
const wire = walk(props);
|
|
96
|
+
this._prev = this._live;
|
|
97
|
+
this._live = found;
|
|
98
|
+
return wire;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Answer an `invoke` from the child. Returns whether an entry was found. */
|
|
102
|
+
invoke(id, args = []) {
|
|
103
|
+
const fn = this._live.get(id) ?? this._prev.get(id);
|
|
104
|
+
if (!fn) {
|
|
105
|
+
this._warn(
|
|
106
|
+
`react-x11: <Frame> dropped a callback (id ${id}) that outlived ` +
|
|
107
|
+
'two props updates — the child held a stub outside the props flow',
|
|
108
|
+
);
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
fn(...args);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The child's side: rebuild `props` with each marker replaced by a stub
|
|
118
|
+
* that sends `invoke(id, args)`. Same walk, other direction.
|
|
119
|
+
*/
|
|
120
|
+
export function reviveCallbacks(wire, invoke) {
|
|
121
|
+
const seen = new Map();
|
|
122
|
+
const walk = (value) => {
|
|
123
|
+
if (Array.isArray(value)) {
|
|
124
|
+
if (seen.has(value)) return seen.get(value);
|
|
125
|
+
const out = [];
|
|
126
|
+
seen.set(value, out);
|
|
127
|
+
for (const item of value) out.push(walk(item));
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
if (isPlainObject(value)) {
|
|
131
|
+
if (CALLBACK in value) {
|
|
132
|
+
const id = value[CALLBACK];
|
|
133
|
+
return (...args) => invoke(id, sanitizeArgs(args));
|
|
134
|
+
}
|
|
135
|
+
if (seen.has(value)) return seen.get(value);
|
|
136
|
+
const out = {};
|
|
137
|
+
seen.set(value, out);
|
|
138
|
+
for (const key of Object.keys(value)) out[key] = walk(value[key]);
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
return value;
|
|
142
|
+
};
|
|
143
|
+
return walk(wire);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Callback arguments, made sendable. A pane calls `onPick(item, ev)` the
|
|
148
|
+
* way it would call any handler, and `ev` is full of functions and node
|
|
149
|
+
* references — dropping what cannot cross (functions become `undefined`,
|
|
150
|
+
* everything else is kept) beats throwing away the whole call, which is
|
|
151
|
+
* what an unfiltered structured clone would do.
|
|
152
|
+
*/
|
|
153
|
+
export function sanitizeArgs(args) {
|
|
154
|
+
const seen = new Map();
|
|
155
|
+
const walk = (value) => {
|
|
156
|
+
if (typeof value === 'function') return undefined;
|
|
157
|
+
if (Array.isArray(value)) {
|
|
158
|
+
if (seen.has(value)) return seen.get(value);
|
|
159
|
+
const out = [];
|
|
160
|
+
seen.set(value, out);
|
|
161
|
+
for (const item of value) out.push(walk(item));
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
if (isPlainObject(value)) {
|
|
165
|
+
if (seen.has(value)) return seen.get(value);
|
|
166
|
+
const out = {};
|
|
167
|
+
seen.set(value, out);
|
|
168
|
+
for (const key of Object.keys(value)) {
|
|
169
|
+
const walked = walk(value[key]);
|
|
170
|
+
if (walked !== undefined || value[key] === undefined) {
|
|
171
|
+
out[key] = walked;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
return value;
|
|
177
|
+
};
|
|
178
|
+
return args.map(walk);
|
|
179
|
+
}
|