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/bus.js ADDED
@@ -0,0 +1,545 @@
1
+ // The D-Bus floor: one connection per bus, shared by everything in the
2
+ // process, acquired lazily and never able to crash an app that has no bus.
3
+ //
4
+ // Everything the desktop wants from an app — a file dialog, the colour
5
+ // scheme, notifications, a global menu, a tray icon — is a D-Bus call, and
6
+ // each of those is a separate feature that will want a connection. This
7
+ // module exists so that they all get the *same* one.
8
+ //
9
+ // ## Why not just call `dbus.sessionBus()`
10
+ //
11
+ // Because `dbus-native`'s `sessionBus()` is a constructor in disguise: every
12
+ // call opens a new socket and gets its own unique name. Menu + tray + the
13
+ // app's own exported service would be three connections and three identities,
14
+ // and the desktop would see three half-applications. A D-Bus connection is
15
+ // *process* identity, so sharing is the design, not an optimisation.
16
+ //
17
+ // And because it throws. `createStream` (dbus-native index.js:32-37) throws
18
+ // `unknown bus address` synchronously when `DBUS_SESSION_BUS_ADDRESS` is
19
+ // unset — which is every environment react-x11 was designed for: a bare
20
+ // `startx`, a WM keybinding, an ssh session, a container, CI. That throw must
21
+ // never reach an app.
22
+ //
23
+ // ## The rule
24
+ //
25
+ // **Nothing here throws at import time, and nothing here crashes an app
26
+ // running where there is no session bus.** `sessionBus()` answers `null`; the
27
+ // caller turns its feature off. `required: true` is the escape hatch for an
28
+ // app whose whole purpose is the bus, and it is the only shape that rejects.
29
+ //
30
+ // ## Lifecycle: connect on first use, stay connected
31
+ //
32
+ // The ref count drives the socket's *event-loop hold*, not its existence:
33
+ //
34
+ // refs > 0 connected, `ref()`d — the process stays alive for it
35
+ // refs == 0 connected, `unref()`d — warm, costs one idle FD, holds nothing
36
+ //
37
+ // Closing at zero refs would be wrong for the consumers this actually has. A
38
+ // file dialog that mounts on click and unmounts on dismiss would tear the
39
+ // connection down and rebuild it every time — and every rebuild is a *new
40
+ // unique name*, with the daemon forgetting match rules and well-known names
41
+ // along with the old one. `closeBus()` is the way out for an app that wants
42
+ // the connection gone; it is never the automatic consequence of an unmount.
43
+
44
+ /** @typedef {'session'|'system'} BusKind */
45
+
46
+ const KINDS = ['session', 'system'];
47
+
48
+ /**
49
+ * There is no bus to be had, and this is why.
50
+ *
51
+ * `cause` is the real reason — no address, `ECONNREFUSED`, an auth rejection,
52
+ * `dbus-native` not installed. Deliberately not a second failure taxonomy: a
53
+ * coarse enum layered on top would have to stay true across platforms, npm
54
+ * layouts and transport versions, and in practice the reason gets logged far
55
+ * more often than it gets branched on.
56
+ */
57
+ export class BusUnavailableError extends Error {
58
+ /**
59
+ * @param {BusKind} kind
60
+ * @param {unknown} [cause]
61
+ */
62
+ constructor(kind, cause) {
63
+ super(
64
+ `react-x11: no ${kind} bus available` +
65
+ (cause instanceof Error ? ` (${cause.message})` : ''),
66
+ { cause },
67
+ );
68
+ this.name = 'BusUnavailableError';
69
+ /** @type {BusKind} which bus was asked for */
70
+ this.kind = kind;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Per-kind state. One entry, replaced wholesale on every generation — a dead
76
+ * connection is never resurrected, because a new socket is a new unique name
77
+ * and callers holding the old one are talking to nobody.
78
+ */
79
+ const state = {
80
+ session: newState('session', 0),
81
+ system: newState('system', 0),
82
+ };
83
+
84
+ function newState(kind, generation) {
85
+ return {
86
+ kind,
87
+ /** Bumped per connection attempt; a ref remembers which one it belongs to. */
88
+ generation,
89
+ /** The in-flight connect, shared by concurrent acquisitions. */
90
+ connecting: null,
91
+ bus: null,
92
+ uniqueName: null,
93
+ /** Live refs on this generation. */
94
+ refs: 0,
95
+ /** Set when the connection died under us; the generation is retired. */
96
+ dead: false,
97
+ };
98
+ }
99
+
100
+ // --------------------------------------------------------------------------
101
+ // The transport
102
+ // --------------------------------------------------------------------------
103
+
104
+ /**
105
+ * `dbus-native`, imported on the first acquisition and never at module load.
106
+ *
107
+ * It is an `optionalDependency`, so on Node 20 — where its own
108
+ * `engines: ">=22.12.0"` makes npm skip it — this import fails, and "no
109
+ * transport installed" collapses into the "no bus here" path rather than
110
+ * adding a mode.
111
+ */
112
+ let transport = null;
113
+
114
+ /**
115
+ * Not public. `portal.js` needs `Variant` from the same copy of the transport
116
+ * to build `a{sv}` option dicts, and there is only ever one copy — importing
117
+ * `dbus-native` a second time from another module would be a second resolve
118
+ * of a package that may not be installed.
119
+ */
120
+ export async function loadTransport() {
121
+ if (transport) return transport;
122
+ const mod = await import('dbus-native');
123
+ // CJS with an `exports` map: named exports do not reliably survive
124
+ // cjs-module-lexer, so take the default and destructure from it.
125
+ transport = mod.default ?? mod;
126
+ return transport;
127
+ }
128
+
129
+ /**
130
+ * The address seam is D-Bus's own — `DBUS_SESSION_BUS_ADDRESS` — plus the
131
+ * `$XDG_RUNTIME_DIR/bus` fallback `dbus-native` does not have yet
132
+ * (sidorares/dbus-native#389). Delete the fallback when that lands.
133
+ *
134
+ * Deliberately no `busAddress` parameter on the public API: a per-call
135
+ * address is incoherent under sharing. Two callers, two addresses, one
136
+ * socket — which would win? Tests use this same seam.
137
+ *
138
+ * Not public, but exported for atspi.js, whose one-shot discovery probe
139
+ * dials its own short-lived connection rather than the shared one — see
140
+ * the note in `accessibilityBusAddress`.
141
+ */
142
+ export function addressFor(kind) {
143
+ if (kind === 'system') {
144
+ return (
145
+ process.env.DBUS_SYSTEM_BUS_ADDRESS ||
146
+ 'unix:path=/var/run/dbus/system_bus_socket'
147
+ );
148
+ }
149
+ if (process.env.DBUS_SESSION_BUS_ADDRESS) {
150
+ return process.env.DBUS_SESSION_BUS_ADDRESS;
151
+ }
152
+ // macOS advertises the session bus through launchd, which dbus-native
153
+ // already falls back to on its own. Leave it undefined and let it.
154
+ if (process.platform === 'darwin') return undefined;
155
+ const runtimeDir = process.env.XDG_RUNTIME_DIR;
156
+ return runtimeDir ? `unix:path=${runtimeDir}/bus` : undefined;
157
+ }
158
+
159
+ function noAddressError(kind) {
160
+ return new Error(
161
+ `react-x11: no ${kind} bus address. ` +
162
+ (kind === 'session'
163
+ ? '$DBUS_SESSION_BUS_ADDRESS is unset and $XDG_RUNTIME_DIR is not ' +
164
+ 'set either, which is normal over ssh, under a bare startx and in ' +
165
+ 'most containers.'
166
+ : '$DBUS_SYSTEM_BUS_ADDRESS is unset and there is no default.'),
167
+ );
168
+ }
169
+
170
+ function noTransportError(cause) {
171
+ return new Error(
172
+ 'react-x11: the D-Bus transport is not installed. dbus-native is an ' +
173
+ 'optional dependency and npm skips it on Node < 22.12 (this process ' +
174
+ `is ${process.version}). Install it explicitly — npm i dbus-native — ` +
175
+ 'or run on Node >= 22.12.',
176
+ { cause },
177
+ );
178
+ }
179
+
180
+ // --------------------------------------------------------------------------
181
+ // Connecting
182
+ // --------------------------------------------------------------------------
183
+
184
+ /** Swallow whatever a doomed connection has left to say. */
185
+ function silence(bus) {
186
+ bus.connection.removeAllListeners('error');
187
+ bus.connection.on('error', () => {});
188
+ }
189
+
190
+ async function discard(bus) {
191
+ silence(bus);
192
+ try {
193
+ await bus.close();
194
+ } catch {
195
+ // Already gone. The cause we are on our way to reporting is the
196
+ // interesting one, not this.
197
+ }
198
+ }
199
+
200
+ /**
201
+ * One connect attempt: dial, wait for the handshake, wait for the unique
202
+ * name, and take ownership of the connection's `'error'` channel.
203
+ *
204
+ * Resolves with `{ bus, uniqueName }` or rejects with a `BusUnavailableError`
205
+ * carrying the real reason. Never resolves before `bus.name` is set.
206
+ */
207
+ async function connect(kind, generation) {
208
+ const fail = (cause) => {
209
+ throw new BusUnavailableError(kind, cause);
210
+ };
211
+
212
+ let dbus;
213
+ try {
214
+ dbus = await loadTransport();
215
+ } catch (cause) {
216
+ return fail(noTransportError(cause));
217
+ }
218
+
219
+ const busAddress = addressFor(kind);
220
+ if (!busAddress && process.platform !== 'darwin') {
221
+ return fail(noAddressError(kind));
222
+ }
223
+
224
+ let bus;
225
+ try {
226
+ // Reconnection stays off, deliberately: a new socket is a new unique
227
+ // name, and the daemon forgets names and match rules along with the old
228
+ // one. The replacement is a new generation, which is explicit.
229
+ bus = dbus.createClient({ busAddress });
230
+ } catch (cause) {
231
+ // The synchronous throw this whole module exists to absorb.
232
+ return fail(cause);
233
+ }
234
+
235
+ const conn = bus.connection;
236
+
237
+ // **This layer owns the connection's one 'error' listener.** dbus-native
238
+ // deliberately attaches none — an unlistened 'error' crashes the process,
239
+ // which is the right contract for a caller-owned connection and the wrong
240
+ // one for a shared one. Consumers never attach their own.
241
+ let onConnect, onClose, onError;
242
+ try {
243
+ await new Promise((resolve, reject) => {
244
+ onConnect = () => resolve();
245
+ onClose = (cause) =>
246
+ reject(cause ?? new Error(`react-x11: the ${kind} bus closed`));
247
+ onError = (err) => reject(err);
248
+ conn.once('connect', onConnect);
249
+ conn.once('close', onClose);
250
+ conn.on('error', onError);
251
+ });
252
+ } catch (cause) {
253
+ await discard(bus);
254
+ return fail(cause);
255
+ } finally {
256
+ conn.removeListener('connect', onConnect);
257
+ conn.removeListener('close', onClose);
258
+ conn.removeListener('error', onError);
259
+ }
260
+
261
+ // Name-ready. `bus.name` is assigned in the Hello reply callback
262
+ // (dbus-native lib/bus.js:1002-1009), so it is *not* set when the
263
+ // constructor returns. One org.freedesktop.DBus round trip is an ordering
264
+ // barrier: replies arrive behind the Hello dbus-native sent first. Without
265
+ // it, the first consumer to derive state from the app's own identity — a
266
+ // portal Request path, say — races Hello and builds a path with
267
+ // `undefined` in it.
268
+ try {
269
+ await bus.listNames();
270
+ } catch (cause) {
271
+ await discard(bus);
272
+ return fail(cause);
273
+ }
274
+ if (bus.name == null) {
275
+ await discard(bus);
276
+ return fail(
277
+ new Error(
278
+ `react-x11: the ${kind} bus answered a call but never assigned a ` +
279
+ 'unique name — the peer is not a message bus.',
280
+ ),
281
+ );
282
+ }
283
+
284
+ // Live. From here a failure is a *death* rather than a failed connect, so
285
+ // both channels retarget onto the generation.
286
+ conn.on('error', () => bury(kind, generation));
287
+ conn.once('close', () => bury(kind, generation));
288
+
289
+ return { bus, uniqueName: bus.name };
290
+ }
291
+
292
+ /**
293
+ * The connection died. Retire the generation rather than resurrecting it:
294
+ * refs on it fail their calls with the transport's `ConnectionClosedError`
295
+ * and `release()` harmlessly, mounted hooks move to `'closed'`, and the next
296
+ * acquisition dials a fresh socket with a fresh unique name.
297
+ */
298
+ function bury(kind, generation) {
299
+ const s = state[kind];
300
+ if (s.generation !== generation || s.dead) return;
301
+ s.dead = true;
302
+ s.refs = 0;
303
+ s.connecting = null;
304
+ notify(kind);
305
+ }
306
+
307
+ // --------------------------------------------------------------------------
308
+ // The event-loop hold
309
+ // --------------------------------------------------------------------------
310
+
311
+ /**
312
+ * `ref()`/`unref()` the live socket, so the ref count decides whether the
313
+ * connection *holds the process open* without deciding whether it exists.
314
+ *
315
+ * `bus.connection.stream` is a `net.Socket` on every path this module dials
316
+ * (dbus-native index.js:169, :30-31). It is reaching into an internal, hence
317
+ * the guards: a caller-supplied stream need not be a socket. A missing `ref`
318
+ * costs nothing worse than a process that exits a beat earlier than it should
319
+ * have, which the exit test would catch.
320
+ */
321
+ function hold(s, held) {
322
+ const stream = s.bus?.connection?.stream;
323
+ if (!stream) return;
324
+ if (held) stream.ref?.();
325
+ else stream.unref?.();
326
+ }
327
+
328
+ // --------------------------------------------------------------------------
329
+ // Acquisition
330
+ // --------------------------------------------------------------------------
331
+
332
+ /**
333
+ * @param {BusKind} kind
334
+ * @param {{ required?: boolean }} [opts]
335
+ * @returns {Promise<BusRefShape | null>}
336
+ */
337
+ async function acquire(kind, opts = {}) {
338
+ let s = state[kind];
339
+
340
+ // A dead generation is retired: a fresh acquisition starts a new one rather
341
+ // than handing out a corpse.
342
+ if (s.dead) {
343
+ s = state[kind] = newState(kind, s.generation + 1);
344
+ }
345
+
346
+ if (!s.bus) {
347
+ // Concurrent acquisitions share one connect attempt.
348
+ if (!s.connecting) {
349
+ const generation = s.generation;
350
+ const attempt = connect(kind, generation).then((result) => {
351
+ // A `closeBus()` that landed mid-connect wins: drop what we dialled.
352
+ if (state[kind] !== s) {
353
+ discard(result.bus);
354
+ throw new BusUnavailableError(
355
+ kind,
356
+ new Error(`react-x11: the ${kind} bus was closed while connecting`),
357
+ );
358
+ }
359
+ s.bus = result.bus;
360
+ s.uniqueName = result.uniqueName;
361
+ // Nothing holds a ref yet, so the socket must not keep the process
362
+ // alive on its own.
363
+ hold(s, false);
364
+ notify(kind);
365
+ return result;
366
+ });
367
+ // **Failure is not cached.** A failed attempt answers its waiters and is
368
+ // forgotten: a session bus can genuinely appear later — the moment
369
+ // something creates $XDG_RUNTIME_DIR/bus — feature probes happen once
370
+ // per feature init so the retry costs nothing, and a transient failure
371
+ // must never become a process-lifetime false negative.
372
+ s.connecting = attempt;
373
+ attempt
374
+ .catch(() => {})
375
+ .then(() => {
376
+ if (s.connecting === attempt) s.connecting = null;
377
+ });
378
+ }
379
+ try {
380
+ await s.connecting;
381
+ } catch (cause) {
382
+ if (opts.required) throw cause;
383
+ return null;
384
+ }
385
+ // `closeBus()` can land between that resolving and this line.
386
+ if (state[kind] !== s || !s.bus) return acquire(kind, opts);
387
+ }
388
+
389
+ return makeRef(s);
390
+ }
391
+
392
+ /** @typedef {{ bus: any, uniqueName: string, release(): Promise<void> }} BusRefShape */
393
+
394
+ function makeRef(s) {
395
+ const generation = s.generation;
396
+ // Captured, not read through the state: a ref's `bus` must stay the object
397
+ // it was handed even after the generation is retired, so that a call on it
398
+ // fails with ConnectionClosedError rather than on `null`.
399
+ const bus = s.bus;
400
+ const uniqueName = s.uniqueName;
401
+ let live = true;
402
+
403
+ // No `notify()` here or in `release()`: the ref count is not part of any
404
+ // status a watcher renders, and waking every mounted hook on every
405
+ // acquire/release would be render churn with nothing behind it.
406
+ if (s.refs++ === 0) hold(s, true);
407
+
408
+ const ref = {
409
+ bus,
410
+ uniqueName,
411
+ /**
412
+ * Drop this consumer's reference. Idempotent *per ref*, so `finally`
413
+ * blocks and `asyncDispose` compose without double-decrement bugs.
414
+ *
415
+ * It does not close anything. `release()` is the only lifecycle verb a
416
+ * ref holder has, deliberately: `ref.bus.close()` would tear the
417
+ * connection out from under the tray, the menu and the app's own service.
418
+ */
419
+ async release() {
420
+ if (!live) return;
421
+ live = false;
422
+ // A ref on a retired generation decrements nothing — `bury` and
423
+ // `closeBus` already zeroed the count, and this ref's slot went with it.
424
+ if (state[s.kind] !== s || s.generation !== generation || s.dead) return;
425
+ if (--s.refs === 0) hold(s, false);
426
+ },
427
+ };
428
+ ref[Symbol.asyncDispose] = () => ref.release();
429
+ return ref;
430
+ }
431
+
432
+ /**
433
+ * A shared session bus, or `null`.
434
+ *
435
+ * ```js
436
+ * const ref = await sessionBus();
437
+ * if (!ref) return; // no bus here; turn the feature off
438
+ * const player = await ref.bus.proxy('org.mpris.MediaPlayer2.vlc', '/org/mpris/MediaPlayer2');
439
+ * await ref.release();
440
+ * ```
441
+ *
442
+ * Never rejects — unless `required: true` asks it to, which converts
443
+ * unavailability into a `BusUnavailableError` carrying the reason. `null` is
444
+ * right for feature-probing plumbing, and hides *why* from an app whose whole
445
+ * purpose is the bus.
446
+ *
447
+ * @param {{ required?: boolean }} [opts]
448
+ */
449
+ export function sessionBus(opts) {
450
+ return acquire('session', opts);
451
+ }
452
+
453
+ /**
454
+ * A shared system bus, or `null`. Hardware, networking, logind — read-mostly
455
+ * for an application. Same contract as {@link sessionBus}.
456
+ *
457
+ * @param {{ required?: boolean }} [opts]
458
+ */
459
+ export function systemBus(opts) {
460
+ return acquire('system', opts);
461
+ }
462
+
463
+ /**
464
+ * Close the shared connection outright. Rare, explicit, never automatic.
465
+ *
466
+ * This is the app-level decision a consumer must not make for its siblings.
467
+ * Outstanding refs are retired with the generation: their calls fail with
468
+ * `ConnectionClosedError` and `release()` still succeeds.
469
+ *
470
+ * @param {BusKind} kind
471
+ */
472
+ export async function closeBus(kind) {
473
+ if (!KINDS.includes(kind)) {
474
+ throw new Error(
475
+ `react-x11: closeBus(${JSON.stringify(kind)}) — expected ` +
476
+ `${KINDS.map((k) => JSON.stringify(k)).join(' or ')}.`,
477
+ );
478
+ }
479
+ const s = state[kind];
480
+ const bus = s.bus;
481
+ // **The teardown is itself a use of the connection.** At zero refs the
482
+ // socket is `unref()`d, so `bus.close()` — which resolves on the socket's
483
+ // own 'close' event — has nothing holding the event loop open, and a
484
+ // process with no other work drains before the event arrives: the promise
485
+ // never settles. Re-ref for the duration, and let the close destroy it.
486
+ hold(s, true);
487
+ // Swap the state first, so anything awaiting the old generation sees it
488
+ // retired rather than racing the socket teardown.
489
+ state[kind] = newState(kind, s.generation + 1);
490
+ notify(kind);
491
+ if (!bus) return;
492
+ silence(bus);
493
+ await bus.close();
494
+ }
495
+
496
+ // --------------------------------------------------------------------------
497
+ // Subscription, for the hooks
498
+ // --------------------------------------------------------------------------
499
+
500
+ const watchers = { session: new Set(), system: new Set() };
501
+
502
+ /**
503
+ * Re-render when a bus's status changes. Not public: `useSessionBus()` is the
504
+ * public shape, and a consumer reaching for the imperative API does not want
505
+ * a store.
506
+ */
507
+ export function watchBus(kind, onChange) {
508
+ watchers[kind].add(onChange);
509
+ return () => watchers[kind].delete(onChange);
510
+ }
511
+
512
+ function notify(kind) {
513
+ for (const fn of [...watchers[kind]]) fn();
514
+ }
515
+
516
+ /** Whether the generation a ref belongs to has been retired. Not public. */
517
+ export function busGeneration(kind) {
518
+ return state[kind].generation;
519
+ }
520
+
521
+ /** Whether the live generation for `kind` has been buried. Not public. */
522
+ export function busIsDead(kind) {
523
+ return state[kind].dead;
524
+ }
525
+
526
+ /**
527
+ * Live refs on the current generation. Not public — it exists so a test can
528
+ * assert that a *subscription* did not leave one behind, since a held ref is
529
+ * a `ref()`d socket and an app that never exits.
530
+ */
531
+ export function busRefs(kind) {
532
+ return state[kind].refs;
533
+ }
534
+
535
+ /**
536
+ * Test seam, not public: forget every connection without closing anything.
537
+ * `withBus()` hard-closes first and then calls this, so that no match rule or
538
+ * exported object survives into the next test.
539
+ */
540
+ export function _resetBusState() {
541
+ for (const kind of KINDS) {
542
+ state[kind] = newState(kind, state[kind].generation + 1);
543
+ notify(kind);
544
+ }
545
+ }
@@ -0,0 +1,136 @@
1
+ // `useSessionBus()` / `useSystemBus()` — the surface almost everyone uses.
2
+ //
3
+ // ## No provider, and why that is not the trick `useApp()` uses
4
+ //
5
+ // The requirement is the same: a component that talks to the desktop works in
6
+ // any tree with *nothing for the app author to wrap*. `useApp()` gets there
7
+ // with a provider `Reconciler.js` injects, because the X connection is
8
+ // genuinely per-tree — `createRoot()` opens one per root and a process can
9
+ // drive several.
10
+ //
11
+ // A D-Bus connection is **process** identity. Every consumer shares one socket
12
+ // and one unique name on purpose, so that an app's exported service, its menu
13
+ // and its tray are visibly one application. There is therefore nothing to
14
+ // provide: these hooks read the module-global in `bus.js` and manage their own
15
+ // acquire/release. Putting a process-global on a tree-scoped context would
16
+ // assert a scoping relationship that does not exist — two roots on two X
17
+ // displays share one session bus.
18
+ //
19
+ // ## The terse path has to be correct on its own
20
+ //
21
+ // const { bus } = useSessionBus();
22
+ // if (!bus) return null; // right, with no reference to status
23
+ //
24
+ // If reading `status` were *required* to avoid a bug, the shape would be
25
+ // wrong. `status` and `cause` are there for the cases that need more —
26
+ // diagnostics ("why is my tray icon missing") and the app-author persona —
27
+ // not as a step every consumer has to remember.
28
+
29
+ import { useCallback, useEffect, useMemo, useState } from 'react';
30
+
31
+ import {
32
+ busGeneration,
33
+ busIsDead,
34
+ sessionBus,
35
+ systemBus,
36
+ watchBus,
37
+ } from './bus.js';
38
+
39
+ /** @typedef {'connecting'|'ready'|'unavailable'|'closed'} BusStatus */
40
+
41
+ const CONNECTING = { bus: null, uniqueName: null, status: 'connecting' };
42
+
43
+ function useBus(kind, acquire) {
44
+ const [attempt, setAttempt] = useState(0);
45
+ // The attempt each answer belongs to, so a `retry()` shows 'connecting'
46
+ // immediately instead of leaving the stale answer up until the new dial
47
+ // settles — and without an extra render to reset it.
48
+ const [answer, setAnswer] = useState({ attempt: -1, handle: CONNECTING });
49
+
50
+ // The way back from `'closed'`. Deliberately not automatic: silently
51
+ // re-acquiring would hand the consumer a fresh `bus` under the same
52
+ // variable and hide the unique-name change that disabling reconnect exists
53
+ // to expose. A feature-level hook that knows how to rebuild its bus-side
54
+ // state can call this; a generic one cannot.
55
+ const retry = useCallback(() => setAttempt((n) => n + 1), []);
56
+
57
+ useEffect(() => {
58
+ let live = true;
59
+ let acquired = null;
60
+ let acquiredGeneration = null;
61
+ let unwatch = null;
62
+ const settle = (handle) => setAnswer({ attempt, handle });
63
+
64
+ // `required: true` purely to obtain the reason. The imperative default
65
+ // stays never-rejecting; the hook is that option's first consumer,
66
+ // converting the rejection into rendering state.
67
+ acquire({ required: true }).then(
68
+ (ref) => {
69
+ if (!live) return void ref.release();
70
+ acquired = ref;
71
+ acquiredGeneration = busGeneration(kind);
72
+ settle({
73
+ bus: ref.bus,
74
+ uniqueName: ref.uniqueName,
75
+ status: 'ready',
76
+ });
77
+ // The connection can die under a live ref. When it does the
78
+ // generation is retired, which is what distinguishes "the daemon went
79
+ // away" from "there was never a bus here".
80
+ unwatch = watchBus(kind, () => {
81
+ if (!live) return;
82
+ if (!busIsDead(kind) && busGeneration(kind) === acquiredGeneration) {
83
+ return;
84
+ }
85
+ settle({ bus: null, uniqueName: null, status: 'closed' });
86
+ });
87
+ },
88
+ (cause) => {
89
+ if (!live) return;
90
+ settle({ bus: null, uniqueName: null, status: 'unavailable', cause });
91
+ },
92
+ );
93
+
94
+ return () => {
95
+ live = false;
96
+ unwatch?.();
97
+ acquired?.release();
98
+ };
99
+ }, [kind, acquire, attempt]);
100
+
101
+ const handle = answer.attempt === attempt ? answer.handle : CONNECTING;
102
+ // Memoized so the handle is stable between renders and safe in a dependency
103
+ // array — a fresh object every render would restart any effect keyed on it.
104
+ return useMemo(() => ({ ...handle, retry }), [handle, retry]);
105
+ }
106
+
107
+ /**
108
+ * The session bus, as rendering state.
109
+ *
110
+ * ```jsx
111
+ * function ThemeWatcher() {
112
+ * const { bus } = useSessionBus();
113
+ * if (!bus) return null; // no bus here: no desktop theme to read
114
+ * ...
115
+ * }
116
+ * ```
117
+ *
118
+ * | `status` | |
119
+ * | -------------- | ------------------------------------------------------ |
120
+ * | `'connecting'` | the handshake is in flight — also the first render |
121
+ * | `'ready'` | `bus` and `uniqueName` are set |
122
+ * | `'unavailable'` | no daemon, socket or permission here; `cause` says why |
123
+ * | `'closed'` | the connection died; call `retry()` to dial a new one |
124
+ *
125
+ * **`'unavailable'` is a snapshot, not a verdict.** Failure is not cached, so
126
+ * a session bus that appears later will be found — do not permanently disable
127
+ * a feature on seeing it.
128
+ */
129
+ export function useSessionBus() {
130
+ return useBus('session', sessionBus);
131
+ }
132
+
133
+ /** The system bus, as rendering state. Same shape as {@link useSessionBus}. */
134
+ export function useSystemBus() {
135
+ return useBus('system', systemBus);
136
+ }