react-x11 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. package/src/scene3d.js +0 -683
@@ -0,0 +1,748 @@
1
+ // `org.freedesktop.Application`: being the app that a `myapp://…` link opens.
2
+ //
3
+ // The concrete flow this exists for: the app opens the system browser for an
4
+ // OAuth login, the provider redirects to `com.example.myapp://auth?code=…`,
5
+ // the desktop routes that URI back to the **already-running** app, and the
6
+ // app's window comes to the front with the code in hand.
7
+ //
8
+ // Four things have to be true and this module owns two of them — dispatch and
9
+ // delivery. Registration (a `.desktop` file claiming
10
+ // `x-scheme-handler/com.example.myapp`) is an install step rather than runtime
11
+ // code and is documented, not executed; the raise is `activate.js`, because it
12
+ // needs an X connection and a window id and this file deliberately has
13
+ // neither.
14
+ //
15
+ // ## Two dispatch paths, and why an app must survive both
16
+ //
17
+ // The desktop entry spec makes D-Bus activation sound like the whole story —
18
+ // `DBusActivatable=true` and implementations "should ignore the `Exec` key".
19
+ // *Should*, and the most common opener does not.
20
+ //
21
+ // - **GIO honours it.** `g_desktop_app_info_launch_uris_with_dbus` calls
22
+ // `Open`/`Activate` on the app's well-known name with a `platform_data`
23
+ // dict. `gio open`, and most of GNOME, take this path.
24
+ // - **`xdg-open`'s generic fallback does not.** Its
25
+ // `open_generic_xdg_x_scheme_handler` resolves the handler with `xdg-mime
26
+ // query default`, reads the `Exec` key and runs it — `DBusActivatable` is
27
+ // never read on that path. Chromium shells out to `xdg-open`, and `xdg-open`
28
+ // takes the generic branch on every desktop it cannot identify, which is
29
+ // every WM-only session: react-x11's own persona.
30
+ //
31
+ // So **a second copy of the app gets spawned with the URI in `argv`, and it
32
+ // has to hand that URI to the first copy and exit.** An app that only exports
33
+ // the interface opens a second window on half of all desktops. Both halves are
34
+ // `registerApplication()`, which is why it answers `role` rather than a
35
+ // boolean.
36
+ //
37
+ // ## The launch call arrives before the UI exists
38
+ //
39
+ // On the D-Bus path the bus *starts the process* because someone called
40
+ // `Open()`, and that call is outstanding while the app boots. Two consequences
41
+ // shape the code below:
42
+ //
43
+ // - registration happens **before `createRoot`**, not inside a component, or
44
+ // the launching call is answered with an unknown-method error;
45
+ // - the reply must not wait for the UI. `Open` is answered on the turn it
46
+ // arrives, the URIs are buffered, and they replay to the first handler that
47
+ // attaches. "Reply when the window is ready" is a design that gets slower
48
+ // until it breaks.
49
+ //
50
+ // ## What this file may not import
51
+ //
52
+ // No `react`, no X11. The bus half of this feature is genuinely orthogonal —
53
+ // it would work for any Node GUI toolkit — and it is written to that seam so
54
+ // that extracting it later is a move rather than a rewrite. What keeps it
55
+ // *here* for now is the connection: `RequestName` lands on one socket, and the
56
+ // app's name, its global menu and its portal Request paths have to be the same
57
+ // identity on the same connection (see docs/dbus.md).
58
+ //
59
+ // See docs/uri-schemes.md. Issue #173.
60
+
61
+ import { loadTransport, sessionBus } from './bus.js';
62
+ import { parseLaunchTime } from './startup.js';
63
+
64
+ /** The standard interface a D-Bus-activatable application exports. */
65
+ export const APPLICATION_IFACE = 'org.freedesktop.Application';
66
+
67
+ /**
68
+ * `RequestName` flags and replies, from the D-Bus specification.
69
+ *
70
+ * `DO_NOT_QUEUE` is what makes single-instance detection one round trip: with
71
+ * it, "somebody else has this name" comes back as a reply code instead of
72
+ * silently parking us in a queue behind them.
73
+ */
74
+ const DO_NOT_QUEUE = 0x4;
75
+ const PRIMARY_OWNER = 1;
76
+ const EXISTS = 3;
77
+ const ALREADY_OWNER = 4;
78
+
79
+ /**
80
+ * How long the *other* instance gets to accept a forwarded URI.
81
+ *
82
+ * Short and explicit, because dbus-native's default is 25 s and its reply
83
+ * timer is not unref'd — a process whose only remaining job is to exit would
84
+ * otherwise sit there for half a minute looking hung.
85
+ */
86
+ const FORWARD_TIMEOUT = 5000;
87
+
88
+ /**
89
+ * Schemes an app may not claim through this API.
90
+ *
91
+ * Being the default browser or the default file handler is a different feature
92
+ * with a different threat model, and neither is what a deep link is for. The
93
+ * list is short on purpose: `mailto:` or `ftp:` are things an app might
94
+ * legitimately *be*, and refusing them would be this module inventing policy.
95
+ */
96
+ const RESERVED_SCHEMES = new Set(['http', 'https', 'file']);
97
+
98
+ /** RFC 3986: `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`. */
99
+ const SCHEME_RE = /^[A-Za-z][A-Za-z0-9+.-]*$/;
100
+
101
+ /** A D-Bus well-known bus name: two or more dot-separated elements. */
102
+ const BUS_NAME_RE = /^[A-Za-z_-][A-Za-z0-9_-]*(\.[A-Za-z_-][A-Za-z0-9_-]*)+$/;
103
+
104
+ /** Anything that starts like an absolute URI. */
105
+ const HAS_SCHEME_RE = /^([A-Za-z][A-Za-z0-9+.-]*):/;
106
+
107
+ // --------------------------------------------------------------------------
108
+ // Derivations and validation
109
+ // --------------------------------------------------------------------------
110
+
111
+ /**
112
+ * The object path the spec says an app id has, which is not a choice:
113
+ *
114
+ * > Starting with the well-known D-Bus name of the application, change all
115
+ * > dots to slashes, prefix a slash, and if a dash is found, convert it to an
116
+ * > underscore.
117
+ *
118
+ * `org.example.FooViewer` → `/org/example/FooViewer`;
119
+ * `com.example.my-app` → `/com/example/my_app`. The dash rule is the one
120
+ * everybody misses, and it fails as "the desktop launched us and then nothing
121
+ * happened" rather than as an error — a dash is legal in a bus name and
122
+ * illegal in an object path, so the call goes to a path we do not serve.
123
+ */
124
+ export function objectPathForAppId(appId) {
125
+ return `/${appId}`.replaceAll('.', '/').replaceAll('-', '_');
126
+ }
127
+
128
+ /**
129
+ * `appId` is three things at once and the spec ties them together: the
130
+ * well-known bus name, the `.desktop` file's basename, and — by convention and
131
+ * by RFC 8252 §7.1 — the URI scheme. So it is checked as the strictest of
132
+ * them, a bus name.
133
+ *
134
+ * This **throws**, and that is not a hole in the never-rejects rule: a
135
+ * malformed app id is a mistake in the source, not a fact about the machine.
136
+ * Answering `null` would hide it on every box without a session bus and
137
+ * surface it only on the developer's, which is the worst place for a bug to
138
+ * be discovered.
139
+ */
140
+ function checkAppId(appId) {
141
+ if (typeof appId !== 'string' || !BUS_NAME_RE.test(appId)) {
142
+ throw new Error(
143
+ `react-x11: registerApplication({ appId: ${JSON.stringify(appId)} }) — ` +
144
+ 'the app id is the well-known D-Bus name, the .desktop file name and ' +
145
+ 'the URI scheme all at once, so it must be a valid bus name: two or ' +
146
+ 'more dot-separated elements of [A-Za-z_-][A-Za-z0-9_-]*, e.g. ' +
147
+ '"com.example.myapp".',
148
+ );
149
+ }
150
+ if (appId.length > 255) {
151
+ throw new Error(
152
+ `react-x11: registerApplication — the app id is ${appId.length} ` +
153
+ 'characters; D-Bus caps a bus name at 255.',
154
+ );
155
+ }
156
+ return appId;
157
+ }
158
+
159
+ /** Same contract as {@link checkAppId}: a bad scheme is a source mistake. */
160
+ function checkSchemes(schemes) {
161
+ if (schemes === undefined) return null;
162
+ if (!Array.isArray(schemes)) {
163
+ throw new Error(
164
+ 'react-x11: registerApplication({ schemes }) — expected an array of ' +
165
+ 'scheme names, e.g. ["com.example.myapp"].',
166
+ );
167
+ }
168
+ for (const scheme of schemes) {
169
+ if (typeof scheme !== 'string' || !SCHEME_RE.test(scheme)) {
170
+ throw new Error(
171
+ `react-x11: registerApplication — ${JSON.stringify(scheme)} is not a ` +
172
+ 'scheme name. RFC 3986 wants a letter followed by letters, digits, ' +
173
+ '"+", "-" or "." — and no colon.',
174
+ );
175
+ }
176
+ if (RESERVED_SCHEMES.has(scheme.toLowerCase())) {
177
+ throw new Error(
178
+ `react-x11: registerApplication — "${scheme}" cannot be registered ` +
179
+ 'here. Being the default browser or file handler is a different ' +
180
+ 'feature; a deep link wants a scheme derived from a domain you ' +
181
+ 'control, written in reverse (RFC 8252 §7.1), e.g. ' +
182
+ '"com.example.myapp".',
183
+ );
184
+ }
185
+ }
186
+ return schemes.map((scheme) => scheme.toLowerCase());
187
+ }
188
+
189
+ /**
190
+ * The scheme of a URI, lowercased, or `null` when the string is not one.
191
+ *
192
+ * Deliberately not `new URL()`: this runs on attacker-supplied input from an
193
+ * unauthenticated local peer, and a parser that throws on some inputs is a
194
+ * parser every caller has to wrap.
195
+ */
196
+ export function schemeOf(uri) {
197
+ if (typeof uri !== 'string') return null;
198
+ const match = HAS_SCHEME_RE.exec(uri);
199
+ return match ? match[1].toLowerCase() : null;
200
+ }
201
+
202
+ /**
203
+ * The URIs of `list` this app answers for.
204
+ *
205
+ * `file:` is always allowed, whatever `schemes` says. `Open` is not only the
206
+ * deep-link entry point — it is also how a file manager says "open this
207
+ * document with you", and an app that declared a custom scheme must not
208
+ * thereby stop opening its own files. An app that declares no schemes at all
209
+ * gets everything, which is the honest reading of "it has not told us what it
210
+ * answers for".
211
+ */
212
+ export function acceptUris(list, schemes) {
213
+ const uris = (Array.isArray(list) ? list : []).filter(
214
+ (uri) => typeof uri === 'string' && schemeOf(uri) !== null,
215
+ );
216
+ if (!schemes) return uris;
217
+ const allowed = new Set([...schemes, 'file']);
218
+ return uris.filter((uri) => {
219
+ if (allowed.has(schemeOf(uri))) return true;
220
+ debug(`ignoring ${redactUri(uri)}: not a scheme this app registered`);
221
+ return false;
222
+ });
223
+ }
224
+
225
+ /**
226
+ * A URI with everything secret taken out of it: no query, no fragment, no
227
+ * userinfo.
228
+ *
229
+ * The whole point of this feature is that an authorization code arrives inside
230
+ * a URI, so **the URI must never be logged whole** — not by us, and the docs
231
+ * say not by the app either. What is left identifies the link well enough to
232
+ * debug a routing problem.
233
+ */
234
+ export function redactUri(uri) {
235
+ const text = String(uri);
236
+ const cut = text.search(/[?#]/);
237
+ const head = cut < 0 ? text : `${text.slice(0, cut)}…`;
238
+ // `scheme://user:password@host/…` — the credential is in the authority, and
239
+ // it survives stripping the query.
240
+ return head.replace(/(:\/\/)[^/@]*@/, '$1…@');
241
+ }
242
+
243
+ /**
244
+ * The debug channel for this module, off unless asked for.
245
+ *
246
+ * Its own switch rather than a general one, because *this* output is the one
247
+ * that has to be safe to paste into an issue: everything that goes through it
248
+ * has already been through {@link redactUri}.
249
+ */
250
+ function debug(message) {
251
+ if (process.env.REACT_X11_DEBUG_URI === '1') {
252
+ console.error(`react-x11: ${message}`);
253
+ }
254
+ }
255
+
256
+ // --------------------------------------------------------------------------
257
+ // platform_data
258
+ // --------------------------------------------------------------------------
259
+
260
+ /**
261
+ * Whatever the wire produced for an `a{sv}`, as a plain object.
262
+ *
263
+ * dbus-native's defaults already hand back exactly that, so this is normally
264
+ * one `Object.entries` pass. It tolerates the other shapes because
265
+ * `setValueShapes()` is process-global and app policy (docs/dbus.md): an app
266
+ * that flips it for its own service must not silently break the launch path.
267
+ */
268
+ function plainDict(value) {
269
+ const out = Object.create(null);
270
+ if (!value || typeof value !== 'object') return out;
271
+ const entries = Array.isArray(value)
272
+ ? value.filter((e) => Array.isArray(e) && e.length >= 2)
273
+ : Object.entries(value);
274
+ for (const [key, raw] of entries) out[String(key)] = plainValue(raw);
275
+ return out;
276
+ }
277
+
278
+ /** A variant in any of the three shapes dbus-native can produce, unwrapped. */
279
+ function plainValue(value) {
280
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
281
+ // `new Variant(signature, value)`
282
+ if ('signature' in value && 'value' in value) return value.value;
283
+ return value;
284
+ }
285
+ // the classic `[signatureTree, [value]]` pair
286
+ if (Array.isArray(value) && value.length === 2 && Array.isArray(value[1])) {
287
+ return value[1].length === 1 ? value[1][0] : value[1];
288
+ }
289
+ return value;
290
+ }
291
+
292
+ /**
293
+ * The launch context a handler is given.
294
+ *
295
+ * **Everything on it is untrusted.** `Open` is callable by any peer on the
296
+ * session bus — it is a per-user socket, not an authorization check — so a
297
+ * hostile local process can fabricate a `desktop-startup-id` and make the app
298
+ * raise itself. That is focus theft rather than data loss, and it is the
299
+ * reason the timestamp is documented as a hint rather than a credential.
300
+ */
301
+ function launchContext(platformData) {
302
+ const data = plainDict(platformData);
303
+ const startupId =
304
+ typeof data['desktop-startup-id'] === 'string'
305
+ ? data['desktop-startup-id']
306
+ : null;
307
+ const token =
308
+ typeof data['activation-token'] === 'string'
309
+ ? data['activation-token']
310
+ : null;
311
+ return Object.freeze({
312
+ platformData: Object.freeze(data),
313
+ // The `_TIME` suffix of the startup id is the X timestamp of the user
314
+ // action that triggered the launch — the same parse startup notification
315
+ // does, and the value the window manager weighs before letting a window
316
+ // come forward.
317
+ timestamp: parseLaunchTime(startupId),
318
+ startupId,
319
+ /** Read, exposed, and ignored: there is no Wayland here. */
320
+ activationToken: token,
321
+ });
322
+ }
323
+
324
+ /** The environment's version of the same thing, for a command-line launch. */
325
+ function environmentContext() {
326
+ const data = {};
327
+ // Read, never deleted. `beginStartup()` consumes DESKTOP_STARTUP_ID for the
328
+ // startup sequence and must still find it — registration runs first.
329
+ if (process.env.DESKTOP_STARTUP_ID) {
330
+ data['desktop-startup-id'] = process.env.DESKTOP_STARTUP_ID;
331
+ }
332
+ if (process.env.XDG_ACTIVATION_TOKEN) {
333
+ data['activation-token'] = process.env.XDG_ACTIVATION_TOKEN;
334
+ }
335
+ return launchContext(data);
336
+ }
337
+
338
+ // --------------------------------------------------------------------------
339
+ // Handlers, and the buffer in front of them
340
+ // --------------------------------------------------------------------------
341
+
342
+ /**
343
+ * One registration per process, because an application has one identity on the
344
+ * desktop. Module state rather than a context, for the same reason
345
+ * `launchTimestamp()` is: `useAppOpen()` has to reach it from inside a tree
346
+ * that was mounted after the launch it is asking about.
347
+ */
348
+ let current = null;
349
+
350
+ const openHandlers = new Set();
351
+ const activateHandlers = new Set();
352
+
353
+ /**
354
+ * Calls that arrived before anything was listening.
355
+ *
356
+ * Drained by the **first** handler to attach and then gone — replaying to
357
+ * every later subscriber would deliver one login twice.
358
+ */
359
+ let buffered = [];
360
+
361
+ /**
362
+ * Run app code detached from the D-Bus reply.
363
+ *
364
+ * Two things this buys, both of which are the difference between a working
365
+ * launch and a mysterious one: the reply goes out on the turn the call
366
+ * arrived, however long the handler takes; and a handler that throws stays the
367
+ * app's problem instead of becoming an error reply to a desktop that has no
368
+ * idea what to do with it.
369
+ */
370
+ function dispatch(fn) {
371
+ queueMicrotask(() => {
372
+ try {
373
+ fn();
374
+ } catch (err) {
375
+ // Not swallowed: an app whose deep-link handler throws would otherwise
376
+ // see nothing at all, and there is no React boundary above a D-Bus call.
377
+ console.error('react-x11: a handler for an app launch threw', err);
378
+ }
379
+ });
380
+ }
381
+
382
+ function deliver(event) {
383
+ const handlers = event.kind === 'open' ? openHandlers : activateHandlers;
384
+ if (handlers.size === 0) {
385
+ buffered.push(event);
386
+ return;
387
+ }
388
+ for (const handler of [...handlers]) {
389
+ dispatch(() =>
390
+ event.kind === 'open'
391
+ ? handler(event.uris, event.ctx)
392
+ : handler(event.ctx),
393
+ );
394
+ }
395
+ }
396
+
397
+ function drainTo(kind, handler) {
398
+ if (buffered.length === 0) return;
399
+ const mine = buffered.filter((event) => event.kind === kind);
400
+ if (mine.length === 0) return;
401
+ buffered = buffered.filter((event) => event.kind !== kind);
402
+ for (const event of mine) {
403
+ dispatch(() =>
404
+ kind === 'open' ? handler(event.uris, event.ctx) : handler(event.ctx),
405
+ );
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Be told when the desktop hands this app URIs to open.
411
+ *
412
+ * ```js
413
+ * const stop = onAppOpen((uris, ctx) => {
414
+ * activateWindow(null, { timestamp: ctx.timestamp });
415
+ * route(uris[0]);
416
+ * });
417
+ * ```
418
+ *
419
+ * Anything that arrived before the first handler attached is replayed to it,
420
+ * so an app whose React tree mounts a second after the launch loses nothing.
421
+ * Safe to call with no registration and no bus — it simply never fires.
422
+ */
423
+ export function onAppOpen(handler) {
424
+ if (typeof handler !== 'function') return () => {};
425
+ openHandlers.add(handler);
426
+ drainTo('open', handler);
427
+ return () => openHandlers.delete(handler);
428
+ }
429
+
430
+ /**
431
+ * Be told when the desktop asks this app to come forward with nothing to open
432
+ * — a second launch from the panel, or `gio launch` on a running app.
433
+ *
434
+ * The handler is what raises the window; nothing is raised for you, because
435
+ * this module has no X connection and an app that wants to answer differently
436
+ * (a new document, say) should not have to undo a raise first.
437
+ */
438
+ export function onAppActivate(handler) {
439
+ if (typeof handler !== 'function') return () => {};
440
+ activateHandlers.add(handler);
441
+ drainTo('activate', handler);
442
+ return () => activateHandlers.delete(handler);
443
+ }
444
+
445
+ // --------------------------------------------------------------------------
446
+ // The exported interface
447
+ // --------------------------------------------------------------------------
448
+
449
+ /**
450
+ * `org.freedesktop.Application`, as the desktop entry spec defines it.
451
+ *
452
+ * All three methods answer with nothing and answer immediately. `Open`'s
453
+ * `uris` are filtered before the app sees them; `ActivateAction` is a
454
+ * deliberate stub — the desktop-actions half wants a design alongside the
455
+ * global menu — but it is a stub that *replies correctly*, which is the
456
+ * difference between an unimplemented action and a shell that thinks the app
457
+ * is broken.
458
+ */
459
+ function defineApplication(dbus, { schemes, onAction }) {
460
+ return dbus.defineInterface({
461
+ name: APPLICATION_IFACE,
462
+ methods: {
463
+ Activate: {
464
+ in: { platform_data: 'a{sv}' },
465
+ out: {},
466
+ handler: ({ platform_data: platformData }) => {
467
+ deliver({ kind: 'activate', ctx: launchContext(platformData) });
468
+ },
469
+ },
470
+ Open: {
471
+ in: { uris: 'as', platform_data: 'a{sv}' },
472
+ out: {},
473
+ handler: ({ uris, platform_data: platformData }) => {
474
+ const accepted = acceptUris(uris, schemes);
475
+ debug(
476
+ `Open(${accepted.map(redactUri).join(', ')}) from the session bus`,
477
+ );
478
+ deliver({
479
+ kind: 'open',
480
+ uris: accepted,
481
+ ctx: launchContext(platformData),
482
+ });
483
+ },
484
+ },
485
+ ActivateAction: {
486
+ in: {
487
+ action_name: 's',
488
+ parameter: 'av',
489
+ platform_data: 'a{sv}',
490
+ },
491
+ out: {},
492
+ handler: ({
493
+ action_name: name,
494
+ parameter,
495
+ platform_data: platformData,
496
+ }) => {
497
+ if (!onAction) return;
498
+ const ctx = launchContext(platformData);
499
+ dispatch(() => onAction(name, parameter ?? [], ctx));
500
+ },
501
+ },
502
+ },
503
+ });
504
+ }
505
+
506
+ // --------------------------------------------------------------------------
507
+ // Registration
508
+ // --------------------------------------------------------------------------
509
+
510
+ /**
511
+ * Own this app's name on the session bus, export
512
+ * `org.freedesktop.Application` on it, and answer whether this process is the
513
+ * app or a second copy of it.
514
+ *
515
+ * ```js
516
+ * const app = await registerApplication({
517
+ * appId: 'com.example.myapp',
518
+ * schemes: ['com.example.myapp'],
519
+ * });
520
+ * if (app?.role === 'secondary') process.exit(0); // we do not exit for you
521
+ * const root = await createRoot();
522
+ * ```
523
+ *
524
+ * **Call it before `createRoot`.** On the D-Bus path the bus started this
525
+ * process *because* someone called `Open`, and that call is outstanding while
526
+ * the app boots; registering from inside a component answers it with an
527
+ * unknown-method error.
528
+ *
529
+ * `null` means there is no session bus — ssh, a bare `startx`, a container,
530
+ * CI, Node 20 without the transport. The app runs as an ordinary
531
+ * single-window program, and a URI that arrived in `argv` is still delivered
532
+ * to {@link onAppOpen}: a cold-start deep link does not need a bus, only a
533
+ * second one does.
534
+ *
535
+ * It never rejects for anything about the machine. It does throw for a
536
+ * malformed `appId` or an unusable `scheme`, which are mistakes in the source
537
+ * — see {@link checkAppId}.
538
+ */
539
+ export async function registerApplication(options = {}) {
540
+ const { onOpen, onActivate, onAction } = options;
541
+ const appId = checkAppId(options.appId);
542
+ const schemes = checkSchemes(options.schemes);
543
+ const objectPath = objectPathForAppId(appId);
544
+
545
+ if (current) {
546
+ // An app has one identity; two registrations would be two RequestNames and
547
+ // two exports on the same path, with the second silently replacing the
548
+ // first. The same id answers the existing registration and **ignores the
549
+ // rest of the options** — a double call cannot corrupt anything, and a
550
+ // second set of handlers belongs on `onAppOpen()` where adding one is what
551
+ // the function is for.
552
+ if (current.appId === appId) return current;
553
+ throw new Error(
554
+ `react-x11: registerApplication("${appId}") — this process is already ` +
555
+ `registered as "${current.appId}". An application has one identity ` +
556
+ 'on the bus; release() the first registration to change it.',
557
+ );
558
+ }
559
+
560
+ if (typeof onOpen === 'function') onAppOpen(onOpen);
561
+ if (typeof onActivate === 'function') onAppActivate(onActivate);
562
+
563
+ // The URIs this launch carried on the command line, before any of the bus
564
+ // work: they are equally real whether we turn out to be the first copy of
565
+ // the app or the second, and whether or not there is a bus at all.
566
+ const argv = Array.isArray(options.argv)
567
+ ? options.argv
568
+ : process.argv.slice(2);
569
+ const uris = acceptUris(argv, schemes);
570
+
571
+ const ref = await sessionBus();
572
+ if (!ref) {
573
+ // No bus is a first-class configuration, not a degraded one. This process
574
+ // is the app because it is the only one that can be.
575
+ if (uris.length > 0) {
576
+ debug(`no session bus; delivering ${uris.length} URI(s) from argv`);
577
+ deliver({ kind: 'open', uris, ctx: environmentContext() });
578
+ }
579
+ return null;
580
+ }
581
+
582
+ let dbus;
583
+ try {
584
+ dbus = await loadTransport();
585
+ } catch {
586
+ // Unreachable in practice — `sessionBus()` already loaded it — but this
587
+ // module must not be the one thing that turns a missing optional
588
+ // dependency into a crash.
589
+ await ref.release();
590
+ return null;
591
+ }
592
+
593
+ const iface = defineApplication(dbus, { schemes, onAction });
594
+
595
+ // **Export before requesting the name.** The daemon queues the activating
596
+ // `Open` call against the well-known name and delivers it the instant we own
597
+ // it; an object exported one await later is an object that did not exist
598
+ // when the launch arrived, and the launcher gets UnknownMethod. Exporting
599
+ // first costs nothing if we turn out to be the second copy — nobody can
600
+ // route to us under a name we do not hold.
601
+ let registration;
602
+ try {
603
+ registration = await ref.bus.export(objectPath, iface);
604
+ } catch (cause) {
605
+ // The path is derived from an id that has already been validated, so this
606
+ // is a transport-level failure rather than a caller error — and a caller
607
+ // whose deep links do not work must still get an app that runs.
608
+ debug(`exporting ${objectPath} failed: ${cause?.message ?? cause}`);
609
+ await ref.release();
610
+ return null;
611
+ }
612
+
613
+ let reply;
614
+ try {
615
+ reply = await ref.bus.requestName(appId, DO_NOT_QUEUE);
616
+ } catch (cause) {
617
+ debug(`RequestName(${appId}) failed: ${cause?.message ?? cause}`);
618
+ await registration.remove().catch(() => {});
619
+ await ref.release();
620
+ return null;
621
+ }
622
+
623
+ const primary = reply === PRIMARY_OWNER || reply === ALREADY_OWNER;
624
+
625
+ if (!primary) {
626
+ // The first copy of the app is running; hand it what we were launched with
627
+ // and become nothing. `DO_NOT_QUEUE` is what makes this one round trip:
628
+ // without it the daemon parks us behind the owner and answers 2 instead,
629
+ // which is a state with no useful behaviour attached to it.
630
+ debug(
631
+ reply === EXISTS
632
+ ? `${appId} is owned by another instance`
633
+ : `RequestName(${appId}) answered ${reply}; not the primary owner`,
634
+ );
635
+ await registration.remove().catch(() => {});
636
+ await forward(ref, dbus, { appId, objectPath, uris });
637
+ await ref.release();
638
+ const secondary = {
639
+ role: 'secondary',
640
+ appId,
641
+ objectPath,
642
+ async release() {},
643
+ };
644
+ secondary[Symbol.asyncDispose] = () => secondary.release();
645
+ return secondary;
646
+ }
647
+
648
+ if (uris.length > 0) {
649
+ // Launched from the command line *with* a link — `xdg-open`'s path, and
650
+ // the cold start of the D-Bus one. Same delivery as a bus call, so an app
651
+ // handles one code path instead of two.
652
+ debug(`launched with ${uris.length} URI(s) in argv`);
653
+ deliver({ kind: 'open', uris, ctx: environmentContext() });
654
+ }
655
+
656
+ let released = false;
657
+ const primaryRegistration = {
658
+ role: 'primary',
659
+ appId,
660
+ objectPath,
661
+ /**
662
+ * Give the name back and stop serving. Rare: a registration normally lives
663
+ * as long as the process, and the bus ref it holds is what keeps the
664
+ * process alive while there is a name to answer for.
665
+ *
666
+ * Idempotent on its own flag rather than on `current`, so that a test seam
667
+ * that forgot the registration cannot turn this into a no-op that leaks
668
+ * the bus ref — a held ref is a `ref()`d socket and a process that never
669
+ * exits.
670
+ */
671
+ async release() {
672
+ if (released) return;
673
+ released = true;
674
+ if (current === primaryRegistration) current = null;
675
+ await ref.bus.releaseName(appId).catch(() => {});
676
+ await registration?.remove().catch(() => {});
677
+ registration = null;
678
+ await ref.release();
679
+ },
680
+ };
681
+ primaryRegistration[Symbol.asyncDispose] = () =>
682
+ primaryRegistration.release();
683
+ current = primaryRegistration;
684
+ return primaryRegistration;
685
+ }
686
+
687
+ /**
688
+ * Hand this launch to the copy of the app that owns the name.
689
+ *
690
+ * `Open` when there is something to open, `Activate` when there is not —
691
+ * "the user started the app again" and "the user clicked a link" are different
692
+ * events, and an app that treats a second launch as an empty `Open` cannot
693
+ * tell them apart.
694
+ *
695
+ * **Auto-start is deliberately left on here**, which is the opposite of the
696
+ * rule `globalmenu.js` follows. There, starting the service we are talking to
697
+ * would be actively harmful; here, the owner exiting between our `RequestName`
698
+ * and this call is exactly the case where launching a fresh instance to handle
699
+ * the URI is the right outcome rather than a bug.
700
+ */
701
+ async function forward(ref, dbus, { appId, objectPath, uris }) {
702
+ const platformData = {};
703
+ for (const [key, value] of Object.entries(
704
+ environmentContext().platformData,
705
+ )) {
706
+ platformData[key] = new dbus.Variant('s', String(value));
707
+ }
708
+ const call =
709
+ uris.length > 0
710
+ ? { member: 'Open', signature: 'asa{sv}', body: [uris, platformData] }
711
+ : { member: 'Activate', signature: 'a{sv}', body: [platformData] };
712
+ debug(
713
+ `another instance owns ${appId}; forwarding ${call.member}` +
714
+ (uris.length ? ` (${uris.map(redactUri).join(', ')})` : ''),
715
+ );
716
+ try {
717
+ await ref.bus.invoke(
718
+ {
719
+ destination: appId,
720
+ path: objectPath,
721
+ interface: APPLICATION_IFACE,
722
+ member: call.member,
723
+ signature: call.signature,
724
+ body: call.body,
725
+ },
726
+ { timeout: FORWARD_TIMEOUT },
727
+ );
728
+ } catch (cause) {
729
+ // The owner died in the window between the two calls, or refuses the
730
+ // interface. Nothing left to try: we do not own the name, so we cannot
731
+ // serve the link either. Reported rather than thrown — the caller's job is
732
+ // still to exit.
733
+ debug(`forwarding to ${appId} failed: ${cause?.message ?? cause}`);
734
+ }
735
+ }
736
+
737
+ /** This process's registration, or `null`. Not public; the docs use `role`. */
738
+ export function currentRegistration() {
739
+ return current;
740
+ }
741
+
742
+ /** Test seam, not public: forget every handler, buffer and registration. */
743
+ export function _resetApplicationState() {
744
+ current = null;
745
+ openHandlers.clear();
746
+ activateHandlers.clear();
747
+ buffered = [];
748
+ }