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/atspi.js ADDED
@@ -0,0 +1,1953 @@
1
+ // The AT-SPI2 bridge: the accessible tree, live on the accessibility bus.
2
+ //
3
+ // Linux accessibility does not go over the X protocol at all. An application
4
+ // exposes a tree of objects on a separate D-Bus instance — the *accessibility
5
+ // bus* — implementing the org.a11y.atspi.* interfaces (at-spi2-core's xml/ is
6
+ // the wire spec), and registers itself with the AT-SPI registry daemon.
7
+ // Assistive technology (Orca, Accerciser, magnifiers) walks that tree and
8
+ // listens for its signals. Toolkit-less applications are expected to drive
9
+ // this themselves rather than inherit it from GTK — which is exactly what
10
+ // this file is.
11
+ //
12
+ // ## No mirror
13
+ //
14
+ // There is no shadow tree to keep in sync. Every method call is answered
15
+ // from the live node tree through the model in a11y.js (`a11yChildren`,
16
+ // `a11yStates`, `node.abs`, …), so the answer cannot be stale. The bridge
17
+ // keeps only three things per exported node: its object path, which
18
+ // interfaces it was exported with, and the last name/states/text it *told*
19
+ // the bus about — the snapshot that turns "something changed" from the
20
+ // renderer's hooks into the precise AT-SPI event diff.
21
+ //
22
+ // ## The ladder (docs/accessibility.md)
23
+ //
24
+ // start() climbs: transport installed → session bus → org.a11y.Bus knows an
25
+ // address → connect → Embed with the registry. Every rung ends in a silent
26
+ // "off" on machines with no accessibility stack — ssh, CI, containers,
27
+ // macOS/Windows X servers — and the renderer's hook slots stay null, so the
28
+ // app pays one property read per event for a bridge that is not there.
29
+ // AT_SPI_BUS_ADDRESS overrides the discovery, which is also the test seam.
30
+ //
31
+ // Like the rest of the D-Bus layer, a dead accessibility bus is not
32
+ // resurrected: the slots are cleared and the app simply stops being
33
+ // accessible until restarted, the same contract bus.js documents.
34
+
35
+ import { addressFor, loadTransport } from './bus.js';
36
+ import {
37
+ hooks,
38
+ ATSPI_ROLE,
39
+ ATSPI_STATE,
40
+ ATSPI_STATE_NICK,
41
+ ATSPI_ROLE_NICK,
42
+ atspiRoleOf,
43
+ a11yErased,
44
+ a11yPruned,
45
+ a11yChildren,
46
+ a11yParent,
47
+ a11yIndexIn,
48
+ sceneChildrenOf,
49
+ knownSceneItems,
50
+ a11yName,
51
+ a11yDescription,
52
+ a11yStates,
53
+ a11yValue,
54
+ a11yAttributes,
55
+ a11yActivatable,
56
+ isTextControl,
57
+ isNativeTextControl,
58
+ acceptsTextEdits,
59
+ hasTextInterface,
60
+ textStateOf,
61
+ inPreedit,
62
+ diffChars,
63
+ } from './a11y.js';
64
+ import { onApp } from './trace-registry.js';
65
+ import { synthesizeClick } from './events.js';
66
+ import { callHandler } from './errors.js';
67
+
68
+ const ROOT_PATH = '/org/a11y/atspi/accessible/root';
69
+ const NODE_PATH = '/org/a11y/atspi/accessible/';
70
+ const NULL_PATH = '/org/a11y/atspi/null';
71
+ const CACHE_PATH = '/org/a11y/atspi/cache';
72
+ const REGISTRY_NAME = 'org.a11y.atspi.Registry';
73
+
74
+ const IFACE = {
75
+ ACCESSIBLE: 'org.a11y.atspi.Accessible',
76
+ APPLICATION: 'org.a11y.atspi.Application',
77
+ COMPONENT: 'org.a11y.atspi.Component',
78
+ ACTION: 'org.a11y.atspi.Action',
79
+ VALUE: 'org.a11y.atspi.Value',
80
+ TEXT: 'org.a11y.atspi.Text',
81
+ EDITABLE_TEXT: 'org.a11y.atspi.EditableText',
82
+ SOCKET: 'org.a11y.atspi.Socket',
83
+ CACHE: 'org.a11y.atspi.Cache',
84
+ EVENT_OBJECT: 'org.a11y.atspi.Event.Object',
85
+ EVENT_WINDOW: 'org.a11y.atspi.Event.Window',
86
+ EVENT_FOCUS: 'org.a11y.atspi.Event.Focus',
87
+ };
88
+
89
+ // AtspiCoordType / AtspiComponentLayer, from the same enums as a11y.js's
90
+ // tables.
91
+ const COORD_SCREEN = 0;
92
+ const COORD_WINDOW = 1;
93
+ const COORD_PARENT = 2;
94
+ const LAYER_WIDGET = 3;
95
+ const LAYER_POPUP = 5;
96
+ const LAYER_WINDOW = 7;
97
+
98
+ /** The version at-spi2-core reports for current releases; ATs use it only
99
+ * to gate features far newer than anything here. */
100
+ const ATSPI_VERSION = '2.1';
101
+
102
+ const EVENT_SIGNATURE = 'siiva{sv}';
103
+ const CACHE_ITEM_SIGNATURE = '((so)(so)(so)iiassusau)';
104
+
105
+ // --------------------------------------------------------------------------
106
+ // Interface descriptors (dbus-native shape). Signatures are copied from
107
+ // at-spi2-core's xml/, which is the authoritative wire spec.
108
+ // --------------------------------------------------------------------------
109
+
110
+ const readOnly = (type) => ({ type, access: 'read' });
111
+
112
+ const ACCESSIBLE_DESC = {
113
+ name: IFACE.ACCESSIBLE,
114
+ methods: {
115
+ GetChildAtIndex: ['i', '(so)'],
116
+ GetChildren: ['', 'a(so)'],
117
+ GetIndexInParent: ['', 'i'],
118
+ GetRelationSet: ['', 'a(ua(so))'],
119
+ GetRole: ['', 'u'],
120
+ GetRoleName: ['', 's'],
121
+ GetLocalizedRoleName: ['', 's'],
122
+ GetState: ['', 'au'],
123
+ GetAttributes: ['', 'a{ss}'],
124
+ GetApplication: ['', '(so)'],
125
+ GetInterfaces: ['', 'as'],
126
+ },
127
+ properties: {
128
+ Name: readOnly('s'),
129
+ Description: readOnly('s'),
130
+ Parent: readOnly('(so)'),
131
+ ChildCount: readOnly('i'),
132
+ Locale: readOnly('s'),
133
+ AccessibleId: readOnly('s'),
134
+ },
135
+ };
136
+
137
+ const APPLICATION_DESC = {
138
+ name: IFACE.APPLICATION,
139
+ methods: {
140
+ GetLocale: ['u', 's'],
141
+ GetApplicationBusAddress: ['', 's'],
142
+ },
143
+ properties: {
144
+ ToolkitName: readOnly('s'),
145
+ Version: readOnly('s'),
146
+ AtspiVersion: readOnly('s'),
147
+ Id: 'i',
148
+ },
149
+ };
150
+
151
+ const SOCKET_DESC = {
152
+ name: IFACE.SOCKET,
153
+ methods: {
154
+ Embedded: ['s', ''],
155
+ Unembed: ['(so)', ''],
156
+ },
157
+ };
158
+
159
+ const COMPONENT_DESC = {
160
+ name: IFACE.COMPONENT,
161
+ methods: {
162
+ Contains: ['iiu', 'b'],
163
+ GetAccessibleAtPoint: ['iiu', '(so)'],
164
+ GetExtents: ['u', '(iiii)'],
165
+ GetPosition: ['u', 'ii'],
166
+ GetSize: ['', 'ii'],
167
+ GetLayer: ['', 'u'],
168
+ GrabFocus: ['', 'b'],
169
+ GetAlpha: ['', 'd'],
170
+ ScrollTo: ['u', 'b'],
171
+ ScrollToPoint: ['uii', 'b'],
172
+ },
173
+ };
174
+
175
+ const ACTION_DESC = {
176
+ name: IFACE.ACTION,
177
+ methods: {
178
+ GetDescription: ['i', 's'],
179
+ GetName: ['i', 's'],
180
+ GetLocalizedName: ['i', 's'],
181
+ GetKeyBinding: ['i', 's'],
182
+ GetActions: ['', 'a(sss)'],
183
+ DoAction: ['i', 'b'],
184
+ },
185
+ properties: { NActions: readOnly('i') },
186
+ };
187
+
188
+ const VALUE_DESC = {
189
+ name: IFACE.VALUE,
190
+ properties: {
191
+ MinimumValue: readOnly('d'),
192
+ MaximumValue: readOnly('d'),
193
+ MinimumIncrement: readOnly('d'),
194
+ CurrentValue: 'd',
195
+ Text: readOnly('s'),
196
+ },
197
+ };
198
+
199
+ const TEXT_DESC = {
200
+ name: IFACE.TEXT,
201
+ methods: {
202
+ GetText: ['ii', 's'],
203
+ GetStringAtOffset: ['iu', 'sii'],
204
+ GetTextAtOffset: ['iu', 'sii'],
205
+ GetTextBeforeOffset: ['iu', 'sii'],
206
+ GetTextAfterOffset: ['iu', 'sii'],
207
+ GetCharacterAtOffset: ['i', 'i'],
208
+ SetCaretOffset: ['i', 'b'],
209
+ GetAttributes: ['i', 'a{ss}ii'],
210
+ GetDefaultAttributes: ['', 'a{ss}'],
211
+ GetAttributeValue: ['is', 's'],
212
+ GetCharacterExtents: ['iu', 'iiii'],
213
+ GetRangeExtents: ['iiu', 'iiii'],
214
+ GetOffsetAtPoint: ['iiu', 'i'],
215
+ GetNSelections: ['', 'i'],
216
+ GetSelection: ['i', 'ii'],
217
+ AddSelection: ['ii', 'b'],
218
+ RemoveSelection: ['i', 'b'],
219
+ SetSelection: ['iii', 'b'],
220
+ },
221
+ properties: {
222
+ CharacterCount: readOnly('i'),
223
+ CaretOffset: readOnly('i'),
224
+ },
225
+ };
226
+
227
+ const EDITABLE_TEXT_DESC = {
228
+ name: IFACE.EDITABLE_TEXT,
229
+ methods: {
230
+ SetTextContents: ['s', 'b'],
231
+ InsertText: ['isi', 'b'],
232
+ CopyText: ['ii', ''],
233
+ CutText: ['ii', 'b'],
234
+ DeleteText: ['ii', 'b'],
235
+ PasteText: ['i', 'b'],
236
+ },
237
+ };
238
+
239
+ const CACHE_DESC = {
240
+ name: IFACE.CACHE,
241
+ methods: {
242
+ GetItems: ['', `a${CACHE_ITEM_SIGNATURE}`],
243
+ },
244
+ signals: {
245
+ AddAccessible: [CACHE_ITEM_SIGNATURE, 'nodeAdded'],
246
+ RemoveAccessible: ['(so)', 'nodeRemoved'],
247
+ },
248
+ };
249
+
250
+ // --------------------------------------------------------------------------
251
+ // Helpers over the node tree. `textStateOf`/`diffChars` live in a11y.js —
252
+ // they answer "what would an AT read here", which the test spy asks too.
253
+ // --------------------------------------------------------------------------
254
+
255
+ function clampOffset(offset, length) {
256
+ return Math.max(0, Math.min(offset | 0, length));
257
+ }
258
+
259
+ /**
260
+ * The attribute run around an offset, `[attributes, start, end)`.
261
+ *
262
+ * A composition is drawn underlined and `underline` is the only registered
263
+ * AT-SPI attribute that says so, so that is what the preedit run carries.
264
+ * `composition` beside it is this renderer's own: AT-SPI registers nothing
265
+ * for a preedit, and an attribute a reader does not know is one it ignores,
266
+ * which is a better failure than a run claiming to be nothing special.
267
+ * Everything outside the composition is one unattributed run, bounded by
268
+ * the composition's edges so an AT walking runs finds them.
269
+ */
270
+ function attributeRun(state, offset) {
271
+ const total = state.chars.length;
272
+ const at = clampOffset(offset, total);
273
+ const pre = state.preedit;
274
+ if (!pre) return [[], 0, total];
275
+ const start = pre.offset;
276
+ const end = pre.offset + pre.length;
277
+ if (at >= start && at < end) {
278
+ return [
279
+ [
280
+ ['underline', 'single'],
281
+ ['composition', 'true'],
282
+ ],
283
+ start,
284
+ end,
285
+ ];
286
+ }
287
+ return at < start ? [[], 0, start] : [[], end, total];
288
+ }
289
+
290
+ /**
291
+ * An offset an AT handed back, in the node's **value** space — what it was
292
+ * told is the displayed string, composition included. An offset inside the
293
+ * composition answers where the composition starts: a preedit is one thing,
294
+ * not a run of characters to put a caret between or edit around.
295
+ */
296
+ function valueOffset(node, offset) {
297
+ const { chars } = textStateOf(node);
298
+ const at = clampOffset(offset, chars.length);
299
+ return node._valueIndex ? node._valueIndex(at) : at;
300
+ }
301
+
302
+ const isWordChar = (ch) => /\S/.test(ch);
303
+
304
+ /** [start, end) of the word containing (or after) `offset`. */
305
+ function wordRange(chars, offset) {
306
+ const n = chars.length;
307
+ let i = clampOffset(offset, n);
308
+ if (i >= n) i = n - 1;
309
+ if (i < 0) return [0, 0];
310
+ if (!isWordChar(chars[i])) {
311
+ // between words: the range is the whitespace run, as GTK reports it
312
+ let s = i;
313
+ while (s > 0 && !isWordChar(chars[s - 1])) s--;
314
+ let e = i;
315
+ while (e < n && !isWordChar(chars[e])) e++;
316
+ return [s, e];
317
+ }
318
+ let s = i;
319
+ while (s > 0 && isWordChar(chars[s - 1])) s--;
320
+ let e = i;
321
+ while (e < n && isWordChar(chars[e])) e++;
322
+ return [s, e];
323
+ }
324
+
325
+ /** Hard-line ranges [start, end) with the newline included in its line —
326
+ * what GtkTextView reports for LINE_START. Soft wraps are not lines here;
327
+ * see docs/accessibility.md. */
328
+ function lineRanges(chars) {
329
+ const ranges = [];
330
+ let start = 0;
331
+ for (let i = 0; i < chars.length; i++) {
332
+ if (chars[i] === '\n') {
333
+ ranges.push([start, i + 1]);
334
+ start = i + 1;
335
+ }
336
+ }
337
+ ranges.push([start, chars.length]);
338
+ return ranges;
339
+ }
340
+
341
+ function lineRangeAt(chars, offset) {
342
+ const ranges = lineRanges(chars);
343
+ for (const range of ranges) {
344
+ if (offset < range[1]) return range;
345
+ }
346
+ return ranges[ranges.length - 1];
347
+ }
348
+
349
+ /**
350
+ * A granular range around `offset`. `granularity` follows AtspiTextGranularity
351
+ * (0 char, 1 word, 2 sentence, 3 line, 4 paragraph); the legacy
352
+ * AtspiTextBoundaryType values GetTextAtOffset carries are translated by the
353
+ * caller. Sentences and paragraphs answer as lines — hard lines are the only
354
+ * paragraph structure a `<textarea>` has.
355
+ */
356
+ function granularRange(chars, offset, granularity) {
357
+ const n = chars.length;
358
+ const at = clampOffset(offset, n);
359
+ switch (granularity) {
360
+ case 0:
361
+ return at >= n ? [n, n] : [at, at + 1];
362
+ case 1:
363
+ return wordRange(chars, at);
364
+ default:
365
+ return lineRangeAt(chars, at);
366
+ }
367
+ }
368
+
369
+ /** Legacy AtspiTextBoundaryType → granularity (start/end variants collapse:
370
+ * the range is the same, only legacy iteration order differed). */
371
+ function boundaryToGranularity(type) {
372
+ if (type === 0) return 0; // CHAR
373
+ if (type === 1 || type === 2) return 1; // WORD_START / WORD_END
374
+ return 3; // SENTENCE_* and LINE_* answer as lines
375
+ }
376
+
377
+ /** The best guess at what the desktop calls this program. */
378
+ function programName() {
379
+ const scriptPath = process.argv?.[1];
380
+ if (typeof scriptPath === 'string' && scriptPath !== '') {
381
+ const base = scriptPath.split(/[\\/]/).pop();
382
+ if (base) return base.replace(/\.[cm]?jsx?$/, '');
383
+ }
384
+ return process.title || 'node';
385
+ }
386
+
387
+ function localeString() {
388
+ return (
389
+ process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || 'C'
390
+ );
391
+ }
392
+
393
+ // --------------------------------------------------------------------------
394
+ // Per-interface implementation prototypes. One tiny `{ b, n }` object per
395
+ // (node, interface) is created at export time; everything else lives here.
396
+ // dbus-native reads properties straight off the object (prototype getters
397
+ // included) and calls methods with the message appended, which none of
398
+ // these need — the node is bound at export.
399
+ // --------------------------------------------------------------------------
400
+
401
+ const AccessibleImpl = {
402
+ get Name() {
403
+ return a11yName(this.n);
404
+ },
405
+ get Description() {
406
+ return a11yDescription(this.n);
407
+ },
408
+ get Parent() {
409
+ return this.b.parentRef(this.n);
410
+ },
411
+ get ChildCount() {
412
+ return a11yChildren(this.n).length;
413
+ },
414
+ get Locale() {
415
+ return localeString();
416
+ },
417
+ get AccessibleId() {
418
+ return '';
419
+ },
420
+ GetChildAtIndex(index) {
421
+ const child = a11yChildren(this.n)[index];
422
+ return child ? this.b.refFor(child) : this.b.nullRef();
423
+ },
424
+ GetChildren() {
425
+ return a11yChildren(this.n).map((child) => this.b.refFor(child));
426
+ },
427
+ GetIndexInParent() {
428
+ const parent = a11yParent(this.n);
429
+ if (!parent) return this.b.toplevels.indexOf(this.n);
430
+ return a11yIndexIn(parent, this.n);
431
+ },
432
+ GetRelationSet() {
433
+ return [];
434
+ },
435
+ GetRole() {
436
+ return atspiRoleOf(this.n);
437
+ },
438
+ // The AT-SPI role name, not the ARIA one the app wrote — see
439
+ // ATSPI_ROLE_NICK. The ARIA role is reported through `xml-roles` in
440
+ // GetAttributes, as the browsers do.
441
+ GetRoleName() {
442
+ return ATSPI_ROLE_NICK[atspiRoleOf(this.n)] ?? 'unknown';
443
+ },
444
+ GetLocalizedRoleName() {
445
+ // No localisation here: answering the untranslated name is what a
446
+ // toolkit with no message catalogue should do, and libatspi falls back
447
+ // to its own translations anyway.
448
+ return ATSPI_ROLE_NICK[atspiRoleOf(this.n)] ?? 'unknown';
449
+ },
450
+ GetState() {
451
+ return a11yStates(this.n);
452
+ },
453
+ GetAttributes() {
454
+ return a11yAttributes(this.n);
455
+ },
456
+ GetApplication() {
457
+ return this.b.rootRef();
458
+ },
459
+ GetInterfaces() {
460
+ return this.b.interfacesOf(this.n);
461
+ },
462
+ };
463
+
464
+ /** The innermost scene item covering a window-coordinate point, or the node
465
+ * itself. Last one wins: items are declared in the order they were drawn,
466
+ * so the one on top is the one the user sees. */
467
+ function deepestItemAt(node, x, y) {
468
+ let found = node;
469
+ for (const item of sceneChildrenOf(node)) {
470
+ if (item.destroyed || a11yPruned(item)) continue;
471
+ const r = item.abs;
472
+ if (x < r.x || y < r.y || x >= r.x + r.width || y >= r.y + r.height) {
473
+ continue;
474
+ }
475
+ found = deepestItemAt(item, x, y);
476
+ }
477
+ return found;
478
+ }
479
+
480
+ const ComponentImpl = {
481
+ Contains(x, y) {
482
+ const r = this.b.extentsOf(this.n, COORD_SCREEN);
483
+ return x >= r[0] && y >= r[1] && x < r[0] + r[2] && y < r[1] + r[3];
484
+ },
485
+ GetAccessibleAtPoint(x, y, coordType) {
486
+ const node = this.n;
487
+ const win = node.isWindow ? node : node.root;
488
+ if (!win) return this.b.nullRef();
489
+ let wx = x;
490
+ let wy = y;
491
+ if (coordType !== COORD_WINDOW) {
492
+ const origin = this.b.screenOriginOf(win);
493
+ wx -= origin.x;
494
+ wy -= origin.y;
495
+ }
496
+ let hit = win.hitTest?.(wx, wy) ?? null;
497
+ while (hit && (a11yErased(hit) || a11yPruned(hit))) hit = hit.parent;
498
+ // Hit testing stops at the retained tree, so an element that draws a
499
+ // scene answers with itself; the item under the point is what a
500
+ // magnifier following the pointer is actually over.
501
+ if (hit) hit = deepestItemAt(hit, wx, wy);
502
+ return hit ? this.b.refFor(hit) : this.b.nullRef();
503
+ },
504
+ GetExtents(coordType) {
505
+ return this.b.extentsOf(this.n, coordType);
506
+ },
507
+ GetPosition(coordType) {
508
+ const r = this.b.extentsOf(this.n, coordType);
509
+ return [r[0], r[1]];
510
+ },
511
+ GetSize() {
512
+ const r = this.b.extentsOf(this.n, COORD_WINDOW);
513
+ return [r[2], r[3]];
514
+ },
515
+ GetLayer() {
516
+ if (this.n.isPopup) return LAYER_POPUP;
517
+ if (this.n.isWindow) return LAYER_WINDOW;
518
+ return LAYER_WIDGET;
519
+ },
520
+ GrabFocus() {
521
+ return this.b.grabFocus(this.n);
522
+ },
523
+ GetAlpha() {
524
+ return 1.0;
525
+ },
526
+ ScrollTo() {
527
+ if (this.b.sceneAction(this.n, 'scroll')) return true;
528
+ // A scene item has no layout of its own, so the most core can reveal is
529
+ // the element that drew it — an element that can do better says so
530
+ // through the action above.
531
+ const target = this.n.a11yOwner ?? this.n;
532
+ // `isScroller()`, not "has the method": every `<box>` carries
533
+ // `scrollIntoView` now, and one that is not a scroll container would end
534
+ // the walk having revealed nothing
535
+ for (let p = target.parent; p; p = p.parent) {
536
+ if (p.isScroller?.()) {
537
+ p.scrollIntoView(target);
538
+ return true;
539
+ }
540
+ }
541
+ return false;
542
+ },
543
+ ScrollToPoint() {
544
+ return false;
545
+ },
546
+ };
547
+
548
+ const ACTIVATE = 'activate';
549
+
550
+ const ActionImpl = {
551
+ get NActions() {
552
+ return 1;
553
+ },
554
+ GetName(index) {
555
+ return index === 0 ? ACTIVATE : '';
556
+ },
557
+ GetLocalizedName(index) {
558
+ return index === 0 ? ACTIVATE : '';
559
+ },
560
+ GetDescription() {
561
+ return '';
562
+ },
563
+ GetKeyBinding() {
564
+ return '';
565
+ },
566
+ GetActions() {
567
+ return [[ACTIVATE, '', '']];
568
+ },
569
+ DoAction(index) {
570
+ if (index !== 0) return false;
571
+ return this.b.activate(this.n);
572
+ },
573
+ };
574
+
575
+ const ValueImpl = {
576
+ get MinimumValue() {
577
+ return a11yValue(this.n)?.min ?? 0;
578
+ },
579
+ get MaximumValue() {
580
+ return a11yValue(this.n)?.max ?? 0;
581
+ },
582
+ get MinimumIncrement() {
583
+ return 0;
584
+ },
585
+ get CurrentValue() {
586
+ return a11yValue(this.n)?.now ?? 0;
587
+ },
588
+ /** An AT writing the value — Orca adjusting a slider. Routed to the
589
+ * component through `onAccessibilityAction`, the seam a widget wires its
590
+ * own state setter into. */
591
+ set CurrentValue(next) {
592
+ const node = this.n;
593
+ const handler = node.props?.onAccessibilityAction;
594
+ if (typeof handler !== 'function') return;
595
+ callHandler(node, 'onAccessibilityAction', handler, {
596
+ action: 'setValue',
597
+ value: Number(next),
598
+ });
599
+ },
600
+ get Text() {
601
+ return a11yValue(this.n)?.text ?? '';
602
+ },
603
+ };
604
+
605
+ const TextImpl = {
606
+ get CharacterCount() {
607
+ return textStateOf(this.n).chars.length;
608
+ },
609
+ get CaretOffset() {
610
+ return textStateOf(this.n).caret;
611
+ },
612
+ GetText(start, end) {
613
+ const { chars } = textStateOf(this.n);
614
+ const s = clampOffset(start, chars.length);
615
+ const e = end < 0 ? chars.length : clampOffset(end, chars.length);
616
+ return chars.slice(s, e).join('');
617
+ },
618
+ GetStringAtOffset(offset, granularity) {
619
+ const { chars } = textStateOf(this.n);
620
+ const [s, e] = granularRange(chars, offset, granularity);
621
+ return [chars.slice(s, e).join(''), s, e];
622
+ },
623
+ GetTextAtOffset(offset, type) {
624
+ return TextImpl.GetStringAtOffset.call(
625
+ this,
626
+ offset,
627
+ boundaryToGranularity(type),
628
+ );
629
+ },
630
+ GetTextBeforeOffset(offset, type) {
631
+ const { chars } = textStateOf(this.n);
632
+ const granularity = boundaryToGranularity(type);
633
+ const [s] = granularRange(chars, offset, granularity);
634
+ if (s <= 0) return ['', 0, 0];
635
+ const [ps, pe] = granularRange(chars, s - 1, granularity);
636
+ return [chars.slice(ps, pe).join(''), ps, pe];
637
+ },
638
+ GetTextAfterOffset(offset, type) {
639
+ const { chars } = textStateOf(this.n);
640
+ const granularity = boundaryToGranularity(type);
641
+ const [, e] = granularRange(chars, offset, granularity);
642
+ if (e >= chars.length) return ['', chars.length, chars.length];
643
+ const [ns, ne] = granularRange(chars, e, granularity);
644
+ return [chars.slice(ns, ne).join(''), ns, ne];
645
+ },
646
+ GetCharacterAtOffset(offset) {
647
+ const { chars } = textStateOf(this.n);
648
+ if (offset < 0 || offset >= chars.length) return 0;
649
+ return chars[offset].codePointAt(0);
650
+ },
651
+ SetCaretOffset(offset) {
652
+ return this.b.setCaret(this.n, offset);
653
+ },
654
+ GetAttributes(offset) {
655
+ return attributeRun(textStateOf(this.n), offset);
656
+ },
657
+ GetDefaultAttributes() {
658
+ return [];
659
+ },
660
+ GetAttributeValue(offset, name) {
661
+ const [attrs] = attributeRun(textStateOf(this.n), offset);
662
+ for (const [key, value] of attrs) {
663
+ if (key === name) return value;
664
+ }
665
+ return '';
666
+ },
667
+ GetCharacterExtents(offset, coordType) {
668
+ return this.b.characterExtents(this.n, offset, coordType);
669
+ },
670
+ GetRangeExtents(start, end, coordType) {
671
+ const a = this.b.characterExtents(this.n, start, coordType);
672
+ const z = this.b.characterExtents(
673
+ this.n,
674
+ Math.max(start, end - 1),
675
+ coordType,
676
+ );
677
+ const x = Math.min(a[0], z[0]);
678
+ const y = Math.min(a[1], z[1]);
679
+ return [
680
+ x,
681
+ y,
682
+ Math.max(a[0] + a[2], z[0] + z[2]) - x,
683
+ Math.max(a[1] + a[3], z[1] + z[3]) - y,
684
+ ];
685
+ },
686
+ GetOffsetAtPoint(x, y, coordType) {
687
+ return this.b.offsetAtPoint(this.n, x, y, coordType);
688
+ },
689
+ GetNSelections() {
690
+ const [s, e] = textStateOf(this.n).selection;
691
+ return s !== e ? 1 : 0;
692
+ },
693
+ GetSelection(index) {
694
+ if (index !== 0) return [0, 0];
695
+ return textStateOf(this.n).selection;
696
+ },
697
+ AddSelection(start, end) {
698
+ return this.b.setTextSelection(this.n, start, end);
699
+ },
700
+ SetSelection(index, start, end) {
701
+ if (index !== 0) return false;
702
+ return this.b.setTextSelection(this.n, start, end);
703
+ },
704
+ RemoveSelection(index) {
705
+ if (index !== 0) return false;
706
+ return this.b.setCaret(this.n, textStateOf(this.n).caret);
707
+ },
708
+ };
709
+
710
+ /**
711
+ * Every write is one replacement — `editText(node, start, end, text)` —
712
+ * because that is the one primitive both sides can implement: the
713
+ * built-ins have `_commit`, and a registered element implements
714
+ * `a11yReplaceText`, which it needs anyway for its own editing.
715
+ */
716
+ const EditableTextImpl = {
717
+ SetTextContents(newContents) {
718
+ const { chars } = textStateOf(this.n);
719
+ return this.b.editText(this.n, 0, chars.length, newContents);
720
+ },
721
+ InsertText(position, text, length) {
722
+ const insert = Array.from(String(text))
723
+ .slice(0, length < 0 ? undefined : length)
724
+ .join('');
725
+ return this.b.editText(this.n, position, position, insert);
726
+ },
727
+ DeleteText(start, end) {
728
+ return this.b.editText(this.n, start, end, '');
729
+ },
730
+ CopyText(start, end) {
731
+ this.b.copyTextRange(this.n, start, end);
732
+ return null;
733
+ },
734
+ CutText(start, end) {
735
+ this.b.copyTextRange(this.n, start, end);
736
+ return EditableTextImpl.DeleteText.call(this, start, end);
737
+ },
738
+ PasteText(position) {
739
+ const node = this.n;
740
+ if (!this.b.editable(node)) return false;
741
+ // the clipboard round trip is the built-ins' own: an element with a
742
+ // seam of its own is handed the text, not asked to fetch it
743
+ if (typeof node._pasteFrom !== 'function') return false;
744
+ node._moveCaret?.(valueOffset(node, position), false);
745
+ node._pasteFrom('CLIPBOARD');
746
+ return true;
747
+ },
748
+ };
749
+
750
+ // --------------------------------------------------------------------------
751
+ // The bridge
752
+ // --------------------------------------------------------------------------
753
+
754
+ class AtspiBridge {
755
+ constructor(bus, toolkitVersion) {
756
+ this.bus = bus;
757
+ this.toolkitVersion = toolkitVersion;
758
+ this.dead = false;
759
+ /** @type {Map<object, {id: number, ifaces: string[], snapshot: object}>} */
760
+ this.exported = new Map();
761
+ this.nextId = 1;
762
+ /** Toplevel `<window>` nodes across every root, in mount order — the
763
+ * application's accessible children. */
764
+ this.toplevels = [];
765
+ this.desktop = [REGISTRY_NAME, ROOT_PATH];
766
+ this.appId = -1;
767
+ this._unsubscribes = [];
768
+ // batched renderer notifications, flushed per commit / microtask
769
+ this._pending = {
770
+ attach: new Set(),
771
+ remove: [],
772
+ props: new Set(),
773
+ text: new Set(),
774
+ focus: [],
775
+ window: [],
776
+ };
777
+ this._flushScheduled = false;
778
+ }
779
+
780
+ // ---- refs and export -------------------------------------------------
781
+
782
+ nullRef() {
783
+ return [this.bus.name, NULL_PATH];
784
+ }
785
+
786
+ rootRef() {
787
+ return [this.bus.name, ROOT_PATH];
788
+ }
789
+
790
+ pathOf(node) {
791
+ const entry = this.exported.get(node);
792
+ return entry ? NODE_PATH + entry.id : NULL_PATH;
793
+ }
794
+
795
+ /** The ref for a node, exporting it on the way if the bus has never seen
796
+ * it. Everything that hands a node to the bus goes through here, so an AT
797
+ * can call back on any ref it was ever given. */
798
+ refFor(node) {
799
+ this.ensureExported(node);
800
+ return [this.bus.name, this.pathOf(node)];
801
+ }
802
+
803
+ parentRef(node) {
804
+ const parent = a11yParent(node);
805
+ return parent ? this.refFor(parent) : this.rootRef();
806
+ }
807
+
808
+ interfacesOf(node) {
809
+ return this.exported.get(node)?.ifaces ?? this._ifaceSetFor(node);
810
+ }
811
+
812
+ _ifaceSetFor(node) {
813
+ const ifaces = [IFACE.ACCESSIBLE, IFACE.COMPONENT];
814
+ if (a11yActivatable(node)) ifaces.push(IFACE.ACTION);
815
+ if (a11yValue(node) !== null) ifaces.push(IFACE.VALUE);
816
+ if (hasTextInterface(node)) ifaces.push(IFACE.TEXT);
817
+ // EditableText only where an edit would actually land: a registered
818
+ // element that reports editable text but implements no write seam is
819
+ // readable and reports its own edits, and an interface whose every
820
+ // method answered false would be a lie an AT cannot see through
821
+ if (isTextControl(node) && acceptsTextEdits(node)) {
822
+ ifaces.push(IFACE.EDITABLE_TEXT);
823
+ }
824
+ return ifaces;
825
+ }
826
+
827
+ _implFor(proto, node) {
828
+ return { __proto__: proto, b: this, n: node };
829
+ }
830
+
831
+ _descriptorFor(name) {
832
+ switch (name) {
833
+ case IFACE.ACCESSIBLE:
834
+ return [ACCESSIBLE_DESC, AccessibleImpl];
835
+ case IFACE.COMPONENT:
836
+ return [COMPONENT_DESC, ComponentImpl];
837
+ case IFACE.ACTION:
838
+ return [ACTION_DESC, ActionImpl];
839
+ case IFACE.VALUE:
840
+ return [VALUE_DESC, ValueImpl];
841
+ case IFACE.TEXT:
842
+ return [TEXT_DESC, TextImpl];
843
+ case IFACE.EDITABLE_TEXT:
844
+ return [EDITABLE_TEXT_DESC, EditableTextImpl];
845
+ default:
846
+ return null;
847
+ }
848
+ }
849
+
850
+ ensureExported(node) {
851
+ if (this.dead || this.exported.has(node)) return;
852
+ const id = this.nextId++;
853
+ const ifaces = this._ifaceSetFor(node);
854
+ const path = NODE_PATH + id;
855
+ const entry = { id, ifaces, snapshot: null };
856
+ // registered before the interfaces export, so a getter that recurses
857
+ // through refFor (Parent, GetChildren) sees the entry and terminates
858
+ this.exported.set(node, entry);
859
+ for (const name of ifaces) {
860
+ const [desc, proto] = this._descriptorFor(name);
861
+ this.bus.exportInterface(this._implFor(proto, node), path, desc);
862
+ }
863
+ entry.snapshot = this._snapshot(node);
864
+ }
865
+
866
+ _unexport(node) {
867
+ const entry = this.exported.get(node);
868
+ if (!entry) return;
869
+ this.exported.delete(node);
870
+ const path = NODE_PATH + entry.id;
871
+ this.emitCacheRemove([this.bus.name, path]);
872
+ this.bus.unexportInterface(path);
873
+ }
874
+
875
+ _snapshot(node) {
876
+ const snapshot = {
877
+ name: a11yName(node),
878
+ description: a11yDescription(node),
879
+ states: a11yStates(node),
880
+ value: a11yValue(node)?.now ?? null,
881
+ ifaces: this.exported.get(node)?.ifaces ?? [],
882
+ text: null,
883
+ // the drawn children, which arrive and leave without a mount to
884
+ // notice them; the shared empty list for everything that draws none
885
+ scene: sceneChildrenOf(node),
886
+ };
887
+ if (hasTextInterface(node)) {
888
+ const { chars, caret, selection, preedit } = textStateOf(node);
889
+ snapshot.text = { chars, caret, selection, preedit };
890
+ }
891
+ return snapshot;
892
+ }
893
+
894
+ // ---- geometry --------------------------------------------------------
895
+
896
+ screenOriginOf(win) {
897
+ const wnd = win?.window;
898
+ if (!wnd) return { x: 0, y: 0 };
899
+ return wnd._screenOrigin ?? { x: wnd.x ?? 0, y: wnd.y ?? 0 };
900
+ }
901
+
902
+ /** [x, y, width, height] in the asked-for coordinate system. A window is
903
+ * its whole surface; a drawn node is its layout rect. */
904
+ extentsOf(node, coordType) {
905
+ let rect;
906
+ const win = node.isWindow ? node : node.root;
907
+ if (node.isWindow) {
908
+ const wnd = node.window;
909
+ rect = { x: 0, y: 0, width: wnd?.width ?? 0, height: wnd?.height ?? 0 };
910
+ } else {
911
+ rect = node.abs ?? { x: 0, y: 0, width: 0, height: 0 };
912
+ }
913
+ let { x, y } = rect;
914
+ if (coordType === COORD_SCREEN) {
915
+ const origin = this.screenOriginOf(win);
916
+ x += origin.x;
917
+ y += origin.y;
918
+ } else if (coordType === COORD_PARENT && !node.isWindow) {
919
+ const parent = a11yParent(node);
920
+ if (parent && !parent.isWindow && parent.abs) {
921
+ x -= parent.abs.x;
922
+ y -= parent.abs.y;
923
+ }
924
+ }
925
+ return [x, y, rect.width, rect.height];
926
+ }
927
+
928
+ /** Where the text control paints its characters. An element that answers
929
+ * `_placedValue` — every field in core does — has already resolved this,
930
+ * scroll and reading direction included, and it is the same answer the
931
+ * caret and the highlight are drawn from; the box minus its scroll is the
932
+ * fallback for one that only answers the geometry accessors. */
933
+ _textOrigin(node) {
934
+ const placed = node._placedValue?.();
935
+ if (placed) return { x: placed.x, y: placed.y };
936
+ const content = node.contentBox?.() ?? node.abs;
937
+ return {
938
+ x: content.x - (node._scrollX ?? 0),
939
+ y: content.y - (node._scrollY ?? 0),
940
+ };
941
+ }
942
+
943
+ characterExtents(node, offset, coordType) {
944
+ const layout = node._valueLayout?.();
945
+ if (!layout || typeof layout.caretPosition !== 'function') {
946
+ return this.extentsOf(node, coordType);
947
+ }
948
+ const { chars } = textStateOf(node);
949
+ const at = clampOffset(offset, chars.length);
950
+ const pos = layout.caretPosition(at);
951
+ const next = layout.caretPosition(Math.min(at + 1, chars.length));
952
+ const origin = this._textOrigin(node);
953
+ let x = origin.x + pos.x;
954
+ let y = origin.y + pos.y;
955
+ const width = next.line === pos.line && next.x > pos.x ? next.x - pos.x : 0;
956
+ if (coordType === COORD_SCREEN) {
957
+ const screen = this.screenOriginOf(node.isWindow ? node : node.root);
958
+ x += screen.x;
959
+ y += screen.y;
960
+ }
961
+ return [
962
+ Math.round(x),
963
+ Math.round(y),
964
+ Math.round(width),
965
+ Math.round(pos.height),
966
+ ];
967
+ }
968
+
969
+ offsetAtPoint(node, x, y, coordType) {
970
+ const layout = node._valueLayout?.();
971
+ if (!layout || typeof layout.indexAt !== 'function') return -1;
972
+ let wx = x;
973
+ let wy = y;
974
+ if (coordType === COORD_SCREEN) {
975
+ const screen = this.screenOriginOf(node.isWindow ? node : node.root);
976
+ wx -= screen.x;
977
+ wy -= screen.y;
978
+ }
979
+ const origin = this._textOrigin(node);
980
+ return layout.indexAt(wx - origin.x, wy - origin.y);
981
+ }
982
+
983
+ // ---- AT-initiated behaviour -----------------------------------------
984
+
985
+ /**
986
+ * Offer an action on a scene item to the element that drew it. `true`
987
+ * means the element handled it; anything else — including not
988
+ * implementing the seam — falls through to core's default, which is why
989
+ * an element may answer only the actions it has an answer for.
990
+ */
991
+ sceneAction(node, action) {
992
+ const owner = node.a11yOwner;
993
+ if (!owner || owner.destroyed) return false;
994
+ return owner.a11ySceneAction?.(node.a11yId, action) === true;
995
+ }
996
+
997
+ /** GrabFocus. An element owns the keyboard among the items it drew — the
998
+ * window's focus manager only ever holds the element — so the item asks
999
+ * it first and otherwise focuses the element itself, which is where the
1000
+ * keys that move between items go anyway. */
1001
+ grabFocus(node) {
1002
+ if (this.sceneAction(node, 'focus')) return true;
1003
+ const target = node.a11yOwner ?? node;
1004
+ if (typeof target.focus !== 'function') return false;
1005
+ target.focus();
1006
+ return true;
1007
+ }
1008
+
1009
+ /** DoAction("activate"): a synthetic click through the same dispatch a
1010
+ * real press uses — capture, bubble, default actions, discrete-priority
1011
+ * commit and paint — so an AT's activation is indistinguishable from the
1012
+ * user's, and from the keyboard's, which reaches `synthesizeClick` too
1013
+ * (events.js). On a scene item it is the click a mouse user would make on
1014
+ * what was drawn there: the element hit-tests its own scene already, so
1015
+ * the item's rect is the whole address, and an element whose activation
1016
+ * is not a click says so through `a11ySceneAction`. */
1017
+ activate(node) {
1018
+ if (node.a11yOwner) {
1019
+ if (this.sceneAction(node, 'activate')) return true;
1020
+ if (node.destroyed) return false;
1021
+ return synthesizeClick(node.a11yOwner, node.abs);
1022
+ }
1023
+ return synthesizeClick(node, node.abs);
1024
+ }
1025
+
1026
+ editable(node) {
1027
+ if (!isTextControl(node) || node.destroyed) return false;
1028
+ if (node.props?.disabled || node.props?.['aria-readonly'] === true) {
1029
+ return false;
1030
+ }
1031
+ return acceptsTextEdits(node);
1032
+ }
1033
+
1034
+ /**
1035
+ * Replace `[start, end)` — in the displayed string, which is what the AT
1036
+ * was told — with `text`. Insert, delete and set-the-lot are all this
1037
+ * with one end moved, and a registered element answers it whole through
1038
+ * `a11yReplaceText` rather than through five methods it would have to
1039
+ * keep consistent with each other.
1040
+ */
1041
+ editText(node, start, end, text) {
1042
+ if (!this.editable(node)) return false;
1043
+ const { chars: shown } = textStateOf(node);
1044
+ const from = clampOffset(Math.min(start, end), shown.length);
1045
+ const to = clampOffset(Math.max(start, end), shown.length);
1046
+ if (!isNativeTextControl(node)) {
1047
+ return node.a11yReplaceText(from, to, String(text)) !== false;
1048
+ }
1049
+ if (typeof node._commit !== 'function') return false;
1050
+ const a = valueOffset(node, from);
1051
+ const b = valueOffset(node, to);
1052
+ const chars = node._chars();
1053
+ const insert = Array.from(String(text));
1054
+ chars.splice(a, b - a, ...insert);
1055
+ node._commit(chars, a + insert.length);
1056
+ return true;
1057
+ }
1058
+
1059
+ /** Put the caret at an offset, selecting nothing. Legitimate on a
1060
+ * read-only element too — caret browsing through a document is a read,
1061
+ * not an edit. */
1062
+ setCaret(node, offset) {
1063
+ return this.setTextSelection(node, offset, offset);
1064
+ }
1065
+
1066
+ setTextSelection(node, start, end) {
1067
+ if (node.destroyed) return false;
1068
+ if (typeof node.a11ySetSelection === 'function') {
1069
+ const { chars } = textStateOf(node);
1070
+ return (
1071
+ node.a11ySetSelection(
1072
+ clampOffset(start, chars.length),
1073
+ clampOffset(end, chars.length),
1074
+ ) !== false
1075
+ );
1076
+ }
1077
+ if (!isNativeTextControl(node)) return false;
1078
+ node._anchor = valueOffset(node, start);
1079
+ node._caret = valueOffset(node, end);
1080
+ node._repaint?.();
1081
+ return true;
1082
+ }
1083
+
1084
+ copyTextRange(node, start, end) {
1085
+ if (!isNativeTextControl(node) || node.destroyed) return;
1086
+ if (typeof node._copySelection !== 'function') return;
1087
+ const caret = node._caret;
1088
+ const anchor = node._anchor;
1089
+ const a = valueOffset(node, start);
1090
+ const b = valueOffset(node, end);
1091
+ node._anchor = Math.min(a, b);
1092
+ node._caret = Math.max(a, b);
1093
+ try {
1094
+ node._copySelection('CLIPBOARD');
1095
+ } finally {
1096
+ node._caret = caret;
1097
+ node._anchor = anchor;
1098
+ }
1099
+ }
1100
+
1101
+ // ---- signals ---------------------------------------------------------
1102
+
1103
+ _signal(path, iface, member, detail, detail1, detail2, anyData) {
1104
+ if (this.dead) return;
1105
+ try {
1106
+ this.bus.sendSignal(path, iface, member, EVENT_SIGNATURE, [
1107
+ detail,
1108
+ detail1 | 0,
1109
+ detail2 | 0,
1110
+ anyData ?? ['i', 0],
1111
+ [],
1112
+ ]);
1113
+ } catch {
1114
+ // a marshalling failure on one event must not take down dispatch;
1115
+ // the tree itself stays queryable
1116
+ }
1117
+ }
1118
+
1119
+ emitObject(node, member, detail = '', d1 = 0, d2 = 0, anyData = null) {
1120
+ this._signal(
1121
+ this.pathOf(node),
1122
+ IFACE.EVENT_OBJECT,
1123
+ member,
1124
+ detail,
1125
+ d1,
1126
+ d2,
1127
+ anyData,
1128
+ );
1129
+ }
1130
+
1131
+ emitWindow(node, member) {
1132
+ this._signal(this.pathOf(node), IFACE.EVENT_WINDOW, member, '', 0, 0, [
1133
+ 's',
1134
+ a11yName(node),
1135
+ ]);
1136
+ }
1137
+
1138
+ emitStateChanged(node, stateBit, on) {
1139
+ const nick = ATSPI_STATE_NICK[stateBit];
1140
+ if (!nick) return;
1141
+ this.emitObject(node, 'StateChanged', nick, on ? 1 : 0, 0, ['i', 0]);
1142
+ }
1143
+
1144
+ _cacheItemFor(node) {
1145
+ return [
1146
+ this.refFor(node),
1147
+ this.rootRef(),
1148
+ this.parentRef(node),
1149
+ AccessibleImpl.GetIndexInParent.call({ b: this, n: node }),
1150
+ a11yChildren(node).length,
1151
+ this.interfacesOf(node),
1152
+ a11yName(node),
1153
+ atspiRoleOf(node),
1154
+ a11yDescription(node),
1155
+ a11yStates(node),
1156
+ ];
1157
+ }
1158
+
1159
+ emitCacheAdd(node) {
1160
+ if (this.dead) return;
1161
+ try {
1162
+ this.bus.sendSignal(
1163
+ CACHE_PATH,
1164
+ IFACE.CACHE,
1165
+ 'AddAccessible',
1166
+ CACHE_ITEM_SIGNATURE,
1167
+ [this._cacheItemFor(node)],
1168
+ );
1169
+ } catch {
1170
+ // as _signal
1171
+ }
1172
+ }
1173
+
1174
+ emitCacheRemove(ref) {
1175
+ if (this.dead) return;
1176
+ try {
1177
+ this.bus.sendSignal(CACHE_PATH, IFACE.CACHE, 'RemoveAccessible', '(so)', [
1178
+ ref,
1179
+ ]);
1180
+ } catch {
1181
+ // as _signal
1182
+ }
1183
+ }
1184
+
1185
+ // ---- the queue -------------------------------------------------------
1186
+
1187
+ _schedule() {
1188
+ if (this._flushScheduled || this.dead) return;
1189
+ this._flushScheduled = true;
1190
+ queueMicrotask(() => this.flush());
1191
+ }
1192
+
1193
+ /** Is this node under a toplevel the bridge is presenting? Detached
1194
+ * subtrees under construction are not, which is what keeps the initial
1195
+ * bottom-up build of a tree from queueing one event per node. */
1196
+ _live(node) {
1197
+ let n = node;
1198
+ while (n) {
1199
+ if (!n.parent) return this.toplevels.includes(n);
1200
+ n = n.parent;
1201
+ }
1202
+ return false;
1203
+ }
1204
+
1205
+ queueAttach(node) {
1206
+ this._pending.attach.add(node);
1207
+ this._schedule();
1208
+ }
1209
+
1210
+ queueRemove(parent, child, index) {
1211
+ this._pending.remove.push({ parent, child, index });
1212
+ this._schedule();
1213
+ }
1214
+
1215
+ queueProps(node) {
1216
+ this._pending.props.add(node);
1217
+ this._schedule();
1218
+ }
1219
+
1220
+ queueText(node) {
1221
+ this._pending.text.add(node);
1222
+ this._schedule();
1223
+ }
1224
+
1225
+ queueFocus(previous, next) {
1226
+ this._pending.focus.push({ previous, next });
1227
+ this._schedule();
1228
+ }
1229
+
1230
+ queueWindowFocus(win, focused) {
1231
+ this._pending.window.push({ win, focused });
1232
+ this._schedule();
1233
+ }
1234
+
1235
+ flush() {
1236
+ if (this.dead) return;
1237
+ this._flushScheduled = false;
1238
+ const p = this._pending;
1239
+ if (
1240
+ p.attach.size === 0 &&
1241
+ p.remove.length === 0 &&
1242
+ p.props.size === 0 &&
1243
+ p.text.size === 0 &&
1244
+ p.focus.length === 0 &&
1245
+ p.window.length === 0
1246
+ ) {
1247
+ return;
1248
+ }
1249
+ this._pending = {
1250
+ attach: new Set(),
1251
+ remove: [],
1252
+ props: new Set(),
1253
+ text: new Set(),
1254
+ focus: [],
1255
+ window: [],
1256
+ };
1257
+
1258
+ // Removals first: a node that left and came back in one batch reads as
1259
+ // its add, never as a stale remove.
1260
+ for (const { parent, child, index } of p.remove) {
1261
+ const stillIn =
1262
+ parent === null
1263
+ ? this.toplevels.includes(child)
1264
+ : child.parent === parent;
1265
+ if (stillIn) continue;
1266
+ const wasExported = this.exported.has(child);
1267
+ const childPath = this.pathOf(child);
1268
+ const childRef = [this.bus.name, childPath];
1269
+ // unexport the whole subtree the AT may hold refs into — the drawn
1270
+ // children included, read from what the element last said rather than
1271
+ // by asking one that is being torn down; each exported node also
1272
+ // tells the client caches it is gone
1273
+ const walk = (n) => {
1274
+ for (const c of n.children ?? []) walk(c);
1275
+ for (const item of knownSceneItems(n)) walk(item);
1276
+ this._unexport(n);
1277
+ };
1278
+ walk(child);
1279
+ // Whatever the AT never saw, it does not need to hear about.
1280
+ if (!wasExported) continue;
1281
+ if (child.isWindow && !child.parent) {
1282
+ this._signal(childPath, IFACE.EVENT_WINDOW, 'Destroy', '', 0, 0, [
1283
+ 's',
1284
+ '',
1285
+ ]);
1286
+ }
1287
+ if (parent === null || this.exported.has(parent)) {
1288
+ this._signal(
1289
+ parent === null ? ROOT_PATH : this.pathOf(parent),
1290
+ IFACE.EVENT_OBJECT,
1291
+ 'ChildrenChanged',
1292
+ 'remove',
1293
+ index,
1294
+ 0,
1295
+ ['(so)', childRef],
1296
+ );
1297
+ }
1298
+ }
1299
+
1300
+ // Attaches, collapsed to the topmost node per subtree: the AT descends
1301
+ // from there on its own.
1302
+ for (const node of p.attach) {
1303
+ if (node.destroyed || !this._live(node)) continue;
1304
+ let covered = false;
1305
+ for (let a = node.parent; a; a = a.parent) {
1306
+ if (p.attach.has(a)) {
1307
+ covered = true;
1308
+ break;
1309
+ }
1310
+ }
1311
+ if (covered) continue;
1312
+ const wasExported = this.exported.has(node);
1313
+ const parent = a11yParent(node);
1314
+ const index = parent
1315
+ ? a11yIndexIn(parent, node)
1316
+ : this.toplevels.indexOf(node);
1317
+ if (!wasExported) this.emitCacheAdd(node);
1318
+ this._signal(
1319
+ parent ? this.pathOf(parent) : ROOT_PATH,
1320
+ IFACE.EVENT_OBJECT,
1321
+ 'ChildrenChanged',
1322
+ 'add',
1323
+ index,
1324
+ 0,
1325
+ ['(so)', this.refFor(node)],
1326
+ );
1327
+ if (node.isWindow && !node.parent) this.emitWindow(node, 'Create');
1328
+ }
1329
+
1330
+ for (const node of p.props) this.syncNode(node);
1331
+ for (const node of p.text) this.syncText(node);
1332
+
1333
+ for (const { previous, next } of p.focus) {
1334
+ if (previous && this.exported.has(previous) && !previous.destroyed) {
1335
+ this.emitStateChanged(previous, ATSPI_STATE.FOCUSED, false);
1336
+ this._refreshSnapshotStates(previous);
1337
+ }
1338
+ if (next && !next.destroyed && this._live(next)) {
1339
+ this.ensureExported(next);
1340
+ this.emitStateChanged(next, ATSPI_STATE.FOCUSED, true);
1341
+ this._signal(this.pathOf(next), IFACE.EVENT_FOCUS, 'Focus', '', 0, 0, [
1342
+ 'i',
1343
+ 0,
1344
+ ]);
1345
+ this._refreshSnapshotStates(next);
1346
+ }
1347
+ }
1348
+
1349
+ const lastWindow = new Map();
1350
+ for (const { win, focused } of p.window) lastWindow.set(win, focused);
1351
+ for (const [win, focused] of lastWindow) {
1352
+ if (win.destroyed || !this.exported.has(win)) continue;
1353
+ this.emitWindow(win, focused ? 'Activate' : 'Deactivate');
1354
+ this.emitStateChanged(win, ATSPI_STATE.ACTIVE, focused);
1355
+ this._refreshSnapshotStates(win);
1356
+ }
1357
+ }
1358
+
1359
+ _refreshSnapshotStates(node) {
1360
+ const entry = this.exported.get(node);
1361
+ if (entry?.snapshot) entry.snapshot.states = a11yStates(node);
1362
+ }
1363
+
1364
+ /** Recompute what the bus was last told about a node and emit the exact
1365
+ * difference. Every "some prop changed" notification lands here, which is
1366
+ * what makes the wiring correct without per-prop plumbing. */
1367
+ syncNode(node) {
1368
+ const entry = this.exported.get(node);
1369
+ if (!entry || node.destroyed) return;
1370
+ const before = entry.snapshot;
1371
+ // interface set can change (a value appearing, a handler added)
1372
+ const wanted = this._ifaceSetFor(node);
1373
+ if (wanted.join() !== entry.ifaces.join()) {
1374
+ const path = NODE_PATH + entry.id;
1375
+ for (const name of entry.ifaces) {
1376
+ if (!wanted.includes(name)) this.bus.unexportInterface(path, name);
1377
+ }
1378
+ for (const name of wanted) {
1379
+ if (!entry.ifaces.includes(name)) {
1380
+ const [desc, proto] = this._descriptorFor(name);
1381
+ this.bus.exportInterface(this._implFor(proto, node), path, desc);
1382
+ }
1383
+ }
1384
+ entry.ifaces = wanted;
1385
+ }
1386
+ const now = this._snapshot(node);
1387
+ now.ifaces = entry.ifaces;
1388
+ entry.snapshot = now;
1389
+ if (now.name !== before.name) {
1390
+ this.emitObject(node, 'PropertyChange', 'accessible-name', 0, 0, [
1391
+ 's',
1392
+ now.name,
1393
+ ]);
1394
+ }
1395
+ if (now.description !== before.description) {
1396
+ this.emitObject(node, 'PropertyChange', 'accessible-description', 0, 0, [
1397
+ 's',
1398
+ now.description,
1399
+ ]);
1400
+ }
1401
+ if (now.value !== before.value && now.value !== null) {
1402
+ this.emitObject(node, 'PropertyChange', 'accessible-value', 0, 0, [
1403
+ 'd',
1404
+ now.value,
1405
+ ]);
1406
+ }
1407
+ for (const half of [0, 1]) {
1408
+ let changed = (before.states[half] ^ now.states[half]) >>> 0;
1409
+ while (changed !== 0) {
1410
+ const low = changed & -changed;
1411
+ const stateBit = 31 - Math.clz32(low) + half * 32;
1412
+ this.emitStateChanged(node, stateBit, Boolean(now.states[half] & low));
1413
+ changed = (changed ^ low) >>> 0;
1414
+ }
1415
+ }
1416
+ if (before.text && now.text) this._diffText(node, before.text, now.text);
1417
+ if (before.scene.length > 0 || now.scene.length > 0) {
1418
+ this._diffScene(node, before.scene, now.scene);
1419
+ }
1420
+ }
1421
+
1422
+ /**
1423
+ * The items an element drew, as children arriving and leaving. Identity
1424
+ * is the element's own id, resolved in a11y.js, so a scene rebuilt every
1425
+ * frame produces events only where something actually changed — and a
1426
+ * surviving item is re-diffed through `syncNode`, the same path a
1427
+ * `<box>`'s props take, which is what gets its name and its selected
1428
+ * state onto the bus without a second diff living here.
1429
+ */
1430
+ _diffScene(parent, before, now) {
1431
+ const kept = new Set(now);
1432
+ for (const item of before) {
1433
+ if (kept.has(item)) continue;
1434
+ if (!this.exported.has(item)) continue;
1435
+ const ref = [this.bus.name, this.pathOf(item)];
1436
+ const walk = (n) => {
1437
+ for (const c of n.children ?? []) walk(c);
1438
+ this._unexport(n);
1439
+ };
1440
+ walk(item);
1441
+ this._signal(
1442
+ this.pathOf(parent),
1443
+ IFACE.EVENT_OBJECT,
1444
+ 'ChildrenChanged',
1445
+ 'remove',
1446
+ // where it was: the retained children it sat behind, then its own
1447
+ // place in the scene it has just left
1448
+ a11yChildren(parent).length - now.length + before.indexOf(item),
1449
+ 0,
1450
+ ['(so)', ref],
1451
+ );
1452
+ }
1453
+ const gone = new Set(before);
1454
+ for (const item of now) {
1455
+ if (gone.has(item)) {
1456
+ this.syncNode(item);
1457
+ continue;
1458
+ }
1459
+ this.emitCacheAdd(item);
1460
+ this._signal(
1461
+ this.pathOf(parent),
1462
+ IFACE.EVENT_OBJECT,
1463
+ 'ChildrenChanged',
1464
+ 'add',
1465
+ a11yIndexIn(parent, item),
1466
+ 0,
1467
+ ['(so)', this.refFor(item)],
1468
+ );
1469
+ }
1470
+ }
1471
+
1472
+ syncText(node) {
1473
+ const entry = this.exported.get(node);
1474
+ if (!entry || node.destroyed || !entry.snapshot?.text) return;
1475
+ const before = entry.snapshot.text;
1476
+ const now = textStateOf(node);
1477
+ entry.snapshot.text = now;
1478
+ this._diffText(node, before, now);
1479
+ }
1480
+
1481
+ /**
1482
+ * The difference as AT-SPI events, with the composition's own churn
1483
+ * marked `:system` — the detail suffix Gecko established for a change the
1484
+ * user did not type, and which at-spi2-core's event dispatch already
1485
+ * parses. A preedit appearing, growing or being abandoned is exactly
1486
+ * that: nothing was typed, a decoration is on the screen.
1487
+ *
1488
+ * The **commit** stays a plain `insert`. `dead_acute` then `e` deletes
1489
+ * the `´` — inside the old preedit, so `delete:system` — and inserts `é`,
1490
+ * which is in no preedit and is what the user actually typed. A reader
1491
+ * that suppresses system text says "é" and nothing else, which is the
1492
+ * distinction this path exists to draw.
1493
+ */
1494
+ _diffText(node, before, now) {
1495
+ const diff = diffChars(before.chars, now.chars);
1496
+ if (diff) {
1497
+ if (diff.removed.length > 0) {
1498
+ // what it replaced belonged to the *previous* state's composition
1499
+ const system = inPreedit(before, diff.offset, diff.removed.length);
1500
+ this.emitObject(
1501
+ node,
1502
+ 'TextChanged',
1503
+ system ? 'delete:system' : 'delete',
1504
+ diff.offset,
1505
+ diff.removed.length,
1506
+ ['s', diff.removed.join('')],
1507
+ );
1508
+ }
1509
+ if (diff.inserted.length > 0) {
1510
+ const system = inPreedit(now, diff.offset, diff.inserted.length);
1511
+ this.emitObject(
1512
+ node,
1513
+ 'TextChanged',
1514
+ system ? 'insert:system' : 'insert',
1515
+ diff.offset,
1516
+ diff.inserted.length,
1517
+ ['s', diff.inserted.join('')],
1518
+ );
1519
+ }
1520
+ }
1521
+ if (now.caret !== before.caret) {
1522
+ this.emitObject(node, 'TextCaretMoved', '', now.caret, 0, ['i', 0]);
1523
+ }
1524
+ const selectionChanged =
1525
+ now.selection[0] !== before.selection[0] ||
1526
+ now.selection[1] !== before.selection[1];
1527
+ if (selectionChanged) {
1528
+ this.emitObject(node, 'TextSelectionChanged', '', 0, 0, ['i', 0]);
1529
+ }
1530
+ }
1531
+
1532
+ // ---- the application root -------------------------------------------
1533
+
1534
+ exportRoot() {
1535
+ const bridge = this;
1536
+ const rootAccessible = {
1537
+ get Name() {
1538
+ return programName();
1539
+ },
1540
+ get Description() {
1541
+ return '';
1542
+ },
1543
+ get Parent() {
1544
+ return bridge.desktop;
1545
+ },
1546
+ get ChildCount() {
1547
+ return bridge.toplevels.length;
1548
+ },
1549
+ get Locale() {
1550
+ return localeString();
1551
+ },
1552
+ get AccessibleId() {
1553
+ return '';
1554
+ },
1555
+ GetChildAtIndex(index) {
1556
+ const win = bridge.toplevels[index];
1557
+ return win ? bridge.refFor(win) : bridge.nullRef();
1558
+ },
1559
+ GetChildren() {
1560
+ return bridge.toplevels.map((win) => bridge.refFor(win));
1561
+ },
1562
+ GetIndexInParent() {
1563
+ return -1;
1564
+ },
1565
+ GetRelationSet() {
1566
+ return [];
1567
+ },
1568
+ GetRole() {
1569
+ return ATSPI_ROLE.APPLICATION;
1570
+ },
1571
+ GetRoleName() {
1572
+ return 'application';
1573
+ },
1574
+ GetLocalizedRoleName() {
1575
+ return 'application';
1576
+ },
1577
+ GetState() {
1578
+ return [0, 0];
1579
+ },
1580
+ GetAttributes() {
1581
+ return [['toolkit', 'react-x11']];
1582
+ },
1583
+ GetApplication() {
1584
+ return bridge.rootRef();
1585
+ },
1586
+ GetInterfaces() {
1587
+ return [IFACE.ACCESSIBLE, IFACE.APPLICATION, IFACE.SOCKET];
1588
+ },
1589
+ };
1590
+ const application = {
1591
+ ToolkitName: 'react-x11',
1592
+ get Version() {
1593
+ return bridge.toolkitVersion;
1594
+ },
1595
+ AtspiVersion: ATSPI_VERSION,
1596
+ get Id() {
1597
+ return bridge.appId;
1598
+ },
1599
+ set Id(value) {
1600
+ bridge.appId = value | 0;
1601
+ },
1602
+ GetLocale() {
1603
+ return localeString();
1604
+ },
1605
+ GetApplicationBusAddress() {
1606
+ return '';
1607
+ },
1608
+ };
1609
+ const socket = {
1610
+ Embedded() {
1611
+ return null;
1612
+ },
1613
+ Unembed() {
1614
+ return null;
1615
+ },
1616
+ };
1617
+ const cache = {
1618
+ GetItems() {
1619
+ const items = [bridge._rootCacheItem(rootAccessible)];
1620
+ const walk = (node) => {
1621
+ items.push(bridge._cacheItemFor(node));
1622
+ for (const child of a11yChildren(node)) walk(child);
1623
+ };
1624
+ for (const win of bridge.toplevels) walk(win);
1625
+ return items;
1626
+ },
1627
+ };
1628
+ this.bus.exportInterface(rootAccessible, ROOT_PATH, ACCESSIBLE_DESC);
1629
+ this.bus.exportInterface(application, ROOT_PATH, APPLICATION_DESC);
1630
+ this.bus.exportInterface(socket, ROOT_PATH, SOCKET_DESC);
1631
+ this.bus.exportInterface(cache, CACHE_PATH, CACHE_DESC);
1632
+ }
1633
+
1634
+ /** The cache item for the application root itself, which is not a node. */
1635
+ _rootCacheItem(rootAccessible) {
1636
+ return [
1637
+ this.rootRef(),
1638
+ this.rootRef(),
1639
+ this.desktop,
1640
+ -1,
1641
+ this.toplevels.length,
1642
+ rootAccessible.GetInterfaces(),
1643
+ rootAccessible.Name,
1644
+ ATSPI_ROLE.APPLICATION,
1645
+ '',
1646
+ [0, 0],
1647
+ ];
1648
+ }
1649
+
1650
+ // ---- registration ----------------------------------------------------
1651
+
1652
+ async embed() {
1653
+ const reply = await this.bus.invoke({
1654
+ destination: REGISTRY_NAME,
1655
+ path: ROOT_PATH,
1656
+ interface: IFACE.SOCKET,
1657
+ member: 'Embed',
1658
+ signature: '(so)',
1659
+ body: [[this.bus.name, ROOT_PATH]],
1660
+ });
1661
+ if (Array.isArray(reply) && reply.length === 2) this.desktop = reply;
1662
+ }
1663
+
1664
+ /** The registry restarting is survivable: watch its name and re-embed
1665
+ * under the new owner, exactly as the GTK bridge does. */
1666
+ async watchRegistry() {
1667
+ const rule =
1668
+ "type='signal',sender='org.freedesktop.DBus'," +
1669
+ "interface='org.freedesktop.DBus',member='NameOwnerChanged'," +
1670
+ `arg0='${REGISTRY_NAME}'`;
1671
+ await this.bus.addMatch(rule);
1672
+ const key = this.bus.mangle(
1673
+ '/org/freedesktop/DBus',
1674
+ 'org.freedesktop.DBus',
1675
+ 'NameOwnerChanged',
1676
+ );
1677
+ this.bus.signals.on(key, (body) => {
1678
+ const newOwner = body?.[2];
1679
+ if (newOwner) this.embed().catch(() => {});
1680
+ });
1681
+ }
1682
+
1683
+ // ---- wiring into the renderer ---------------------------------------
1684
+
1685
+ install() {
1686
+ hooks.rootMounted = (win) => {
1687
+ if (this.toplevels.includes(win)) return;
1688
+ this.toplevels.push(win);
1689
+ this.queueAttach(win);
1690
+ };
1691
+ hooks.rootUnmounted = (win) => {
1692
+ const index = this.toplevels.indexOf(win);
1693
+ if (index === -1) return;
1694
+ this.toplevels.splice(index, 1);
1695
+ this.queueRemove(null, win, index);
1696
+ };
1697
+ hooks.attached = (parent, child) => {
1698
+ if (!this._live(parent)) return;
1699
+ this.queueAttach(child);
1700
+ };
1701
+ hooks.detach = (parent, child) => {
1702
+ if (!this.exported.has(child) && !this._live(parent)) return;
1703
+ this.queueRemove(parent, child, a11yIndexIn(parent, child));
1704
+ };
1705
+ hooks.propsChanged = (node) => {
1706
+ if (this.exported.has(node)) this.queueProps(node);
1707
+ };
1708
+ hooks.textContent = (chunk) => {
1709
+ let text = chunk.parent;
1710
+ while (text && text.kind === 'text' && text.isSpan) text = text.parent;
1711
+ if (!text) return;
1712
+ if (this.exported.has(text)) this.queueText(text);
1713
+ // labels feed names-from-contents anywhere above them
1714
+ for (let a = text; a; a = a11yParent(a)) {
1715
+ if (this.exported.has(a)) this.queueProps(a);
1716
+ if (a.isWindow) break;
1717
+ }
1718
+ };
1719
+ hooks.textState = (node) => {
1720
+ if (this.exported.has(node)) this.queueText(node);
1721
+ };
1722
+ hooks.focus = (previous, next) => {
1723
+ this.queueFocus(previous, next);
1724
+ };
1725
+ hooks.windowFocus = (win, focused) => {
1726
+ this.queueWindowFocus(win, focused);
1727
+ };
1728
+ hooks.commit = () => this.flush();
1729
+ hooks.announce = (text, opts) => {
1730
+ // spoken from the active window, which is the context the user is in
1731
+ const target =
1732
+ this.toplevels.find((win) => win.events?.windowFocused) ??
1733
+ this.toplevels[0] ??
1734
+ null;
1735
+ if (target) this.ensureExported(target);
1736
+ this._signal(
1737
+ target ? this.pathOf(target) : ROOT_PATH,
1738
+ IFACE.EVENT_OBJECT,
1739
+ 'Announcement',
1740
+ '',
1741
+ opts?.assertive ? 2 : 1, // AtspiLive: 1 polite, 2 assertive
1742
+ 0,
1743
+ ['s', String(text)],
1744
+ );
1745
+ return true;
1746
+ };
1747
+
1748
+ // toplevels that mounted before the bridge finished connecting
1749
+ this._unsubscribes.push(
1750
+ onApp((app) => {
1751
+ for (const win of app._rootChildren ?? []) {
1752
+ hooks.rootMounted(win);
1753
+ }
1754
+ }),
1755
+ );
1756
+ }
1757
+
1758
+ bury() {
1759
+ if (this.dead) return;
1760
+ this.dead = true;
1761
+ for (const key of Object.keys(hooks)) hooks[key] = null;
1762
+ for (const unsubscribe of this._unsubscribes) unsubscribe();
1763
+ this._unsubscribes = [];
1764
+ }
1765
+ }
1766
+
1767
+ // --------------------------------------------------------------------------
1768
+ // The ladder
1769
+ // --------------------------------------------------------------------------
1770
+
1771
+ let bridge = null;
1772
+
1773
+ /** Wait for a fresh dbus-native client to finish its handshake and its
1774
+ * Hello, or throw with the reason it could not. */
1775
+ async function connected(bus) {
1776
+ await new Promise((resolve, reject) => {
1777
+ const conn = bus.connection;
1778
+ const cleanup = () => {
1779
+ conn.removeListener('connect', onConnect);
1780
+ conn.removeListener('error', onError);
1781
+ conn.removeListener('close', onClose);
1782
+ };
1783
+ const onConnect = () => {
1784
+ cleanup();
1785
+ resolve();
1786
+ };
1787
+ const onError = (err) => {
1788
+ cleanup();
1789
+ reject(err);
1790
+ };
1791
+ const onClose = () => {
1792
+ cleanup();
1793
+ reject(new Error('the bus closed during the handshake'));
1794
+ };
1795
+ conn.once('connect', onConnect);
1796
+ conn.on('error', onError);
1797
+ conn.once('close', onClose);
1798
+ });
1799
+ // one round trip so bus.name (the Hello reply) is settled
1800
+ await bus.listNames();
1801
+ if (bus.name == null) {
1802
+ throw new Error('the peer answered but never assigned a unique name');
1803
+ }
1804
+ }
1805
+
1806
+ /**
1807
+ * Ask the session bus where the accessibility bus is. `org.a11y.Bus` is
1808
+ * D-Bus-activatable, so this also starts the launcher on desktops where it
1809
+ * is not already running.
1810
+ *
1811
+ * Deliberately **not** the shared `sessionBus()` connection. Discovery is
1812
+ * one call at process startup; riding the shared machinery would leave the
1813
+ * warm shared socket open as the side effect of a probe, and would race
1814
+ * anything that swaps `DBUS_SESSION_BUS_ADDRESS` and resets the shared
1815
+ * state under an in-flight climb — which is exactly what a test harness
1816
+ * (this repo's own included) does between cases. A private connection,
1817
+ * closed before this returns, cannot interfere with anyone.
1818
+ */
1819
+ async function accessibilityBusAddress(dbus) {
1820
+ if (process.env.AT_SPI_BUS_ADDRESS) return process.env.AT_SPI_BUS_ADDRESS;
1821
+ const busAddress = addressFor('session');
1822
+ if (!busAddress && process.platform !== 'darwin') return null;
1823
+ let sbus = null;
1824
+ try {
1825
+ sbus = dbus.createClient({ busAddress });
1826
+ await connected(sbus);
1827
+ const address = await sbus.invoke({
1828
+ destination: 'org.a11y.Bus',
1829
+ path: '/org/a11y/bus',
1830
+ interface: 'org.a11y.Bus',
1831
+ member: 'GetAddress',
1832
+ });
1833
+ return typeof address === 'string' && address !== '' ? address : null;
1834
+ } catch {
1835
+ return null;
1836
+ } finally {
1837
+ if (sbus) {
1838
+ silenceAndEnd(sbus);
1839
+ }
1840
+ }
1841
+ }
1842
+
1843
+ function silenceAndEnd(abus) {
1844
+ try {
1845
+ abus.connection.removeAllListeners('error');
1846
+ abus.connection.on('error', () => {});
1847
+ abus.connection.end?.();
1848
+ } catch {
1849
+ // it was never usable; nothing to say
1850
+ }
1851
+ }
1852
+
1853
+ /**
1854
+ * Climb the ladder and return the live bridge, or null. Called once per
1855
+ * process through `startA11y()`; every rung that fails reports why when
1856
+ * REACT_X11_A11Y is set and stays silent otherwise — "no accessibility
1857
+ * stack here" is a normal configuration, not an error.
1858
+ */
1859
+ export async function start() {
1860
+ if (bridge) return bridge.dead ? null : bridge;
1861
+ const loud = Boolean(process.env.REACT_X11_A11Y);
1862
+ const off = (why, cause) => {
1863
+ if (loud) {
1864
+ console.warn(
1865
+ `react-x11: accessibility off — ${why}` +
1866
+ (cause ? ` (${cause.message ?? cause})` : ''),
1867
+ );
1868
+ }
1869
+ return null;
1870
+ };
1871
+
1872
+ let dbus;
1873
+ try {
1874
+ dbus = await loadTransport();
1875
+ } catch (cause) {
1876
+ return off('dbus-native is not installed (npm i dbus-native)', cause);
1877
+ }
1878
+
1879
+ const address = await accessibilityBusAddress(dbus);
1880
+ if (!address) {
1881
+ return off(
1882
+ 'no accessibility bus (no session bus, or no at-spi on this desktop)',
1883
+ );
1884
+ }
1885
+
1886
+ let abus;
1887
+ try {
1888
+ abus = dbus.createClient({ busAddress: address });
1889
+ } catch (cause) {
1890
+ return off(`could not parse the accessibility bus address`, cause);
1891
+ }
1892
+
1893
+ try {
1894
+ await connected(abus);
1895
+ } catch (cause) {
1896
+ silenceAndEnd(abus);
1897
+ return off('could not connect to the accessibility bus', cause);
1898
+ }
1899
+
1900
+ // Never hold the process open: the bridge is a passenger, not cargo. If
1901
+ // the app has no other work, exiting is correct and the registry sees the
1902
+ // name drop.
1903
+ abus.connection.stream?.unref?.();
1904
+
1905
+ let version = '0.0.0';
1906
+ try {
1907
+ const { createRequire } = await import('node:module');
1908
+ version = createRequire(import.meta.url)('../package.json').version;
1909
+ } catch {
1910
+ // bundled; the toolkit version is cosmetic
1911
+ }
1912
+
1913
+ const b = new AtspiBridge(abus, version);
1914
+ abus.connection.on('error', () => b.bury());
1915
+ abus.connection.once('close', () => b.bury());
1916
+
1917
+ b.exportRoot();
1918
+ try {
1919
+ await b.embed();
1920
+ } catch (cause) {
1921
+ // A bus with no registry is unusual but not fatal: the watch below
1922
+ // embeds the moment one appears.
1923
+ if (loud) {
1924
+ console.warn(
1925
+ 'react-x11: accessibility registry not reachable yet:',
1926
+ cause?.message ?? cause,
1927
+ );
1928
+ }
1929
+ }
1930
+ b.watchRegistry().catch(() => {});
1931
+ b.install();
1932
+ bridge = b;
1933
+ if (loud) {
1934
+ console.warn(
1935
+ `react-x11: accessibility bridge live on ${abus.name} (${address})`,
1936
+ );
1937
+ }
1938
+ return b;
1939
+ }
1940
+
1941
+ /** Test seam: tear the bridge down and forget it, so the next start() can
1942
+ * climb the ladder again against a different bus. */
1943
+ export async function _stopForTests() {
1944
+ if (!bridge) return;
1945
+ const b = bridge;
1946
+ bridge = null;
1947
+ b.bury();
1948
+ try {
1949
+ await b.bus.close();
1950
+ } catch {
1951
+ // already gone
1952
+ }
1953
+ }