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.
Files changed (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. 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
+ }