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
package/src/portal.js ADDED
@@ -0,0 +1,461 @@
1
+ // xdg-desktop-portal: the shared machinery every portal-backed feature needs,
2
+ // and nothing user-visible.
3
+ //
4
+ // A portal is a D-Bus API, not a widget. An app asks "the desktop" to run a
5
+ // dialog *in another process* and hand back the answer — which is how a
6
+ // sandboxed app gets a file picker it is not allowed to draw, and how a Qt app
7
+ // on GNOME gets a GTK dialog with your bookmarks in it.
8
+ //
9
+ // ## The one thing that is easy to get wrong
10
+ //
11
+ // Every portal method that shows UI has the same shape: it returns an object
12
+ // path **immediately** and answers later with a
13
+ // `org.freedesktop.portal.Request.Response` signal. Subscribe after the call
14
+ // returns and you can lose the answer — the dialog can be dismissed before
15
+ // your match rule is in place.
16
+ //
17
+ // So the path is predictable *by design*. From the upstream
18
+ // `org.freedesktop.portal.Request.xml`:
19
+ //
20
+ // > Since version 0.9 of xdg-desktop-portal, the handle will be of the form
21
+ // > `/org/freedesktop/portal/desktop/request/SENDER/TOKEN` where SENDER is the
22
+ // > callers unique name, with the initial ':' removed and all '.' replaced by
23
+ // > '_' […] This change was made to let applications subscribe to the Response
24
+ // > signal before making the initial portal call, thereby avoiding a race
25
+ // > condition.
26
+ //
27
+ // `portalRequest()` is that subscribe-then-call, written once, so every portal
28
+ // added later is a method name and an options dict rather than a race to
29
+ // re-solve.
30
+
31
+ import { loadTransport, sessionBus } from './bus.js';
32
+
33
+ export const PORTAL_NAME = 'org.freedesktop.portal.Desktop';
34
+ export const PORTAL_PATH = '/org/freedesktop/portal/desktop';
35
+ const REQUEST_IFACE = 'org.freedesktop.portal.Request';
36
+
37
+ /** The portal answered; `response` is what it means. */
38
+ export const RESPONSE_OK = 0;
39
+ export const RESPONSE_CANCELLED = 1;
40
+ export const RESPONSE_ENDED = 2;
41
+
42
+ /**
43
+ * There is no portal here — no bus, no service, or a version too old for what
44
+ * was asked. A **typed** rejection rather than a crash: it is the signal that
45
+ * a caller should fall back to something it can draw itself, which is exactly
46
+ * what the file dialog does.
47
+ */
48
+ export class NoPortalError extends Error {
49
+ constructor(message, cause) {
50
+ super(`react-x11: ${message}`, { cause });
51
+ this.name = 'NoPortalError';
52
+ }
53
+ }
54
+
55
+ /** The user closed the dialog, or the desktop ended the request some other way. */
56
+ export class PortalCancelledError extends Error {
57
+ constructor(response = RESPONSE_CANCELLED) {
58
+ super(
59
+ response === RESPONSE_CANCELLED
60
+ ? 'react-x11: the portal dialog was cancelled'
61
+ : 'react-x11: the portal request ended without an answer',
62
+ );
63
+ this.name = 'PortalCancelledError';
64
+ /** 1 when the user cancelled, 2 when it ended some other way. */
65
+ this.response = response;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * `':1.42'` → `'1_42'`.
71
+ *
72
+ * The transform is three characters of code and the whole reason the Request
73
+ * race is avoidable, so it is named and tested rather than inlined.
74
+ */
75
+ export const senderPath = (uniqueName) =>
76
+ uniqueName.replace(/^:/, '').replaceAll('.', '_');
77
+
78
+ /**
79
+ * A token that is a legal object-path element and not guessable — the XML asks
80
+ * for "a per-library prefix combined with a random number", so: ours plus
81
+ * eight random bytes.
82
+ */
83
+ async function newToken() {
84
+ const { randomBytes } = await import('node:crypto');
85
+ return `rx11_${randomBytes(8).toString('hex')}`;
86
+ }
87
+
88
+ // --------------------------------------------------------------------------
89
+ // hasService
90
+ // --------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Cache per bus generation. `NameOwnerChanged` would invalidate it precisely;
94
+ * a portal appearing mid-session is rare enough that the cheap version — cache
95
+ * only the *positive* answer — is the honest trade. A false negative that
96
+ * cached would be the bug (a feature disabled for the life of the process),
97
+ * and this cannot produce one.
98
+ */
99
+ const reachable = new Map();
100
+
101
+ /**
102
+ * Is `name` reachable — owned right now, **or activatable on demand**?
103
+ *
104
+ * `NameHasOwner` alone is the wrong question and gets this wrong on a healthy
105
+ * desktop. `org.freedesktop.portal.Desktop` ships a D-Bus service file:
106
+ *
107
+ * [D-BUS Service]
108
+ * Name=org.freedesktop.portal.Desktop
109
+ * Exec=…/xdg-desktop-portal
110
+ *
111
+ * so on a GNOME session where no app has touched a portal yet the name has no
112
+ * owner and would answer *false* — and a feature gated on it takes the
113
+ * fallback path forever, on a machine where the portal works perfectly.
114
+ */
115
+ export async function hasService(name, busRef) {
116
+ if (reachable.get(name)) return true;
117
+ const ref = busRef ?? (await sessionBus());
118
+ if (!ref) return false;
119
+ try {
120
+ const [owned, activatable] = await Promise.all([
121
+ ref.bus.listNames(),
122
+ ref.bus.listActivatableNames().catch(() => []),
123
+ ]);
124
+ const found = owned.includes(name) || activatable.includes(name);
125
+ if (found) reachable.set(name, true);
126
+ return found;
127
+ } catch {
128
+ return false;
129
+ } finally {
130
+ if (!busRef) await ref.release();
131
+ }
132
+ }
133
+
134
+ /** Test seam, not public: forget what was learned about the bus. */
135
+ export function _resetServiceCache() {
136
+ reachable.clear();
137
+ versions.clear();
138
+ }
139
+
140
+ // --------------------------------------------------------------------------
141
+ // portalVersion
142
+ // --------------------------------------------------------------------------
143
+
144
+ /**
145
+ * Positive answers only, like {@link hasService}'s cache and for the same
146
+ * reason: a version can only move by the portal restarting, which is rare
147
+ * enough to ignore, while a cached "not there" would disable a feature for
148
+ * the life of the process on a machine where the portal appears one call
149
+ * later.
150
+ */
151
+ const versions = new Map();
152
+
153
+ /**
154
+ * The version of one portal *interface*, or `0` when it is not there.
155
+ *
156
+ * `hasService()` answers a different question. The portal service being
157
+ * reachable says nothing about which interfaces its backends provide —
158
+ * XFCE's portal has no `Screenshot` interface at all, and a GNOME old enough
159
+ * has it at version 1, before `PickColor` existed. Capability lives in the
160
+ * interface's own `version` property, so gating a feature means reading it:
161
+ *
162
+ * ```js
163
+ * if ((await portalVersion('org.freedesktop.portal.Screenshot', ref)) >= 2) {
164
+ * // PickColor is there
165
+ * }
166
+ * ```
167
+ *
168
+ * `0` collapses "no bus", "no portal", "no such interface" and "no version
169
+ * property" into one answer, because every caller would branch the same way
170
+ * on all four.
171
+ */
172
+ export async function portalVersion(iface, busRef) {
173
+ const cached = versions.get(iface);
174
+ if (cached !== undefined) return cached;
175
+ const ref = busRef ?? (await sessionBus());
176
+ if (!ref) return 0;
177
+ try {
178
+ const value = await ref.bus.invoke(
179
+ {
180
+ destination: PORTAL_NAME,
181
+ path: PORTAL_PATH,
182
+ interface: 'org.freedesktop.DBus.Properties',
183
+ member: 'Get',
184
+ signature: 'ss',
185
+ body: [iface, 'version'],
186
+ },
187
+ { timeout: 10_000 },
188
+ );
189
+ // dbus-native unwraps the `v` reply to its value.
190
+ const version = typeof value === 'number' && value > 0 ? value : 0;
191
+ if (version > 0) versions.set(iface, version);
192
+ return version;
193
+ } catch {
194
+ // `org.freedesktop.DBus.Error.InvalidArgs` — the portal is there and the
195
+ // interface is not, which is XFCE today for Screenshot. Or no portal at
196
+ // all. The caller's branch is the same either way.
197
+ return 0;
198
+ } finally {
199
+ if (!busRef) await ref.release();
200
+ }
201
+ }
202
+
203
+ // --------------------------------------------------------------------------
204
+ // The Request helper
205
+ // --------------------------------------------------------------------------
206
+
207
+ /**
208
+ * Call a portal method that answers through a `Request`, with the
209
+ * subscription already in place before the call goes out.
210
+ *
211
+ * ```js
212
+ * const { response, results } = await portalRequest(ref, {
213
+ * iface: 'org.freedesktop.portal.FileChooser',
214
+ * member: 'OpenFile',
215
+ * parentWindow: 'x11:1a00007',
216
+ * title: 'Open',
217
+ * options: { multiple: true },
218
+ * });
219
+ * ```
220
+ *
221
+ * The default argument shape is FileChooser's, `(s parent_window, s title,
222
+ * a{sv} options)` — but that shape is FileChooser's alone. Every other portal
223
+ * that answers through a `Request` has its own leading arguments
224
+ * (`Screenshot.PickColor` has no title, `Print` carries a serial, …), so a
225
+ * caller states them, as `signature` and `args` together:
226
+ *
227
+ * ```js
228
+ * await portalRequest(ref, {
229
+ * iface: 'org.freedesktop.portal.Screenshot',
230
+ * member: 'PickColor',
231
+ * signature: 'sa{sv}',
232
+ * args: [parentWindow],
233
+ * });
234
+ * ```
235
+ *
236
+ * `signature` is the method's full argument signature and must end in
237
+ * `a{sv}` — the options dict is where `handle_token` rides, so a method
238
+ * without one cannot be Request-shaped. `args` is everything before it;
239
+ * the options dict itself stays in `options` and is appended here.
240
+ *
241
+ * Three things it owns, so that no caller reimplements them:
242
+ *
243
+ * - **Cancellation.** An `AbortSignal` calls `Request.Close()` on the same
244
+ * path. Per the XML that means **no `Response` is emitted**, so the promise
245
+ * has to be settled here rather than left waiting for one. The listener goes
246
+ * on **before the first await**, because `addEventListener('abort')` on a
247
+ * signal that has already fired never runs: an abort that lands while
248
+ * `AddMatch` or the initial call is still in flight would otherwise be
249
+ * dropped, leaving a promise nothing can settle and a dialog on screen with
250
+ * nobody listening — the exact leak this is here to prevent.
251
+ * - **Deadlines.** There is deliberately **no timeout on the answer** — a
252
+ * dialog can legitimately be open for an hour, which is the whole reason
253
+ * portals are request-shaped instead of plain calls. Only the initial method
254
+ * call gets a short one: a portal that does not hand back a handle in a few
255
+ * seconds is not there.
256
+ * - **A pre-0.9 portal** that returns an unpredictable path. Cheap insurance:
257
+ * re-subscribe on whatever came back.
258
+ *
259
+ * @param {{ bus: any, uniqueName: string }} busRef
260
+ * @returns {Promise<{ response: number, results: object }>}
261
+ */
262
+ export async function portalRequest(
263
+ busRef,
264
+ {
265
+ iface,
266
+ member,
267
+ parentWindow = '',
268
+ title = '',
269
+ signature,
270
+ args,
271
+ options = {},
272
+ signal,
273
+ },
274
+ ) {
275
+ // They travel as a pair: the signature says what the args are, and either
276
+ // half alone is a call that marshals wrong at the far end with no error
277
+ // anywhere near the mistake.
278
+ if ((signature === undefined) !== (args === undefined)) {
279
+ throw new Error(
280
+ `react-x11: portalRequest(${iface}.${member}) got ` +
281
+ `${signature === undefined ? '`args` without `signature`' : '`signature` without `args`'}` +
282
+ " — they describe each other, so pass both (e.g. signature: 'sa{sv}'," +
283
+ ' args: [parentWindow]), or neither for the FileChooser shape.',
284
+ );
285
+ }
286
+ if (signature === undefined) {
287
+ signature = 'ssa{sv}';
288
+ args = [parentWindow, title];
289
+ } else if (!signature.endsWith('a{sv}')) {
290
+ throw new Error(
291
+ `react-x11: portalRequest(${iface}.${member}) — the signature ` +
292
+ `'${signature}' does not end in the options dict (a{sv}). ` +
293
+ 'handle_token rides in that dict, so a method without one does not ' +
294
+ 'answer through a Request; call bus.invoke directly instead.',
295
+ );
296
+ }
297
+
298
+ const { bus, uniqueName } = busRef;
299
+ const token = await newToken();
300
+ const path = `${PORTAL_PATH}/request/${senderPath(uniqueName)}/${token}`;
301
+
302
+ if (signal?.aborted) throw signal.reason ?? new PortalCancelledError();
303
+
304
+ const subscribe = async (requestPath) => {
305
+ const sub = await bus.watch(
306
+ `type='signal',sender='${PORTAL_NAME}',` +
307
+ `interface='${REQUEST_IFACE}',member='Response',path='${requestPath}'`,
308
+ );
309
+ const key = bus.mangle(requestPath, REQUEST_IFACE, 'Response');
310
+ return { sub, key };
311
+ };
312
+
313
+ const closeRequest = (requestPath) =>
314
+ bus
315
+ .invoke({
316
+ destination: PORTAL_NAME,
317
+ path: requestPath,
318
+ interface: REQUEST_IFACE,
319
+ member: 'Close',
320
+ signature: '',
321
+ body: [],
322
+ })
323
+ .catch(() => {
324
+ // The request may already be gone — that is the outcome we wanted.
325
+ });
326
+
327
+ // The abort wiring, before anything that can suspend. `sent` is the guard on
328
+ // Close(): there is no request to close until the call has actually gone out,
329
+ // and the path the portal will answer on is the predictable one from here
330
+ // until a pre-0.9 portal says otherwise.
331
+ let sent = false;
332
+ let requestPath = path;
333
+ /** The path the abort actually closed, if it has fired. */
334
+ let closedPath = null;
335
+ let onAbort;
336
+ let aborted;
337
+ if (signal) {
338
+ aborted = new Promise((_, reject) => {
339
+ onAbort = () => {
340
+ if (sent) {
341
+ closedPath = requestPath;
342
+ closeRequest(requestPath);
343
+ }
344
+ reject(signal.reason ?? new PortalCancelledError());
345
+ };
346
+ signal.addEventListener('abort', onAbort, { once: true });
347
+ });
348
+ // Nothing awaits `aborted` until the races below, and an abort can land
349
+ // before then. Mark it handled so a rejection in that window is not an
350
+ // unhandled one; the races still see it.
351
+ aborted.catch(() => {});
352
+ }
353
+
354
+ // Subscribe FIRST. Everything else in this function is bookkeeping around
355
+ // the fact that this line comes before the invoke. It is not raced against
356
+ // the abort: a match rule half-added is a match rule leaked, so let it
357
+ // finish and let the `finally` below take it away again.
358
+ let { sub, key } = await subscribe(path);
359
+ const answered = new Promise((resolve) => {
360
+ // `bus.signals` emits the signal's argument array.
361
+ bus.signals.once(key, ([response, results]) =>
362
+ resolve({ response, results: results ?? {} }),
363
+ );
364
+ });
365
+
366
+ try {
367
+ // An abort during `AddMatch` above: nothing has been asked for yet, so
368
+ // there is nothing to Close — just leave, and let the `finally` unsubscribe.
369
+ if (signal?.aborted) throw signal.reason ?? new PortalCancelledError();
370
+
371
+ // `sent` flips *before* the await, not after: from the moment the call is
372
+ // on the wire the portal may already have a dialog up, and an abort in that
373
+ // window has to Close it even though no handle has come back yet.
374
+ sent = true;
375
+ const call = bus
376
+ .invoke(
377
+ {
378
+ destination: PORTAL_NAME,
379
+ path: PORTAL_PATH,
380
+ interface: iface,
381
+ member,
382
+ signature,
383
+ body: [...args, { ...options, handle_token: token }],
384
+ },
385
+ { timeout: 10_000 },
386
+ )
387
+ // Settled either way, so that an abort winning the race below does not
388
+ // leave this one rejecting into nobody's hands.
389
+ .then(
390
+ (handle) => ({ handle }),
391
+ (cause) => ({ cause }),
392
+ );
393
+
394
+ // A pre-0.9 portal that picks its own path is the one case where an abort
395
+ // that beat the reply closed the wrong one — and which path it really was
396
+ // is not knowable until the handle arrives, quite possibly after this
397
+ // function has already rejected.
398
+ call.then(({ handle }) => {
399
+ if (closedPath && typeof handle === 'string' && handle !== closedPath) {
400
+ closeRequest(handle);
401
+ }
402
+ });
403
+
404
+ const { handle, cause } = await Promise.race([call, aborted ?? call]);
405
+ if (cause)
406
+ throw new NoPortalError(`${iface}.${member} did not answer`, cause);
407
+
408
+ // Pre-0.9 portals chose their own path. Re-subscribe on the real one; the
409
+ // first subscription stays until the finally below, which is cheaper than
410
+ // getting the ordering wrong.
411
+ if (typeof handle === 'string' && handle !== path) {
412
+ requestPath = handle;
413
+ const moved = await subscribe(handle);
414
+ bus.signals.once(moved.key, ([response, results]) => {
415
+ bus.signals.emit(key, [response, results]);
416
+ });
417
+ sub = { remove: () => Promise.all([sub.remove(), moved.sub.remove()]) };
418
+ }
419
+
420
+ // `Close()` produces no Response, so the abort has to settle the race
421
+ // itself rather than wait for one that will never arrive.
422
+ return await Promise.race([answered, aborted ?? answered]);
423
+ } finally {
424
+ if (onAbort) signal.removeEventListener('abort', onAbort);
425
+ await sub.remove().catch(() => {});
426
+ }
427
+ }
428
+
429
+ /**
430
+ * A `parent_window` handle for the portal, or `''` when there is no window to
431
+ * name — which is legal and just means the dialog floats.
432
+ *
433
+ * Lowercase hex, no `0x`. Both shipping backends tolerate a prefix, which is
434
+ * precisely why it is easy to get wrong and never notice; Qt's parser answers
435
+ * 0 on failure with no error path, so a third backend would silently give an
436
+ * unparented, non-modal dialog rather than throw.
437
+ */
438
+ export function parentWindowHandle(xid) {
439
+ return typeof xid === 'number' && xid > 0 ? `x11:${xid.toString(16)}` : '';
440
+ }
441
+
442
+ /**
443
+ * `Variant`, from the one copy of the transport the bus layer loaded.
444
+ *
445
+ * Needed for the option types inference cannot produce — `a(sa(us))` for
446
+ * filters, `u` where a plain number would marshal as `i`.
447
+ */
448
+ export async function variant(signature, value) {
449
+ const dbus = await loadTransport();
450
+ return new dbus.Variant(signature, value);
451
+ }
452
+
453
+ /**
454
+ * A NUL-terminated byte array, which is how the FileChooser portal spells a
455
+ * path in its options (`current_folder`, `current_file`). A plain string there
456
+ * marshals as `s` and the backend ignores it — silently, which is the failure
457
+ * mode worth writing a helper to avoid.
458
+ */
459
+ export function pathBytes(value) {
460
+ return Buffer.from(`${value}\0`, 'utf8');
461
+ }
package/src/priority.js CHANGED
@@ -27,6 +27,32 @@ export function resolveUpdatePriority() {
27
27
  : DefaultEventPriority;
28
28
  }
29
29
 
30
+ // The renderer's `flushSyncWork`, registered by Reconciler.js when it builds
31
+ // the reconciler. The dispatcher needs it and cannot import the Reconciler
32
+ // back without a cycle, and there is only ever one reconciler per process.
33
+ let syncFlush = null;
34
+
35
+ export function setSyncFlush(fn) {
36
+ syncFlush = fn;
37
+ }
38
+
39
+ /**
40
+ * Land every discrete-priority update React is holding, now.
41
+ *
42
+ * `scheduleMicrotask` in the host config is what makes this necessary: a
43
+ * sync-lane update is committed in a microtask, so an event handler that has
44
+ * returned has *not* yet seen React's half of its own response. Painting
45
+ * before this ran would show the default action's half — the `:active` flip,
46
+ * the caret — and leave React's to the next frame, which is both a frame
47
+ * late and a second paint.
48
+ *
49
+ * A no-op when there is nothing pending, and when React is already rendering
50
+ * or committing (`flushSyncWork` checks its own execution context).
51
+ */
52
+ export function flushSyncWork() {
53
+ syncFlush?.();
54
+ }
55
+
30
56
  /** Run fn (an event handler batch) at the given update priority. */
31
57
  export function runWithPriority(priority, fn) {
32
58
  const previous = currentUpdatePriority;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `react-x11/refresh` — the runtime half of state-preserving hot reload.
3
+ * Run the app under the loader (`node --import react-x11/refresh/register`)
4
+ * and import from here only for the two seams below.
5
+ */
6
+
7
+ /** One applied hot-reload batch. */
8
+ export interface ReloadEvent {
9
+ /** Canonical URLs of the refresh-boundary modules that re-evaluated. */
10
+ urls: string[];
11
+ /** False when the batch left no component updates to apply. */
12
+ refreshed: boolean;
13
+ }
14
+
15
+ /**
16
+ * Called after each hot-reload batch has re-rendered the edited
17
+ * components in place. Returns an unsubscribe function.
18
+ */
19
+ export function onReload(listener: (event: ReloadEvent) => void): () => void;
20
+
21
+ /** Apply pending component updates now. Returns null if nothing was pending. */
22
+ export function performReactRefresh(): unknown;
23
+
24
+ /**
25
+ * Internal surface for the loader's injected prelude/footer — reached as
26
+ * the default export because only default bindings initialize
27
+ * synchronously inside a hot module. Not part of the public API.
28
+ */
29
+ declare const runtime: {
30
+ register(type: unknown, id: string): void;
31
+ createSignatureFunctionForTransform(): unknown;
32
+ moduleReady(
33
+ hot: unknown,
34
+ url: string,
35
+ exportsMap: Record<string, unknown> | null,
36
+ ): void;
37
+ performReactRefresh: typeof performReactRefresh;
38
+ onReload: typeof onReload;
39
+ };
40
+ export default runtime;
@@ -0,0 +1,122 @@
1
+ // react-x11/refresh — the runtime half of state-preserving hot reload
2
+ // (Fast Refresh). The loader half is ./loader.js; `react-x11/refresh/register`
3
+ // wires both up for `node --import`. See docs/ecosystem/dev-tooling.md.
4
+ //
5
+ // Import order is what this module exists to own:
6
+ //
7
+ // 1. injectIntoGlobalHook must patch the DevTools global hook before any
8
+ // renderer registers with it — so the renderer import below is dynamic,
9
+ // after the patch;
10
+ // 2. injectIntoDevTools hands the reconciler's internals (dev build:
11
+ // scheduleRefresh & friends) to the patched hook, which is how
12
+ // react-refresh learns to re-render mounted roots. It must run before
13
+ // the first commit or the root won't be tracked.
14
+ //
15
+ // A host app does not need to import this module at all: the loader injects
16
+ // an import of it into every hot module's prelude, and the entry is itself a
17
+ // hot module, so the patch always lands before the app's own imports
18
+ // evaluate React or the renderer. Importing it explicitly is only for the
19
+ // two exports — `onReload` and `performReactRefresh`.
20
+ //
21
+ // react-refresh/runtime lives in node_modules, outside the hot graph, so
22
+ // every reloaded module registers into this one runtime instance.
23
+ import RefreshRuntime from 'react-refresh/runtime';
24
+
25
+ RefreshRuntime.injectIntoGlobalHook(globalThis);
26
+
27
+ const ReactX11 = await import('../index.js');
28
+ // no argument: react-reconciler 0.33 takes none, and the renderer metadata
29
+ // comes from the host config in src/Reconciler.js
30
+ ReactX11.Renderer.injectIntoDevTools();
31
+
32
+ const listeners = new Set();
33
+
34
+ /**
35
+ * Called after a hot-reload batch has been applied: the edited components
36
+ * have re-rendered in place. The seam a dev tool sits on — log the reload,
37
+ * clear an error state, update a workbench UI. Returns an unsubscribe.
38
+ */
39
+ export function onReload(listener) {
40
+ if (typeof listener !== 'function') {
41
+ throw new TypeError(
42
+ `react-x11/refresh: onReload takes a function, got ${typeof listener}.`,
43
+ );
44
+ }
45
+ listeners.add(listener);
46
+ return () => listeners.delete(listener);
47
+ }
48
+
49
+ /** Manual seam: apply pending component updates now. null = nothing pending. */
50
+ export function performReactRefresh() {
51
+ return RefreshRuntime.performReactRefresh();
52
+ }
53
+
54
+ // A reload batch re-evaluates each module along the changed → accepting
55
+ // path as a separate dynamic import, and an editor save can fire the file
56
+ // watcher more than once — so the refresh is debounced. The delay is
57
+ // imperceptible next to the save itself; what it buys is one re-render and
58
+ // one onReload event per save instead of one per module.
59
+ const REFRESH_DEBOUNCE_MS = 30;
60
+ const pendingUrls = new Set();
61
+ let pendingTimer = null;
62
+
63
+ function flushRefresh() {
64
+ pendingTimer = null;
65
+ const urls = [...pendingUrls];
66
+ pendingUrls.clear();
67
+ const result = RefreshRuntime.performReactRefresh();
68
+ const event = { urls, refreshed: result !== null };
69
+ for (const listener of [...listeners]) {
70
+ try {
71
+ listener(event);
72
+ } catch (err) {
73
+ console.error('react-x11/refresh: onReload listener threw', err);
74
+ }
75
+ }
76
+ }
77
+
78
+ // The loader's injected footer calls this at the end of every hot module's
79
+ // evaluation, handing over the module's exported values (or null when the
80
+ // exports cannot all be named statically — re-exports, an anonymous
81
+ // default). A module whose exports are all components is a refresh
82
+ // boundary: it self-accepts, so an edit to it (or to anything below it
83
+ // with no boundary of its own) re-evaluates up to here and no further,
84
+ // and the re-evaluation schedules the refresh. Anything else stays
85
+ // un-accepted and the change propagates to the nearest boundary above.
86
+ function moduleReady(hot, url, exportsMap) {
87
+ const boundary =
88
+ exportsMap !== null &&
89
+ Object.values(exportsMap).every((value) =>
90
+ RefreshRuntime.isLikelyComponentType(value),
91
+ );
92
+ if (boundary) {
93
+ hot.accept();
94
+ } else {
95
+ // The hot context survives reloads, so an edit that adds a
96
+ // non-component export must also retract the acceptance the previous
97
+ // version registered — otherwise importers keep stale values forever.
98
+ // hot-module-replacement has no public un-accept, hence the field.
99
+ hot._selfAccepted = false;
100
+ }
101
+ const busted = /\?hmr=\d+$/.test(url);
102
+ if (boundary && busted) {
103
+ pendingUrls.add(url.replace(/\?hmr=\d+$/, ''));
104
+ if (pendingTimer) clearTimeout(pendingTimer);
105
+ pendingTimer = setTimeout(flushRefresh, REFRESH_DEBOUNCE_MS);
106
+ }
107
+ }
108
+
109
+ // The injected prelude and footer reach everything through this default
110
+ // export: inside a hot module, named imports are rewritten to live
111
+ // bindings that initialize in a microtask, and module-scope code runs
112
+ // before that — only the default binding is initialized synchronously.
113
+ export default {
114
+ register(type, id) {
115
+ RefreshRuntime.register(type, id);
116
+ },
117
+ createSignatureFunctionForTransform:
118
+ RefreshRuntime.createSignatureFunctionForTransform,
119
+ moduleReady,
120
+ performReactRefresh,
121
+ onReload,
122
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `react-x11/refresh/loader` — the loader half of state-preserving hot
3
+ * reload. `react-x11/refresh/register` calls registerRefresh() with the
4
+ * defaults; a tool that needs the seams writes its own --import module.
5
+ */
6
+
7
+ export interface RefreshOptions {
8
+ /** File extensions treated as hot modules. Default: ['.jsx']. */
9
+ extensions?: string[];
10
+ /**
11
+ * Keep additional modules out of the hot graph (identity that must
12
+ * survive a reload: contexts, stores). node_modules and react-x11's own
13
+ * sources are always excluded.
14
+ */
15
+ ignore?: (path: string) => boolean;
16
+ /**
17
+ * Only 'classic' is supported: the automatic runtime injects an import
18
+ * that breaks under the hot-module import rewrite. Passing anything
19
+ * else throws.
20
+ */
21
+ jsxRuntime?: 'classic';
22
+ /**
23
+ * Extra statements injected into every hot module, one statement per
24
+ * entry — an entry holding two statements or a newline throws.
25
+ */
26
+ prelude?: string[];
27
+ }
28
+
29
+ /** Register the loader + hot-module hooks. Once per process. */
30
+ export function registerRefresh(options?: RefreshOptions): Promise<void>;
31
+
32
+ /** The transform alone, for a tool hosting its own module hooks. */
33
+ export function createTransformer(options?: RefreshOptions): Promise<{
34
+ preludeLineCount: number;
35
+ matches(pathname: string): boolean;
36
+ transform(source: string, filename: string): { code: string };
37
+ }>;