react-x11 2.45.2 → 2.46.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.45.2",
3
+ "version": "2.46.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -221,6 +221,10 @@
221
221
  "./cocoa-main": {
222
222
  "types": "./src/cocoa/main.d.ts",
223
223
  "default": "./src/cocoa/main.js"
224
+ },
225
+ "./frame/child": {
226
+ "types": "./src/frame/child.d.ts",
227
+ "default": "./src/frame/child.js"
224
228
  }
225
229
  },
226
230
  "types": "./src/index.d.ts"
package/src/cocoa/app.js CHANGED
@@ -982,9 +982,12 @@ export class CocoaApp {
982
982
  * pane-present and pane-cursor go out. `players` is the
983
983
  * host's word that it plays the pane's `<video src>`s, which makes
984
984
  * `createPlayer` here — before the pane's first render, which asks.
985
+ * `displayScale` is the scale the host's window is drawn at, taken here
986
+ * for the same reason (`adoptPaneScale`).
985
987
  */
986
- attachPaneChannel(channel, { players = false } = {}) {
988
+ attachPaneChannel(channel, { players = false, displayScale } = {}) {
987
989
  if (!this._paneMode) return;
990
+ this.adoptPaneScale(displayScale);
988
991
  this._paneSend = (msg) => {
989
992
  try {
990
993
  channel.send(msg);
@@ -1013,6 +1016,7 @@ export class CocoaApp {
1013
1016
  const wnd = msg.window != null ? this._windows.get(msg.window) : pane;
1014
1017
  wnd?._shown?.(msg.seq);
1015
1018
  } else if (msg?.type === 'pane-rect') {
1019
+ this.adoptPaneScale(msg.displayScale);
1016
1020
  pane.setPaneSize(msg.width, msg.height, msg.scale, msg.screen);
1017
1021
  if (typeof msg.live === 'boolean') pane.setLive(msg.live);
1018
1022
  this._flushPaneOutbox();
@@ -1039,6 +1043,46 @@ export class CocoaApp {
1039
1043
  });
1040
1044
  }
1041
1045
 
1046
+ /**
1047
+ * The scale of the window a pane is shown in, as the host says it — in
1048
+ * the hello and in every pane-rect — made this process's. Returns whether
1049
+ * it moved.
1050
+ *
1051
+ * A pane process reads the screens when it starts, and the host read
1052
+ * them when it did. Across a change of the desk between the two the
1053
+ * answers differ: a browser's tabs drawn at 2x and a page forked for a
1054
+ * new tab after the desk went to 1x — a mirror set for a projector — was
1055
+ * laid out at 1x in a frame the host showed at 2x, every length in it
1056
+ * half what it is beside the tabs, and every click it was sent landing
1057
+ * at twice the distance. A pane has no window on a screen of its own;
1058
+ * the host's window is its display, and its scale is the one everything
1059
+ * here multiplies by: the layout, the fonts, the windows the pane opens,
1060
+ * the screen layout their popups are placed against.
1061
+ *
1062
+ * From the hello that is all, before anything is laid out. A tree
1063
+ * already laid out is restyled in the new unit, as a `scale` prop that
1064
+ * moved restyles a subtree. The pane's own window keeps the scale its
1065
+ * pane-rect gives it (`setPaneSize`): that one is the frame's, a
1066
+ * `<box scale>` around the `<Frame>` included, and says how many device
1067
+ * pixels the rect is.
1068
+ */
1069
+ adoptPaneScale(scale) {
1070
+ if (!this._paneMode || !(scale > 0) || !Number.isFinite(scale)) {
1071
+ return false;
1072
+ }
1073
+ if (scale === this.scale) return false;
1074
+ this.scale = scale;
1075
+ setScaleForTests(this, scale, 'frame');
1076
+ setScreensForTests(this, screenLayout(this._screens, scale));
1077
+ this.fonts?.setScale?.(scale);
1078
+ for (const wnd of this._windows.values()) {
1079
+ if (wnd.destroyed) continue;
1080
+ if (wnd !== this._paneWindow) wnd.scale = scale;
1081
+ wnd._reactX11Node?._rescaleSubtree();
1082
+ }
1083
+ return true;
1084
+ }
1085
+
1042
1086
  /**
1043
1087
  * A message about one of the pane's other windows (`CocoaPaneSubwindow`),
1044
1088
  * held until the host's first pane-rect. The host hears a pane's messages
@@ -771,6 +771,31 @@ export class CocoaFontManager {
771
771
  this._typesetterUnits = 0;
772
772
  }
773
773
 
774
+ /**
775
+ * Make fonts at another scale from here on — a `<Frame>` pane taking the
776
+ * host's (`CocoaApp.adoptPaneScale`). Every handle made at the old one is
777
+ * let go: a size is in device pixels, so the same size at another scale
778
+ * is another point size, which is another optical size of San Francisco
779
+ * and another tracking. What the app loaded stays loaded.
780
+ */
781
+ setScale(scale) {
782
+ const next = scale > 0 && Number.isFinite(scale) ? scale : 1;
783
+ if (next === this._scale) return;
784
+ this._scale = next;
785
+ this._scaleArgs = next === 1 ? [] : [next];
786
+ this._fonts.clear();
787
+ this._faces.clear();
788
+ this._sized.clear();
789
+ this._layouts.clear();
790
+ // a face CoreText chose sizes itself by copying the handle it arrived
791
+ // as, and a copy keeps the scale it was made at
792
+ this._faceByPs.clear();
793
+ for (const faces of this._registered.values()) {
794
+ for (const reg of faces) reg.face = null;
795
+ }
796
+ this._forgetTypesetters();
797
+ }
798
+
774
799
  /**
775
800
  * A paragraph's typesetter, kept from a layout of the same text at any
776
801
  * width. Two thirds of a layout is the text becoming glyphs — the
@@ -188,6 +188,17 @@ export class CocoaPaneWindow {
188
188
  this._post({ type: 'pane-cursor', cursor: name ?? null });
189
189
  }
190
190
 
191
+ /**
192
+ * Tab off an end of the pane's order, handed to the host, which goes on
193
+ * from the `<Frame>`'s box as from any stop of its own
194
+ * (`EventManager._tabOut`). Answers whether anyone was there to take it.
195
+ */
196
+ tabOut(backwards) {
197
+ if (this.destroyed || !this.app._paneSend) return false;
198
+ this._post({ type: 'pane-tab-out', backwards });
199
+ return true;
200
+ }
201
+
191
202
  /** A message about this window, to the host. The pane's own names none:
192
203
  * the host knows it as the pane (`CocoaPaneSubwindow` names itself). */
193
204
  _post(msg) {
@@ -733,6 +744,12 @@ export class CocoaPaneSubwindow extends CocoaPaneWindow {
733
744
  this.app._postWindow({ ...msg, window: this.windowId });
734
745
  }
735
746
 
747
+ /** A window the pane makes besides its own is no stop in the host's
748
+ * order: Tab goes round inside it, as in any window. */
749
+ tabOut() {
750
+ return false;
751
+ }
752
+
736
753
  _send(msg) {
737
754
  if (this.destroyed) return;
738
755
  this._post({ type: 'pane-window', ...msg });
@@ -29,18 +29,113 @@ import {
29
29
  bindRelaunchState,
30
30
  forwardSignals,
31
31
  installStdio,
32
- onWorkerEnd,
33
32
  openThreadedChannel,
34
- printUncaught,
35
33
  RELAUNCH,
36
34
  routeExit,
37
- signalEnded,
38
35
  } from './threaded.js';
39
36
 
40
37
  // Set once the worker is set up, process-wide: the explicit launcher and
41
38
  // the package's own import can both reach `bootstrapWorker`, and from two
42
39
  // copies of the package the bridge would refuse the second `connect`.
43
- const BOOTED = Symbol.for('react-x11.cocoa.workerBooted');
40
+ const BOOTED_KEY = 'react-x11.cocoa.workerBooted';
41
+ const BOOTED = Symbol.for(BOOTED_KEY);
42
+
43
+ // Printed above an error that ended the worker before its import of
44
+ // react-x11 had run there: thrown by code the main thread had run without
45
+ // a complaint a moment before, which is all the developer has to go on.
46
+ const STOPPED_EARLY =
47
+ 'react-x11: the app stopped on its worker thread before reaching its ' +
48
+ 'import of react-x11 there. On macOS react-x11 runs the app on a worker ' +
49
+ 'thread, starting the entry over on it, so what the entry imports ahead ' +
50
+ 'of react-x11 runs twice: on the main thread, then on the worker, where ' +
51
+ 'a call only a main thread may make — process.chdir(), say — throws. ' +
52
+ 'Make such a call on the main thread only (isMainThread, from ' +
53
+ 'node:worker_threads); what it changes holds for the whole process, the ' +
54
+ 'worker included. Or keep the app on the main thread with ' +
55
+ 'REACT_X11_THREADED=0. See docs/macos.md, "What changes for an app".';
56
+
57
+ /**
58
+ * What a worker `relaunch` starts runs before the app's entry, as the source
59
+ * of a function of `(process, global)`: what the worker needs from its first
60
+ * line, since the main thread is parked and hears nothing else from it.
61
+ *
62
+ * Its end, written into the shared state after the app's last exit
63
+ * listener. The main thread ends the process on it, so an app's
64
+ * `process.on('exit')` handler must have finished by then, as it has in a
65
+ * process of its own. Kept last by re-adding it whenever the app adds one:
66
+ * Node runs a worker's exit listeners through `process.emit` and Bun calls
67
+ * them directly (measured), so wrapping the emit would cover only Node.
68
+ *
69
+ * Its uncaught error, printed by itself. Node hands a worker's uncaught
70
+ * error to its parent as an event, and the parent here never runs its event
71
+ * loop again to hear it. Only when nothing handles the error: an app's own
72
+ * `uncaughtException` handler means it is not uncaught. Straight to fd 2,
73
+ * because the worker's own stderr forwards through the parked thread too
74
+ * until `bootstrapWorker` replaces it; a pipe the main thread made
75
+ * non-blocking answers EAGAIN when full, and the write waits it out.
76
+ *
77
+ * Both used to be set up by `bootstrapWorker`, when the entry reached its
78
+ * import of react-x11. A worker that died before then — a module imported
79
+ * ahead of react-x11 calling `process.chdir()`, which only a main thread
80
+ * may — wrote nothing into the shared state and printed nothing, and the
81
+ * process waited in `Atomics.wait` for ever.
82
+ *
83
+ * A string, not a function's `toString()`: a build step rewrites a function
84
+ * — a bundler's name helper, a coverage counter — into references the
85
+ * worker cannot resolve, and a throw here hangs the process the way this
86
+ * exists to prevent.
87
+ */
88
+ export const WORKER_START = `(function (process, global) {
89
+ 'use strict';
90
+ const { workerData } = process.getBuiltinModule('node:worker_threads');
91
+ const { writeSync } = process.getBuiltinModule('node:fs');
92
+ const { inspect } = process.getBuiltinModule('node:util');
93
+ const state = new Int32Array(workerData.reactX11State);
94
+
95
+ const write = (text) => {
96
+ const bytes = Buffer.from(text);
97
+ for (let off = 0; off < bytes.length; ) {
98
+ try {
99
+ off += writeSync(2, bytes, off);
100
+ } catch (err) {
101
+ if (err.code !== 'EAGAIN') return;
102
+ }
103
+ }
104
+ };
105
+ process.on('uncaughtExceptionMonitor', (err) => {
106
+ if (process.listenerCount('uncaughtException') > 0) return;
107
+ const why = global[Symbol.for(${JSON.stringify(BOOTED_KEY)})]
108
+ ? ''
109
+ : ${JSON.stringify(`${STOPPED_EARLY}\n`)};
110
+ write(why + inspect(err) + '\\n');
111
+ });
112
+
113
+ const add = process.on;
114
+ const end = (code) => {
115
+ const n = Number.parseInt(code ?? process.exitCode ?? 0, 10) || 0;
116
+ Atomics.store(state, 1, n);
117
+ Atomics.store(state, 0, ${RELAUNCH.ENDED});
118
+ Atomics.notify(state, 0);
119
+ };
120
+ add.call(process, 'exit', end);
121
+ for (const name of [
122
+ 'on',
123
+ 'addListener',
124
+ 'once',
125
+ 'prependListener',
126
+ 'prependOnceListener',
127
+ ]) {
128
+ const method = process[name];
129
+ process[name] = function (event, listener) {
130
+ const out = method.call(this, event, listener);
131
+ if (event === 'exit' && listener !== end) {
132
+ process.removeListener('exit', end);
133
+ add.call(process, 'exit', end);
134
+ }
135
+ return out;
136
+ };
137
+ }
138
+ })`;
44
139
 
45
140
  // How long the main thread waits, once AppKit's run is over, for the
46
141
  // worker's own exit handlers to finish — they run to the end in a process
@@ -48,6 +143,19 @@ const BOOTED = Symbol.for('react-x11.cocoa.workerBooted');
48
143
  // process for ever.
49
144
  const WORKER_END_MS = 5000;
50
145
 
146
+ // Where Node starts an app (`startedRunning`): the script that loads the
147
+ // `--require` preloads and then the entry, and the module that runs the
148
+ // entry, an ES module's graph from an async function.
149
+ const ENTRY_POINT = new Set([
150
+ 'node:internal/main/run_main_module',
151
+ 'node:internal/modules/run_main',
152
+ ]);
153
+
154
+ // How a process is started under an ES module loader, as a flag of its own
155
+ // or in NODE_OPTIONS: `--import tsx`, `--import=tsx`, `--loader
156
+ // ts-node/esm`, `--experimental-loader ./hooks.mjs`.
157
+ const LOADER_FLAG = /^--(?:import|loader|experimental-loader)(?:=|$)/;
158
+
51
159
  /**
52
160
  * Why this import should not move the app onto a worker, or null when it
53
161
  * should. A pure answer over what the process says about itself, so that
@@ -57,6 +165,8 @@ export function relaunchVeto({
57
165
  isMainThread: main,
58
166
  platform,
59
167
  env,
168
+ execArgv,
169
+ node,
60
170
  entry,
61
171
  compiled,
62
172
  late,
@@ -89,13 +199,33 @@ export function relaunchVeto({
89
199
  ) {
90
200
  return 'a test runner';
91
201
  }
92
- // Imported after the app started running — a dynamic import from a
93
- // timer, say. Relaunching would run again, on the worker, everything the
94
- // entry has already done on this thread.
202
+ // Imported after the app started running: by an `import()` from a timer,
203
+ // say, or one the entry's body awaits. Relaunching would run again, on
204
+ // the worker, everything the entry has already done on this thread.
95
205
  if (late) return 'imported after the app started running';
206
+ // Started under an ES module loader, before Node 24. The worker gets the
207
+ // loader's flags, and whether its hooks follow is the loader's to say:
208
+ // tsx, the one an app is likeliest to run under, keeps them to the main
209
+ // thread on Node 20 and 22 — before 22.22.3, by tsx 4.23's own check —
210
+ // and a worker without them stops on the entry's first .jsx or .ts module
211
+ // (measured, 20.20 and 22.16). No loader can be asked before the move, so
212
+ // any `--import` or `--loader` counts. From 24 an app under one moves,
213
+ // and tsx follows it there from 4.21.1 on 24.11.1 (an older one does not,
214
+ // docs/macos.md); `react-x11/cocoa-main` moves one on 20 or 22 whose
215
+ // loader follows.
216
+ if (node && node < 24 && startedUnderLoader(execArgv, env.NODE_OPTIONS)) {
217
+ return 'an ES module loader before Node 24';
218
+ }
96
219
  return null;
97
220
  }
98
221
 
222
+ /** Whether node's flags, its own or NODE_OPTIONS', name an ES module loader. */
223
+ function startedUnderLoader(execArgv = [], nodeOptions = '') {
224
+ return [...execArgv, ...nodeOptions.split(/\s+/)].some((flag) =>
225
+ LOADER_FLAG.test(flag),
226
+ );
227
+ }
228
+
99
229
  /** What `relaunchVeto` reads, from this process. */
100
230
  export function describeProcess(moduleUrl) {
101
231
  const bun = globalThis.Bun;
@@ -104,6 +234,9 @@ export function describeProcess(moduleUrl) {
104
234
  isMainThread,
105
235
  platform: process.platform,
106
236
  env: process.env,
237
+ execArgv: process.execArgv,
238
+ // Node's major version; Bun answers with one of its own choosing
239
+ node: bun ? null : Number(process.versions.node.split('.')[0]),
107
240
  entry: typeof entry === 'string' && fs.existsSync(entry) ? entry : null,
108
241
  compiled: compiledExecutable(bun),
109
242
  late: startedRunning(bun, moduleUrl, entry),
@@ -121,15 +254,36 @@ function compiledExecutable(bun) {
121
254
  }
122
255
 
123
256
  /**
124
- * Whether the entry's own code has started. Node's event loop has not
125
- * turned while the entry's static imports evaluate —
126
- * `performance.nodeTiming.loopStart` is -1 until it does. Bun reports 1
127
- * there from the first line; what tells in Bun is the entry's record in the
128
- * module cache, which is there once the entry has been evaluated and
129
- * absent while its imports are (both measured, Node 26 and Bun 1.4).
257
+ * Whether the entry's own code has started: whether this import is one the
258
+ * app made after its start, rather than part of it.
259
+ *
260
+ * Node tells by where the import is evaluated from. It starts an app from
261
+ * two modules of its own (`ENTRY_POINT`): a CommonJS entry, and every
262
+ * `require()` of its first run and of a `--require` preload, evaluate under
263
+ * them synchronously, and an ES module entry's graph — static imports, an
264
+ * `--import` preload's — from an async function there, which V8 keeps on
265
+ * the stack as an async frame. An `import()` is evaluated by a job of its
266
+ * own, whose stack ends in the module loader, or in the app's own code when
267
+ * that awaits it: an `await import()` in the entry's body, an async
268
+ * function of the app's. Measured on Node 20.20, 22.16, 23.2, 24.12, 25.4
269
+ * and 26.0, static imports nested and behind a module with a top-level
270
+ * await, and under tsx, `--experimental-loader`, source maps and a stack
271
+ * trace limit of 0. A Node that moved those modules would read every
272
+ * import as late, which keeps the app where it is rather than running it
273
+ * twice; test/cocoa-relaunch.test.js would say so on any OS.
274
+ *
275
+ * `performance.nodeTiming.loopStart`, -1 until the event loop turns, is the
276
+ * public signal and is wrong both ways: Node 20 and 22 turn the loop while
277
+ * they load an ES module entry, so no such entry moved there, and Node 24
278
+ * and later evaluate an `import()` the entry makes before the loop's first
279
+ * turn, so an app that made one moved and ran its entry twice.
280
+ *
281
+ * Bun reports 1 there from the first line; what tells in Bun is the entry's
282
+ * record in the module cache, which is there once the entry has been
283
+ * evaluated and absent while its imports are (measured, Bun 1.4).
130
284
  */
131
285
  function startedRunning(bun, moduleUrl, entry) {
132
- if (!bun) return performance.nodeTiming?.loopStart > 0;
286
+ if (!bun) return !evaluatedFromEntryPoint();
133
287
  try {
134
288
  return Boolean(createRequire(moduleUrl).cache?.[entry]?.loaded);
135
289
  } catch {
@@ -137,13 +291,40 @@ function startedRunning(bun, moduleUrl, entry) {
137
291
  }
138
292
  }
139
293
 
294
+ /** Whether this stack, async frames and all, comes from `ENTRY_POINT`. */
295
+ function evaluatedFromEntryPoint() {
296
+ const { prepareStackTrace, stackTraceLimit } = Error;
297
+ let sites;
298
+ try {
299
+ // Reflect.set: an Error the app has frozen is not a reason to throw
300
+ // from its import of react-x11
301
+ Reflect.set(Error, 'stackTraceLimit', Infinity);
302
+ Reflect.set(Error, 'prepareStackTrace', (_, callSites) => callSites);
303
+ const held = {};
304
+ Error.captureStackTrace(held);
305
+ sites = held.stack;
306
+ } finally {
307
+ Reflect.set(Error, 'prepareStackTrace', prepareStackTrace);
308
+ Reflect.set(Error, 'stackTraceLimit', stackTraceLimit);
309
+ }
310
+ if (!Array.isArray(sites)) return false;
311
+ for (const site of sites) {
312
+ const file = site.getFileName() ?? '';
313
+ if (ENTRY_POINT.has(file)) return true;
314
+ // the app's own code awaits this import, so it has run already
315
+ if (site.isAsync() && !file.startsWith('node:')) return false;
316
+ }
317
+ return false;
318
+ }
319
+
140
320
  /**
141
321
  * The import-time move (src/bootstrap.js): relaunch when nothing vetoes it
142
322
  * and a bridge that can run AppKit's loop is installed. Returns why it did
143
323
  * not, for anyone asking; when it does, it does not return.
144
324
  */
145
325
  export function relaunchOnImport(moduleUrl) {
146
- const veto = relaunchVeto(describeProcess(moduleUrl));
326
+ const facts = describeProcess(moduleUrl);
327
+ const veto = relaunchVeto(facts);
147
328
  if (veto) return veto;
148
329
  let native;
149
330
  try {
@@ -153,16 +334,17 @@ export function relaunchOnImport(moduleUrl) {
153
334
  return 'no cocoa bridge';
154
335
  }
155
336
  if (typeof native.runMain !== 'function') return 'the bridge has no runMain';
156
- return relaunch(describeProcess(moduleUrl).entry, native);
337
+ return relaunch(facts.entry, native);
157
338
  }
158
339
 
159
340
  /**
160
- * The worker's side, set up the moment it imports react-x11: after it the
161
- * app cannot tell it is on a worker, short of asking — its logs reach the
162
- * terminal, `process.exit` ends the process, a crash is printed, and a
163
- * Ctrl-C reaches its `process.on('SIGINT')` (src/cocoa/threaded.js). What
164
- * the app imports before react-x11 runs before this, so a module that logs
165
- * as it loads should come after it.
341
+ * The worker's side, set up the moment it imports react-x11, on top of
342
+ * `WORKER_START`, which has said how the worker ends and printed what
343
+ * crashed it since its first line: after it the app cannot tell it is on a
344
+ * worker, short of asking — its logs reach the terminal, `process.exit`
345
+ * ends the process, and a Ctrl-C reaches its `process.on('SIGINT')`
346
+ * (src/cocoa/threaded.js). What the app imports before react-x11 runs
347
+ * before this, so a module that logs as it loads should come after it.
166
348
  */
167
349
  export function bootstrapWorker(state) {
168
350
  if (globalThis[BOOTED]) return;
@@ -170,9 +352,7 @@ export function bootstrapWorker(state) {
170
352
  const native = loadNative();
171
353
  installStdio();
172
354
  bindRelaunchState(state);
173
- printUncaught();
174
355
  routeExit(native);
175
- onWorkerEnd(signalEnded);
176
356
  openThreadedChannel(native).subscribe(forwardSignals());
177
357
  }
178
358
 
@@ -183,11 +363,24 @@ export function bootstrapWorker(state) {
183
363
  */
184
364
  export function relaunch(entry, native) {
185
365
  const state = new Int32Array(new SharedArrayBuffer(8));
186
- // The worker's entry is the app's own, so nothing has to ship beside it —
366
+ // The worker runs the app's own entry, so nothing has to ship beside it —
187
367
  // a bundle (docs/packaging.md, tier 2) is one file, and a worker entry of
188
368
  // the package's own would be a second one the bundler never emits. The
189
369
  // worker sets itself up when it imports react-x11 (`bootstrapWorker`,
190
- // src/bootstrap.js), recognising the shared state in its workerData.
370
+ // src/bootstrap.js), recognising the shared state in its workerData, and
371
+ // runs `WORKER_START` before anything else, so that it can end the
372
+ // process even if it never gets that far.
373
+ //
374
+ // Node starts the worker on those lines, as `eval` code that then runs
375
+ // the entry the way Node runs a worker's file: `process.argv[1]` set to
376
+ // it, and `Module.runMain`, which runs the `--import` loaders first and
377
+ // makes the entry the main module, `require.main` and `import.meta.main`
378
+ // as before. An `import()` of the entry does neither, and an eval worker
379
+ // runs no `--import` of its own (measured, Node 20–26). Bun's eval worker
380
+ // loses both a failure of the entry and `require.main` (measured, Bun
381
+ // 1.4), so there the entry stays the worker's own and the lines go in as
382
+ // its preload, base64-encoded: percent-encoded, Bun skipped them without
383
+ // an error.
191
384
  //
192
385
  // No `execArgv`: a Worker given none inherits the node flags the process
193
386
  // started with, and among them a loader like `--import tsx`, which is what
@@ -196,11 +389,25 @@ export function relaunch(entry, native) {
196
389
  // `--expose-gc`, `--max-old-space-size`, `--title` — which the worker
197
390
  // would have had anyway, since those hold for every thread: it gets `gc`
198
391
  // either way (measured, Node 20–26; Bun treats the two the same).
199
- new Worker(entry, {
392
+ const options = {
200
393
  argv: process.argv.slice(2),
201
394
  env: SHARE_ENV,
202
395
  workerData: { reactX11State: state.buffer },
203
- });
396
+ };
397
+ const start = `${WORKER_START}(process, globalThis);\n`;
398
+ if (globalThis.Bun) {
399
+ const source = Buffer.from(start).toString('base64');
400
+ new Worker(entry, {
401
+ ...options,
402
+ preload: [`data:text/javascript;base64,${source}`],
403
+ });
404
+ } else {
405
+ new Worker(
406
+ `${start}process.argv[1] = ${JSON.stringify(entry)};\n` +
407
+ "process.getBuiltinModule('node:module').runMain(process.argv[1]);\n",
408
+ { ...options, eval: true },
409
+ );
410
+ }
204
411
  for (;;) {
205
412
  Atomics.wait(state, 0, RELAUNCH.WAITING);
206
413
  const now = Atomics.load(state, 0);
@@ -13,13 +13,13 @@ import fs from 'node:fs';
13
13
  import { constants } from 'node:os';
14
14
  import { Writable } from 'node:stream';
15
15
  import tty from 'node:tty';
16
- import { inspect } from 'node:util';
17
16
 
18
17
  let channel = null;
19
18
 
20
19
  // The relaunch's shared state (src/cocoa/relaunch.js), [state, exit code]:
21
20
  // the main thread waits on it, without AppKit, until the worker says what
22
- // it needs — AppKit, or nothing more because it is done.
21
+ // it needs — AppKit, or nothing more because it is done, which the worker
22
+ // says from the first line it runs (`WORKER_START`).
23
23
  export const RELAUNCH = Object.freeze({ WAITING: 0, APPKIT: 1, ENDED: 2 });
24
24
  let relaunchState = null;
25
25
 
@@ -44,63 +44,6 @@ export async function requestAppKit(native, state = relaunchState) {
44
44
  while (!native.threaded()) await new Promise((r) => setTimeout(r, 1));
45
45
  }
46
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
47
  /** The open channel, or null outside threaded mode. */
105
48
  export function threadedChannel() {
106
49
  return channel;
package/src/events.js CHANGED
@@ -622,6 +622,9 @@ export class EventManager {
622
622
  // window gets keys, and the focused node's caret/ring has to follow
623
623
  onDiscrete('focus', (ev) => this._onWindowFocus(true, ev));
624
624
  onDiscrete('blur', (ev) => this._onWindowFocus(false, ev));
625
+ // Tab arriving from outside the window: a `<Frame>`'s host moving its
626
+ // focus into the pane this window is (`_tabEnter`)
627
+ onDiscrete('tabenter', (ev) => this._tabEnter(Boolean(ev?.backwards)));
625
628
  }
626
629
 
627
630
  /**
@@ -2071,8 +2074,9 @@ export class EventManager {
2071
2074
  const manager = this.focusManager;
2072
2075
  if (manager !== this) return manager._cycleFocus(backwards);
2073
2076
  const list = this._tabbables();
2074
- if (list.length === 0) return;
2075
2077
  const index = list.indexOf(this.focused);
2078
+ if (this._tabOut(list, index, backwards)) return;
2079
+ if (list.length === 0) return;
2076
2080
  if (index === -1) {
2077
2081
  // nothing focused, or focus sits outside the current scope: Tab enters
2078
2082
  this.focus(backwards ? list[list.length - 1] : list[0], 'key', {
@@ -2089,6 +2093,47 @@ export class EventManager {
2089
2093
  );
2090
2094
  }
2091
2095
 
2096
+ /**
2097
+ * Tab off an end of a window that is itself a stop in another window's
2098
+ * order — a `<Frame>`'s pane, whose host goes on from the box the pane is
2099
+ * shown in. The order does not wrap there: the window hands Tab back
2100
+ * (`tabOut` on the backend window, which says whether anyone took it),
2101
+ * and lets go of its focus, as an XEmbed client does when it sends
2102
+ * FOCUS_NEXT, so the stop it was at shows no ring while the host's next
2103
+ * one does. Coming back, Tab lands at an end again (`_tabEnter`).
2104
+ *
2105
+ * Not inside a focus scope: a modal's order wraps in a pane as it does
2106
+ * in a window, and the dialog keeps the keyboard until it closes.
2107
+ */
2108
+ _tabOut(list, index, backwards) {
2109
+ const wnd = this.node.window;
2110
+ if (typeof wnd?.tabOut !== 'function') return false;
2111
+ if (this._scopeRoot() !== this.node) return false;
2112
+ // nothing focused is not an end: Tab enters at one
2113
+ if (list.length && index !== (backwards ? 0 : list.length - 1)) {
2114
+ return false;
2115
+ }
2116
+ if (!wnd.tabOut(backwards)) return false;
2117
+ this.focus(null);
2118
+ return true;
2119
+ }
2120
+
2121
+ /**
2122
+ * Tab arriving at this window from the one it is shown in: the first stop
2123
+ * in its order, or the last for a back-Tab, as a Tab into a window with
2124
+ * nothing focused lands. A window with no stop keeps the keys where they
2125
+ * are, and the next Tab hands them straight back (`_tabOut`).
2126
+ */
2127
+ _tabEnter(backwards) {
2128
+ const manager = this.focusManager;
2129
+ if (manager !== this) return manager._tabEnter(backwards);
2130
+ const list = this._tabbables();
2131
+ if (list.length === 0) return;
2132
+ this.focus(backwards ? list[list.length - 1] : list[0], 'key', {
2133
+ backwards,
2134
+ });
2135
+ }
2136
+
2092
2137
  /**
2093
2138
  * A subtree just stopped being visible: `<Suspense>` showing its fallback,
2094
2139
  * `<Activity mode="hidden">`, or a style that turned `display: 'none'`.
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `react-x11/frame/child` — the entry a `<Frame>` forks: a pane process's
3
+ * whole bootstrap, over the IPC channel `fork()` sets up. **Resolved, not
4
+ * imported**: a `transport` that starts the pane its own way — under a
5
+ * sandbox, with a runtime of its own choosing — forks
6
+ * `fileURLToPath(import.meta.resolve('react-x11/frame/child'))`, and
7
+ * importing it anywhere else exits the process. No exports.
8
+ */
9
+ export {};
@@ -230,8 +230,13 @@ export async function runFrameChild(transport, options = {}) {
230
230
  send: (msg) => transport.send(msg),
231
231
  onMessage: (cb) => transport.onMessage(cb),
232
232
  },
233
- // what the host does for the pane that a pane cannot do itself
234
- { players: Boolean(hello.players) },
233
+ {
234
+ // what the host does for the pane that a pane cannot do itself
235
+ players: Boolean(hello.players),
236
+ // and the scale it is drawn at, before anything here is laid out at
237
+ // the one this process read for itself
238
+ displayScale: hello.rect?.displayScale,
239
+ },
235
240
  );
236
241
 
237
242
  let closing = false;
@@ -46,6 +46,7 @@ import React, {
46
46
  import { useAppOrNull } from '../appcontext.js';
47
47
  import { canEmbed } from '../embedding.js';
48
48
  import { WHEEL_NOTCH_PX } from '../events.js';
49
+ import { XK_TAB } from '../keysyms.js';
49
50
  import { FrameEnv } from './env.js';
50
51
  import { CallbackTable, PROTOCOL } from './protocol.js';
51
52
 
@@ -72,21 +73,22 @@ const childPath = () =>
72
73
 
73
74
  /** The default transport: fork this package's child entry. `execArgv` is
74
75
  * inherited, which is what carries a dev loader (tsx, the refresh loader)
75
- * into the pane; a bundled app forks plain JS and needs none. */
76
- function forkTransport({ src, display }) {
76
+ * into the pane; a bundled app forks plain JS and needs none. The pane's
77
+ * own flags and environment (`<Frame execArgv env>`) go on top. */
78
+ function forkTransport({ src, display, execArgv = [], env = {} }) {
77
79
  const child = fork(childPath(), [], {
78
80
  serialization: 'advanced',
79
81
  // dev loaders (tsx, the refresh loader) follow into the pane; the test
80
82
  // runner's own flags must not — `--test` in execArgv would run the pane
81
- // entry as a test file
82
- execArgv: process.execArgv.filter(
83
- (a) => a !== '--test' && !a.startsWith('--test-'),
84
- ),
85
- env: {
86
- ...process.env,
87
- REACT_X11_FRAME: '1',
88
- ...(display ? { DISPLAY: display } : {}),
89
- },
83
+ // entry as a test file. The pane's come after, so a flag it names is
84
+ // the one in force where the two disagree.
85
+ execArgv: [
86
+ ...process.execArgv.filter(
87
+ (a) => a !== '--test' && !a.startsWith('--test-'),
88
+ ),
89
+ ...execArgv,
90
+ ],
91
+ env: paneEnv(env, display),
90
92
  });
91
93
  void src; // resolved by the child from the hello, not from argv
92
94
  return {
@@ -120,6 +122,58 @@ function forkTransport({ src, display }) {
120
122
  };
121
123
  }
122
124
 
125
+ /**
126
+ * The environment a pane process starts with: this one's, with what the
127
+ * `<Frame>` names over it — `undefined` takes a variable away — and then
128
+ * the two that are not the app's to set: `REACT_X11_FRAME`, which is how a
129
+ * pane knows it is one (`isFramed`), and `DISPLAY`, which `display` names.
130
+ */
131
+ function paneEnv(env, display) {
132
+ const out = { ...process.env };
133
+ for (const [name, value] of Object.entries(env)) {
134
+ if (value === undefined) delete out[name];
135
+ else out[name] = value;
136
+ }
137
+ out.REACT_X11_FRAME = '1';
138
+ if (display) out.DISPLAY = display;
139
+ return out;
140
+ }
141
+
142
+ /**
143
+ * What a pane's process is started with, checked and as one string: the
144
+ * session's key, so that an `execArgv` array or an `env` object written in
145
+ * place — a new one each render — restarts nothing, and one that says
146
+ * something else restarts the pane. Throws on what a fork cannot take,
147
+ * which the session reports as a `spawn` failure.
148
+ */
149
+ function launchOf(execArgv, env) {
150
+ if (
151
+ execArgv !== undefined &&
152
+ (!Array.isArray(execArgv) || execArgv.some((a) => typeof a !== 'string'))
153
+ ) {
154
+ throw new TypeError(
155
+ 'react-x11: <Frame execArgv> is an array of strings — the flags the ' +
156
+ "pane's process is started with.",
157
+ );
158
+ }
159
+ if (
160
+ env !== undefined &&
161
+ (env === null ||
162
+ typeof env !== 'object' ||
163
+ Object.values(env).some((v) => v !== undefined && typeof v !== 'string'))
164
+ ) {
165
+ throw new TypeError(
166
+ "react-x11: <Frame env> maps a variable's name to a string, or to " +
167
+ 'undefined to take it away.',
168
+ );
169
+ }
170
+ // `undefined` is kept apart from a variable left alone, which
171
+ // JSON.stringify would drop
172
+ return JSON.stringify([execArgv ?? [], env ?? {}], (_, v) =>
173
+ v === undefined ? { unset: true } : v,
174
+ );
175
+ }
176
+
123
177
  function srcString(src) {
124
178
  if (src !== null && typeof src === 'object' && typeof src.href === 'string') {
125
179
  return src.href;
@@ -219,6 +273,8 @@ export function Frame({
219
273
  props = EMPTY_PROPS,
220
274
  style,
221
275
  display,
276
+ execArgv,
277
+ env: processEnv,
222
278
  bridge = true,
223
279
  fallback,
224
280
  focusable,
@@ -261,8 +317,20 @@ export function Frame({
261
317
  const source = srcString(src);
262
318
  const makeTransport = transport ?? forkTransport;
263
319
  const containerRef = useRef(null);
320
+ // the pane process's own flags and environment, by what they say
321
+ let launch;
322
+ let launchError = null;
323
+ try {
324
+ launch = launchOf(execArgv, processEnv);
325
+ } catch (err) {
326
+ launch = '';
327
+ launchError = err;
328
+ }
329
+ const paneProcess = useRef(null);
330
+ paneProcess.current = { execArgv: execArgv ?? [], env: processEnv ?? {} };
264
331
 
265
- // The session: one child process per (src, display, transport, restart).
332
+ // The session: one child process per (src, display, the process's flags
333
+ // and environment, transport, restart).
266
334
  // Declared before the update effect so that on the mounting commit the
267
335
  // hello goes out first and the update pass sees itself already sent.
268
336
  useEffect(() => {
@@ -296,7 +364,13 @@ export function Frame({
296
364
 
297
365
  let t;
298
366
  try {
299
- t = makeTransport({ src: source, display });
367
+ if (launchError) throw launchError;
368
+ t = makeTransport({
369
+ src: source,
370
+ display,
371
+ execArgv: paneProcess.current.execArgv,
372
+ env: paneProcess.current.env,
373
+ });
300
374
  } catch (err) {
301
375
  fail(Object.assign(err, { phase: 'spawn' }));
302
376
  return undefined;
@@ -407,6 +481,13 @@ export function Frame({
407
481
  const rect = {
408
482
  width: Math.round((node?.abs?.width || 0) / sc) || 400,
409
483
  height: Math.round((node?.abs?.height || 0) / sc) || 300,
484
+ // The display scale the host is drawn at. A pane process reads the
485
+ // screens when it starts, which is not when the host did: a page
486
+ // forked after the desk went to 1x — a mirror set for a projector —
487
+ // was laid out at 1x in a frame the host showed at 2x. A pane that
488
+ // has no window of its own takes this one instead of its own
489
+ // (`CocoaApp.adoptPaneScale`).
490
+ ...(node?.root?.scale > 0 && { displayScale: node.root.scale }),
410
491
  };
411
492
  s.sent = { props: p, env: e, bridge: b };
412
493
  trySend({
@@ -431,7 +512,7 @@ export function Frame({
431
512
  // ask, and everything is unref'd so nothing holds the host open
432
513
  if (session.current === s) session.current = null;
433
514
  };
434
- }, [source, display, generation, makeTransport, canShowPane]);
515
+ }, [source, display, launch, generation, makeTransport, canShowPane]);
435
516
 
436
517
  // One update per commit that changed the pane's inputs, props and env in
437
518
  // the same message — so a theme flip and the state change that caused it
@@ -525,6 +606,37 @@ function PaneHostView({
525
606
  const embedErrorRef = useRef(onEmbedError);
526
607
  embedErrorRef.current = onEmbedError;
527
608
 
609
+ const forward = (name, data) => (ev) => {
610
+ const s = session.current;
611
+ const node = containerRef.current;
612
+ if (!s || !node) return;
613
+ const sc = node.scale ?? 1;
614
+ const payload = {
615
+ x: Math.max(0, Math.round(ev.x * sc - node.abs.x)),
616
+ y: Math.max(0, Math.round(ev.y * sc - node.abs.y)),
617
+ rootx: Math.round(ev.x * sc),
618
+ rooty: Math.round(ev.y * sc),
619
+ // The modifier mask, which a synthetic event carries on its native
620
+ // event alone. Read off the synthetic one, every key and press
621
+ // reached the pane with none: Shift+Tab went forwards there, and a
622
+ // Ctrl+C or a Shift+click was a C and a click.
623
+ buttons: ev.nativeEvent?.buttons ?? 0,
624
+ time: Date.now(),
625
+ ...data(ev),
626
+ };
627
+ try {
628
+ s.trySend?.({ type: 'pane-event', name, ev: payload });
629
+ } catch {
630
+ // the exit path owns the failure story
631
+ }
632
+ };
633
+ const key = (ev) => ({
634
+ keysym: ev.keysym,
635
+ baseKeysym: ev.baseKeysym ?? ev.keysym,
636
+ codepoint: ev.codepoint,
637
+ keycode: ev.keycode ?? 0,
638
+ });
639
+
528
640
  useEffect(() => {
529
641
  const s = session.current;
530
642
  const node = containerRef.current;
@@ -595,6 +707,7 @@ function PaneHostView({
595
707
  width: rect.width,
596
708
  height: rect.height,
597
709
  scale: rect.scale,
710
+ displayScale: rect.displayScale,
598
711
  live: liveNow(),
599
712
  ...(rect.screen && { screen: rect.screen }),
600
713
  });
@@ -609,6 +722,7 @@ function PaneHostView({
609
722
  a.width === b.width &&
610
723
  a.height === b.height &&
611
724
  a.scale === b.scale &&
725
+ a.displayScale === b.displayScale &&
612
726
  a.screen?.x === b.screen?.x &&
613
727
  a.screen?.y === b.screen?.y;
614
728
  const sized = () => {
@@ -632,7 +746,11 @@ function PaneHostView({
632
746
  y: Math.round(origin.y + node.abs.y),
633
747
  }
634
748
  : null;
635
- const rect = { width, height, scale: sc, screen };
749
+ // `scale` is the frame's, a `<box scale>` around it included, and
750
+ // says how many device pixels the pane's rect is; `displayScale` is
751
+ // the window's, the one the pane lays out at (see the hello)
752
+ const displayScale = node.root?.scale ?? sc;
753
+ const rect = { width, height, scale: sc, displayScale, screen };
636
754
  if (sameRect(rect, pending ?? sent)) return;
637
755
  if (inFlight) pending = rect;
638
756
  else transmit(rect);
@@ -663,8 +781,67 @@ function PaneHostView({
663
781
  }),
664
782
  );
665
783
 
784
+ // Tab, which goes through the pane as it goes through any subtree. The
785
+ // box is a stop in this window's order, and the pane's own order hangs
786
+ // off it — the chain XEmbed makes for a `<foreign>` with FOCUS_FIRST,
787
+ // FOCUS_LAST and FOCUS_NEXT/PREV (src/foreignnodes.js). Tab arriving
788
+ // at the box goes on into the pane, at its first stop or, for a
789
+ // back-Tab, its last (`tabenter`). Tab while the box has the focus is
790
+ // the pane's, sent on and not cycled here. And the pane running off an
791
+ // end of its order hands Tab back (`pane-tab-out`), which goes on from
792
+ // the box. Without the three, the box took a Tab and the pane the next,
793
+ // which moved the pane's focus a stop and this window's out of the
794
+ // pane, so Tab went between one of the pane's stops and the window's.
795
+ //
796
+ // Keys go over as the box's default action, after the whole tree has
797
+ // had them, so a chord the application answered above the box never
798
+ // reaches the pane: sent from the box's handler, Ctrl+Tab switched the
799
+ // application's tab and moved the focus in the pane. And the pane
800
+ // composes what it is sent, as an embedded client does, rather than
801
+ // this window composing it first.
802
+ const tabInto = (backwards) =>
803
+ s.trySend?.({
804
+ type: 'pane-event',
805
+ name: 'tabenter',
806
+ ev: { backwards, buttons: 0, time: Date.now() },
807
+ });
808
+ const own = {
809
+ defaultFocus: Object.hasOwn(node, 'defaultFocus'),
810
+ defaultKeyDown: Object.hasOwn(node, 'defaultKeyDown'),
811
+ defaultKeyUp: Object.hasOwn(node, 'defaultKeyUp'),
812
+ composes: Object.hasOwn(node, 'composes'),
813
+ };
814
+ const was = {
815
+ defaultFocus: node.defaultFocus,
816
+ defaultKeyDown: node.defaultKeyDown,
817
+ defaultKeyUp: node.defaultKeyUp,
818
+ composes: node.composes,
819
+ };
820
+ const sendKeyDown = forward('keydown', key);
821
+ const sendKeyUp = forward('keyup', key);
822
+ node.defaultFocus = (info) => {
823
+ if (info?.reason === 'key') tabInto(Boolean(info.backwards));
824
+ };
825
+ node.defaultKeyDown = (ev) => {
826
+ sendKeyDown(ev);
827
+ // the pane's order answers it, and hands it back at an end
828
+ if (ev.keysym === XK_TAB) ev.preventDefault();
829
+ };
830
+ node.defaultKeyUp = sendKeyUp;
831
+ node.composes = false;
832
+
666
833
  const off = s.transport.onMessage((msg) => {
667
- if (msg?.type === 'pane-window') {
834
+ if (msg?.type === 'pane-tab-out') {
835
+ // Only from focus the box holds: a pane that hands back a Tab it
836
+ // was not given is news that came too late.
837
+ const manager = node.root?.events?.focusManager;
838
+ if (!manager || manager.focused !== node) return;
839
+ const backwards = msg.backwards === true;
840
+ manager._cycleFocus(backwards);
841
+ // the window's only stop: round into the pane again, at the end
842
+ // that Tab comes to next
843
+ if (manager.focused === node) tabInto(backwards);
844
+ } else if (msg?.type === 'pane-window') {
668
845
  host.paneWindow?.(msg);
669
846
  } else if (msg?.type === 'pane-menu') {
670
847
  host.popUpMenu?.(msg);
@@ -705,32 +882,15 @@ function PaneHostView({
705
882
  node.root?.events?.refreshCursor();
706
883
  node.absolutize = origAbsolutize;
707
884
  if (origShift) node._shiftAbs = origShift;
885
+ for (const name of Object.keys(own)) {
886
+ if (own[name]) node[name] = was[name];
887
+ else delete node[name];
888
+ }
708
889
  if (s.paneHost === host) s.paneHost = null;
709
890
  host.destroy();
710
891
  };
711
892
  }, [app, session, containerRef]);
712
893
 
713
- const forward = (name, data) => (ev) => {
714
- const s = session.current;
715
- const node = containerRef.current;
716
- if (!s || !node) return;
717
- const sc = node.scale ?? 1;
718
- const payload = {
719
- x: Math.max(0, Math.round(ev.x * sc - node.abs.x)),
720
- y: Math.max(0, Math.round(ev.y * sc - node.abs.y)),
721
- rootx: Math.round(ev.x * sc),
722
- rooty: Math.round(ev.y * sc),
723
- buttons: ev.buttons ?? 0,
724
- time: Date.now(),
725
- ...data(ev),
726
- };
727
- try {
728
- s.trySend?.({ type: 'pane-event', name, ev: payload });
729
- } catch {
730
- // the exit path owns the failure story
731
- }
732
- };
733
-
734
894
  return h('box', {
735
895
  ref: containerRef,
736
896
  style,
@@ -754,17 +914,5 @@ function PaneHostView({
754
914
  smooth: Boolean(ev.smooth),
755
915
  source: 'forwarded',
756
916
  })),
757
- onKeyDown: forward('keydown', (ev) => ({
758
- keysym: ev.keysym,
759
- baseKeysym: ev.baseKeysym ?? ev.keysym,
760
- codepoint: ev.codepoint,
761
- keycode: ev.keycode ?? 0,
762
- })),
763
- onKeyUp: forward('keyup', (ev) => ({
764
- keysym: ev.keysym,
765
- baseKeysym: ev.baseKeysym ?? ev.keysym,
766
- codepoint: ev.codepoint,
767
- keycode: ev.keycode ?? 0,
768
- })),
769
917
  });
770
918
  }
@@ -4,6 +4,7 @@
4
4
  import { discrete } from '../../events.js';
5
5
  import { createClientMessages } from '../../clientmessage.js';
6
6
  import { callHandler } from '../../errors.js';
7
+ import { ntkRoot } from '../../ntkroot.js';
7
8
  import { runWithPriority, DiscreteEventPriority } from '../../priority.js';
8
9
  import { DEV } from '../util.js';
9
10
 
@@ -178,9 +179,48 @@ export class WindowListeners {
178
179
  }),
179
180
  );
180
181
  }
182
+ if (this.props.embeddable && !parentWindow) this._initXEmbedTab(wnd);
181
183
  this.events.attach();
182
184
  }
183
185
 
186
+ /**
187
+ * Tab through a window another client embeds — a `<Frame>`'s pane on X11
188
+ * — as XEmbed's client. The embedder is a `<foreign>`
189
+ * (src/foreignnodes.js): Tab arriving at it sends XEMBED_FOCUS_IN with
190
+ * FOCUS_FIRST or FOCUS_LAST, which is this window's `tabenter`, and
191
+ * XEMBED_FOCUS_NEXT or _PREV, which its `tabOut` sends, moves the
192
+ * embedder's focus on from it (`EventManager._tabEnter`, `_tabOut`). The
193
+ * Cocoa and Windows panes say the same two things over their channel.
194
+ * Without it, Tab went round the pane's own stops for good: nothing
195
+ * handed it back.
196
+ *
197
+ * ntk's `XEmbedPlug` speaks it, and publishes `_XEMBED_INFO` with
198
+ * XEMBED_MAPPED set — an embedder maps such a window as it takes it, as it
199
+ * maps one that publishes none, so the pane still never maps itself.
200
+ */
201
+ _initXEmbedTab(wnd) {
202
+ const ntk = ntkRoot();
203
+ if (!ntk?.XEmbedPlug || typeof wnd.sendClientMessage !== 'function') {
204
+ return;
205
+ }
206
+ if (typeof wnd.tabOut === 'function') return;
207
+ const { XEMBED } = ntk;
208
+ const plug = new ntk.XEmbedPlug(this.app, { window: wnd });
209
+ plug.ready.catch(() => {});
210
+ plug.on('focusIn', (detail) => {
211
+ if (detail !== XEMBED.FOCUS_FIRST && detail !== XEMBED.FOCUS_LAST) {
212
+ return;
213
+ }
214
+ wnd.emit('tabenter', { backwards: detail === XEMBED.FOCUS_LAST });
215
+ });
216
+ wnd.tabOut = (backwards) => {
217
+ if (!plug.embedder) return false;
218
+ if (backwards) plug.focusPrev();
219
+ else plug.focusNext();
220
+ return true;
221
+ };
222
+ }
223
+
184
224
  /**
185
225
  * A close request nobody handled — `onCloseRequest` is the override, this
186
226
  * is what happens without one.
@@ -388,10 +388,13 @@ export async function act(fn, only = null) {
388
388
  // block — commit and settle. One round leaves that update in flight, and
389
389
  // React says so out loud ("An update to Button inside a test was not
390
390
  // wrapped in act(...)"), which is how this was found.
391
+ // A pointer move off the wire is still not in the tree: its window holds
392
+ // it for the next frame, so each round also hands over what is held.
391
393
  for (let round = 0; round < 2; round++) {
392
394
  await React.act(async () => {
393
395
  await new Promise((resolve) => setImmediate(resolve));
394
396
  for (const entry of entries) await settle(entry.app);
397
+ for (const entry of entries) deliverHeldEvents(entry);
395
398
  });
396
399
  }
397
400
  } finally {
@@ -401,6 +404,35 @@ export async function act(fn, only = null) {
401
404
  for (const entry of entries) await settle(entry.app);
402
405
  }
403
406
 
407
+ /**
408
+ * Dispatch the events each window in a root is holding for its next frame.
409
+ *
410
+ * ntk delivers its noisy events — pointer motion, a resize, an expose —
411
+ * merged, once a frame, and starts frames at least a frame interval apart.
412
+ * A move that lands within an interval of the last frame waits out the rest
413
+ * of it, and the rest ends after `act()` has returned: the second of two
414
+ * hovers in a row is such a move, the first one's frame having started the
415
+ * interval, and the assertion right after it saw the pointer where it had
416
+ * been. A press or a key never waits, since ntk delivers what it holds ahead
417
+ * of any discrete event, but a pure move has nothing behind it.
418
+ *
419
+ * Delivered as the frame would deliver them, only now, and inside
420
+ * `React.act`, so what the handlers set commits in the same round.
421
+ * `flushCoalescedEvents()` is ntk's public way to ask for that; an ntk
422
+ * without it has only the method its frame calls, and that branch goes once
423
+ * the floor is an ntk with it.
424
+ */
425
+ function deliverHeldEvents(entry) {
426
+ for (const node of windowNodesOf(entry.windowNode)) {
427
+ const win = node.window;
428
+ if (typeof win?.flushCoalescedEvents === 'function') {
429
+ win.flushCoalescedEvents();
430
+ } else {
431
+ win?._flushCoalesced?.();
432
+ }
433
+ }
434
+ }
435
+
404
436
  /**
405
437
  * Run the frame the scheduler would have run, for every window in a root.
406
438
  *
@@ -45,6 +45,22 @@ export interface FrameComponentProps {
45
45
  style?: StyleProp;
46
46
  /** X display for the pane process; defaults to the inherited `$DISPLAY`. */
47
47
  display?: string;
48
+ /**
49
+ * Flags for the pane's process, after the ones it inherits from this one
50
+ * (a dev loader's): `['--max-old-space-size=256']` bounds the pane's heap,
51
+ * which nothing inside the pane can. They are handed to the runtime that
52
+ * forks it, so they are that runtime's flags. A change restarts the pane;
53
+ * the same flags in a new array do not.
54
+ */
55
+ execArgv?: readonly string[];
56
+ /**
57
+ * Environment variables for the pane's process, over this one's; a value
58
+ * of `undefined` takes a variable away. `DISPLAY` is `display`'s and
59
+ * `REACT_X11_FRAME` marks the pane (`isFramed`), so neither can be set
60
+ * here. A change restarts the pane; the same variables in a new object do
61
+ * not.
62
+ */
63
+ env?: Readonly<Record<string, string | undefined>>;
48
64
  /**
49
65
  * Which bridged contexts follow the app into this pane: `true` (default)
50
66
  * for every registered one — the theme included — `false` for none, or an
@@ -75,10 +91,23 @@ export interface FrameComponentProps {
75
91
  * (a loopback pair into `runFrameChild`), and the door to running a pane
76
92
  * somewhere other than a child process. Advanced; see docs/frame.md.
77
93
  */
78
- transport?(options: { src: string; display?: string }): FrameTransport;
94
+ transport?(options: FrameTransportOptions): FrameTransport;
79
95
  ref?: Ref<FrameHandle>;
80
96
  }
81
97
 
98
+ /** What a transport is asked to start: the pane module, and the display,
99
+ * flags and environment `<Frame>` names for its process. The default
100
+ * transport forks `react-x11/frame/child` with them. */
101
+ export interface FrameTransportOptions {
102
+ src: string;
103
+ display?: string;
104
+ /** `<Frame execArgv>`, or none: added to what the pane inherits. */
105
+ execArgv: readonly string[];
106
+ /** `<Frame env>`, or none: over this process's environment, `undefined`
107
+ * taking a variable away. */
108
+ env: Readonly<Record<string, string | undefined>>;
109
+ }
110
+
82
111
  /** The parent's end of the wire to a pane. */
83
112
  export interface FrameTransport {
84
113
  /** May throw when the channel is gone or the message will not clone. */
package/src/win32/app.js CHANGED
@@ -418,9 +418,12 @@ export class Win32App {
418
418
  * The pane process's end of the frame channel: geometry and input in,
419
419
  * presents and the cursor out. Feature-detected by the pane bootstrap
420
420
  * (src/frame/childmain.js), and a no-op in a process that is not a pane.
421
+ * `displayScale` is the scale the host's window is drawn at, taken before
422
+ * the pane's first render (`adoptPaneScale`).
421
423
  */
422
- attachPaneChannel(channel) {
424
+ attachPaneChannel(channel, { displayScale } = {}) {
423
425
  if (!this._paneMode) return;
426
+ this.adoptPaneScale(displayScale);
424
427
  this._paneSend = (msg) => {
425
428
  try {
426
429
  channel.send(msg);
@@ -429,6 +432,8 @@ export class Win32App {
429
432
  }
430
433
  };
431
434
  channel.onMessage((msg) => {
435
+ // the app's, so heard with no window to size yet as well
436
+ if (msg?.type === 'pane-rect') this.adoptPaneScale(msg.displayScale);
432
437
  const wnd = [...this._windows.values()][0];
433
438
  if (!wnd) return;
434
439
  if (msg?.type === 'pane-rect') {
@@ -439,6 +444,36 @@ export class Win32App {
439
444
  });
440
445
  }
441
446
 
447
+ /**
448
+ * The scale of the window a pane is shown in, as the host says it — in
449
+ * the hello and in every pane-rect — made this process's. Returns whether
450
+ * it moved.
451
+ *
452
+ * A pane process reads the screens when it starts, and the host read
453
+ * them when it did. Across a change of the desk between the two — a
454
+ * display's scaling changed, a laptop docked — the answers differ, and a
455
+ * pane forked after it was laid out at its own scale in a buffer the host
456
+ * sized at the host's: every length in it off by the ratio of the two,
457
+ * and every click it was sent landing that far off too. The host's window
458
+ * is a pane's display, and its scale is the one the tree multiplies by.
459
+ * The pane's buffer keeps the scale its pane-rect gives it
460
+ * (`Win32PaneWindow.setPaneSize`): that one is the frame's, a
461
+ * `<box scale>` around the `<Frame>` included. A tree already laid out
462
+ * is restyled in the new unit.
463
+ */
464
+ adoptPaneScale(scale) {
465
+ if (!this._paneMode || !(scale > 0) || !Number.isFinite(scale)) {
466
+ return false;
467
+ }
468
+ if (scale === this.scale) return false;
469
+ this.scale = scale;
470
+ setScaleForTests(this, scale, 'frame');
471
+ for (const wnd of this._windows.values()) {
472
+ if (!wnd.destroyed) wnd._reactX11Node?._rescaleSubtree();
473
+ }
474
+ return true;
475
+ }
476
+
442
477
  close() {
443
478
  if (this._closed) return Promise.resolve();
444
479
  this._closed = true;
@@ -224,6 +224,14 @@ export class Win32PaneWindow {
224
224
  this.title = title;
225
225
  }
226
226
 
227
+ /** Tab off an end of the pane's order, handed to the host, which goes on
228
+ * from the `<Frame>`'s box (the Cocoa pane's `tabOut`). */
229
+ tabOut(backwards) {
230
+ if (this.destroyed || !this.app._paneSend) return false;
231
+ this.app._paneSend({ type: 'pane-tab-out', backwards });
232
+ return true;
233
+ }
234
+
227
235
  /** The cursor this pane's tree names, sent to the host, which shows it:
228
236
  * the pointer is over the host's window, not this one (the Cocoa pane's
229
237
  * `setCursor`). */