react-x11 2.16.1 → 2.17.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 (54) hide show
  1. package/README.md +38 -23
  2. package/package.json +3 -1
  3. package/src/Reconciler.js +82 -23
  4. package/src/a11y.js +18 -1
  5. package/src/appcontext.js +8 -0
  6. package/src/appearance.js +36 -0
  7. package/src/{cocoa → backend}/context2d.js +27 -7
  8. package/src/capabilities.js +99 -1
  9. package/src/cocoa/app.js +8 -4
  10. package/src/cocoa/fonts.js +1 -1
  11. package/src/cocoa/overlay.js +2 -2
  12. package/src/cocoa/panewindow.js +2 -2
  13. package/src/cocoa/presenter.js +2 -2
  14. package/src/cocoa/surface.js +3 -3
  15. package/src/cocoa/window.js +2 -2
  16. package/src/events.js +21 -0
  17. package/src/foreignnodes.js +8 -3
  18. package/src/frame/index.js +30 -4
  19. package/src/glnodes.js +12 -1
  20. package/src/idle.js +59 -1
  21. package/src/index.d.ts +41 -0
  22. package/src/index.js +30 -3
  23. package/src/launcher.js +17 -8
  24. package/src/launcherhooks.js +24 -10
  25. package/src/node.d.ts +1 -1
  26. package/src/nodes/cascade.js +9 -0
  27. package/src/nodes/node.js +6 -1
  28. package/src/nodes/window/hints.js +21 -2
  29. package/src/nodes/window/window.js +2 -2
  30. package/src/notifications.js +39 -14
  31. package/src/taskbarhooks.js +164 -0
  32. package/src/transfer.js +20 -1
  33. package/src/trayhooks.js +1 -1
  34. package/src/types/capabilities.d.ts +32 -3
  35. package/src/types/elements.d.ts +23 -1
  36. package/src/types/events.d.ts +16 -0
  37. package/src/types/launcher.d.ts +20 -6
  38. package/src/types/taskbar.d.ts +79 -0
  39. package/src/wayland/context2d.js +1 -1
  40. package/src/win32/a11y.js +604 -0
  41. package/src/win32/app.js +768 -0
  42. package/src/win32/bezels.js +158 -0
  43. package/src/win32/dnd.js +283 -0
  44. package/src/win32/fonts.js +497 -0
  45. package/src/win32/glarea.js +548 -0
  46. package/src/win32/ime.js +267 -0
  47. package/src/win32/keymap.js +116 -0
  48. package/src/win32/native.js +54 -0
  49. package/src/win32/panehost.js +106 -0
  50. package/src/win32/panewindow.js +343 -0
  51. package/src/win32/shell.js +426 -0
  52. package/src/win32/surface.js +192 -0
  53. package/src/win32/window.js +659 -0
  54. package/src/windowid.js +66 -0
@@ -0,0 +1,604 @@
1
+ // The accessibility bridge for Windows: the same tree, pushed to UI
2
+ // Automation.
3
+ //
4
+ // `src/atspi.js` answers AT-SPI's questions from the **live** tree, because
5
+ // AT-SPI asks over a bus, asynchronously, and JS answers when it gets to
6
+ // them. UIA asks synchronously and often — one focus change is dozens of
7
+ // property reads — so answering from JS would pace the screen reader by the
8
+ // application's busiest moment. The bridge therefore *pushes*, and
9
+ // `windows/src/uia.cc` answers from its copy. The reasoning is
10
+ // docs/windows.md §"Accessibility: UI Automation"; what matters here is the
11
+ // consequence: **this file's job is a diff, not an answer.**
12
+ //
13
+ // Everything it pushes comes from `src/a11y.js`, which is pure functions over
14
+ // the live tree and is what the AT-SPI bridge reads too. Nothing about roles,
15
+ // names, states or values is decided twice — the only thing that is Windows'
16
+ // own is the last translation, from the one canonical role to UIA's control
17
+ // type, and that table is below where it can be read and tested.
18
+ //
19
+ // It fills the same `hooks` slots `atspi.js` fills, and only one bridge is
20
+ // ever installed in a process (`startA11y()`).
21
+ import {
22
+ ATSPI_ROLE,
23
+ ATSPI_STATE,
24
+ a11yChildren,
25
+ a11yDescription,
26
+ a11yName,
27
+ a11yStates,
28
+ a11yValue,
29
+ a11yActivatable,
30
+ atspiRoleOf,
31
+ hooks,
32
+ isNativeTextControl,
33
+ } from '../a11y.js';
34
+ import { synthesizeClick } from '../events.js';
35
+ import { onApp } from '../trace-registry.js';
36
+
37
+ const TRACE = process.env.REACT_X11_TRACE_A11Y === '1';
38
+
39
+ // --------------------------------------------------------------------------
40
+ // The one translation that is Windows' own
41
+ // --------------------------------------------------------------------------
42
+
43
+ /**
44
+ * UIA's control type ids. Spelled out rather than imported because the bridge
45
+ * is the only thing that needs them and a table of forty numbers is easier to
46
+ * check against Microsoft's list when it is one table.
47
+ */
48
+ const UIA = Object.freeze({
49
+ Button: 50000,
50
+ Calendar: 50001,
51
+ CheckBox: 50002,
52
+ ComboBox: 50003,
53
+ Edit: 50004,
54
+ Hyperlink: 50005,
55
+ Image: 50006,
56
+ ListItem: 50007,
57
+ List: 50008,
58
+ Menu: 50009,
59
+ MenuBar: 50010,
60
+ MenuItem: 50011,
61
+ ProgressBar: 50012,
62
+ RadioButton: 50013,
63
+ ScrollBar: 50014,
64
+ Slider: 50015,
65
+ Spinner: 50016,
66
+ StatusBar: 50017,
67
+ Tab: 50018,
68
+ TabItem: 50019,
69
+ Text: 50020,
70
+ ToolBar: 50021,
71
+ ToolTip: 50022,
72
+ Tree: 50023,
73
+ TreeItem: 50024,
74
+ Custom: 50025,
75
+ Group: 50026,
76
+ Thumb: 50027,
77
+ DataGrid: 50028,
78
+ DataItem: 50029,
79
+ Document: 50030,
80
+ SplitButton: 50031,
81
+ Window: 50032,
82
+ Pane: 50033,
83
+ Header: 50034,
84
+ HeaderItem: 50035,
85
+ Table: 50036,
86
+ TitleBar: 50037,
87
+ Separator: 50038,
88
+ });
89
+
90
+ /**
91
+ * AT-SPI role → UIA control type.
92
+ *
93
+ * The AT-SPI number is the tree's *one* canonical role (`atspiRoleOf`), so
94
+ * this is the whole of what Windows adds. Two rules decide the awkward cases:
95
+ *
96
+ * - **Group, not Pane, for a container.** A UIA Pane is a top-level region
97
+ * of a window (a document pane, a preview pane); a `<box>` is not one,
98
+ * and Narrator announces panes.
99
+ * - **Text, not Edit, for a label.** UIA's Edit *means editable*; a
100
+ * read-only `<text>` announced as an edit field is one a screen reader
101
+ * offers to type into.
102
+ *
103
+ * Anything missing falls to Group, which is a container a screen reader steps
104
+ * through silently — the same behaviour AT-SPI's FILLER gets, and the reason
105
+ * an unlabelled tree is boring here rather than noisy.
106
+ */
107
+ const ROLE_TO_UIA = new Map([
108
+ [ATSPI_ROLE.ALERT, UIA.Pane],
109
+ [ATSPI_ROLE.CANVAS, UIA.Image],
110
+ [ATSPI_ROLE.CHECK_BOX, UIA.CheckBox],
111
+ [ATSPI_ROLE.CHECK_MENU_ITEM, UIA.MenuItem],
112
+ [ATSPI_ROLE.COLUMN_HEADER, UIA.HeaderItem],
113
+ [ATSPI_ROLE.COMBO_BOX, UIA.ComboBox],
114
+ [ATSPI_ROLE.DIAL, UIA.Slider],
115
+ [ATSPI_ROLE.DIALOG, UIA.Window],
116
+ [ATSPI_ROLE.DRAWING_AREA, UIA.Image],
117
+ [ATSPI_ROLE.FILLER, UIA.Group],
118
+ [ATSPI_ROLE.FRAME, UIA.Window],
119
+ [ATSPI_ROLE.ICON, UIA.Image],
120
+ [ATSPI_ROLE.IMAGE, UIA.Image],
121
+ [ATSPI_ROLE.LABEL, UIA.Text],
122
+ [ATSPI_ROLE.LIST, UIA.List],
123
+ [ATSPI_ROLE.LIST_BOX, UIA.List],
124
+ [ATSPI_ROLE.LIST_ITEM, UIA.ListItem],
125
+ [ATSPI_ROLE.MENU, UIA.Menu],
126
+ [ATSPI_ROLE.MENU_BAR, UIA.MenuBar],
127
+ [ATSPI_ROLE.MENU_ITEM, UIA.MenuItem],
128
+ [ATSPI_ROLE.PAGE_TAB, UIA.TabItem],
129
+ [ATSPI_ROLE.PAGE_TAB_LIST, UIA.Tab],
130
+ [ATSPI_ROLE.PANEL, UIA.Group],
131
+ [ATSPI_ROLE.PASSWORD_TEXT, UIA.Edit],
132
+ [ATSPI_ROLE.POPUP_MENU, UIA.Menu],
133
+ [ATSPI_ROLE.PROGRESS_BAR, UIA.ProgressBar],
134
+ [ATSPI_ROLE.BUTTON, UIA.Button],
135
+ [ATSPI_ROLE.RADIO_BUTTON, UIA.RadioButton],
136
+ [ATSPI_ROLE.RADIO_MENU_ITEM, UIA.MenuItem],
137
+ [ATSPI_ROLE.ROW_HEADER, UIA.HeaderItem],
138
+ [ATSPI_ROLE.SCROLL_BAR, UIA.ScrollBar],
139
+ [ATSPI_ROLE.SCROLL_PANE, UIA.Pane],
140
+ [ATSPI_ROLE.SEPARATOR, UIA.Separator],
141
+ [ATSPI_ROLE.SLIDER, UIA.Slider],
142
+ [ATSPI_ROLE.SPIN_BUTTON, UIA.Spinner],
143
+ [ATSPI_ROLE.SPLIT_PANE, UIA.Pane],
144
+ [ATSPI_ROLE.STATUS_BAR, UIA.StatusBar],
145
+ [ATSPI_ROLE.TABLE, UIA.Table],
146
+ [ATSPI_ROLE.TABLE_CELL, UIA.DataItem],
147
+ [ATSPI_ROLE.TABLE_COLUMN_HEADER, UIA.HeaderItem],
148
+ [ATSPI_ROLE.TABLE_ROW, UIA.DataItem],
149
+ [ATSPI_ROLE.TABLE_ROW_HEADER, UIA.HeaderItem],
150
+ [ATSPI_ROLE.TEXT, UIA.Edit],
151
+ [ATSPI_ROLE.TOGGLE_BUTTON, UIA.Button],
152
+ [ATSPI_ROLE.TOOL_BAR, UIA.ToolBar],
153
+ [ATSPI_ROLE.TOOL_TIP, UIA.ToolTip],
154
+ [ATSPI_ROLE.TREE, UIA.Tree],
155
+ [ATSPI_ROLE.TREE_ITEM, UIA.TreeItem],
156
+ [ATSPI_ROLE.TREE_TABLE, UIA.Tree],
157
+ [ATSPI_ROLE.VIEWPORT, UIA.Pane],
158
+ [ATSPI_ROLE.WINDOW, UIA.Window],
159
+ [ATSPI_ROLE.HEADER, UIA.Header],
160
+ [ATSPI_ROLE.FOOTER, UIA.Group],
161
+ [ATSPI_ROLE.PARAGRAPH, UIA.Text],
162
+ [ATSPI_ROLE.EMBEDDED, UIA.Pane],
163
+ [ATSPI_ROLE.ENTRY, UIA.Edit],
164
+ [ATSPI_ROLE.CAPTION, UIA.Text],
165
+ [ATSPI_ROLE.HEADING, UIA.Text],
166
+ [ATSPI_ROLE.SECTION, UIA.Group],
167
+ [ATSPI_ROLE.FORM, UIA.Group],
168
+ [ATSPI_ROLE.LINK, UIA.Hyperlink],
169
+ [ATSPI_ROLE.DOCUMENT_TEXT, UIA.Document],
170
+ [ATSPI_ROLE.DOCUMENT_WEB, UIA.Document],
171
+ [ATSPI_ROLE.DOCUMENT_FRAME, UIA.Document],
172
+ [ATSPI_ROLE.GROUPING, UIA.Group],
173
+ [ATSPI_ROLE.NOTIFICATION, UIA.Group],
174
+ [ATSPI_ROLE.INFO_BAR, UIA.Group],
175
+ [ATSPI_ROLE.LEVEL_BAR, UIA.ProgressBar],
176
+ [ATSPI_ROLE.ARTICLE, UIA.Group],
177
+ [ATSPI_ROLE.LANDMARK, UIA.Group],
178
+ [ATSPI_ROLE.LOG, UIA.Group],
179
+ [ATSPI_ROLE.MATH, UIA.Group],
180
+ [ATSPI_ROLE.RATING, UIA.Slider],
181
+ [ATSPI_ROLE.TIMER, UIA.Text],
182
+ [ATSPI_ROLE.STATIC, UIA.Text],
183
+ [ATSPI_ROLE.SWITCH, UIA.Button],
184
+ [ATSPI_ROLE.SEPARATOR, UIA.Separator],
185
+ ]);
186
+
187
+ /** What UIA should call this node. Exported for the test that checks the
188
+ * table against the roles an app can actually write. */
189
+ export function uiaControlType(node) {
190
+ return ROLE_TO_UIA.get(atspiRoleOf(node)) ?? UIA.Group;
191
+ }
192
+
193
+ /** One state out of the pair of uint32s `a11yStates` returns. */
194
+ function hasState(states, state) {
195
+ return state < 32
196
+ ? (states[0] & (1 << state)) !== 0
197
+ : (states[1] & (1 << (state - 32))) !== 0;
198
+ }
199
+
200
+ // --------------------------------------------------------------------------
201
+ // The snapshot
202
+ // --------------------------------------------------------------------------
203
+
204
+ /** The roles whose checked state UIA reads through Toggle rather than a
205
+ * state flag. A radio button is *not* one: UIA gives it SelectionItem, and
206
+ * reporting it as a toggle makes a screen reader say "checkbox". */
207
+ const TOGGLES = new Set([
208
+ ATSPI_ROLE.CHECK_BOX,
209
+ ATSPI_ROLE.CHECK_MENU_ITEM,
210
+ ATSPI_ROLE.TOGGLE_BUTTON,
211
+ ATSPI_ROLE.SWITCH,
212
+ ]);
213
+
214
+ /**
215
+ * What the mirror is told about one node.
216
+ *
217
+ * Every field comes from `a11y.js`, so this is a *translation* and never a
218
+ * second opinion — the bug a second copy of the truth invites is exactly the
219
+ * one where the two disagree about what a node is called.
220
+ */
221
+ function snapshotOf(node, id, parentId, childIds) {
222
+ const states = a11yStates(node);
223
+ const role = atspiRoleOf(node);
224
+ const value = a11yValue(node);
225
+ const box = node.abs ?? { x: 0, y: 0, width: 0, height: 0 };
226
+ const editable = hasState(states, ATSPI_STATE.EDITABLE);
227
+ const text = isNativeTextControl(node) ? (node.value ?? '') : '';
228
+
229
+ return {
230
+ id,
231
+ parent: parentId,
232
+ children: childIds,
233
+ controlType: ROLE_TO_UIA.get(role) ?? UIA.Group,
234
+ name: a11yName(node) ?? '',
235
+ description: a11yDescription(node) ?? '',
236
+ // `<textinput>`'s text, or a valuetext where a range has one. A node
237
+ // with neither offers no Value pattern at all rather than an empty one.
238
+ value: editable ? text : (value?.text ?? ''),
239
+ automationId: typeof node.props?.id === 'string' ? node.props.id : '',
240
+ enabled: hasState(states, ATSPI_STATE.ENABLED),
241
+ focusable: hasState(states, ATSPI_STATE.FOCUSABLE),
242
+ focused: hasState(states, ATSPI_STATE.FOCUSED),
243
+ // UIA's "offscreen" is "not currently displayed", which is what SHOWING
244
+ // says the other way round.
245
+ offscreen: !hasState(states, ATSPI_STATE.SHOWING),
246
+ readOnly: !editable,
247
+ // Never both: UIA reads a checkbox through Toggle, and a control that
248
+ // also advertised Invoke is one Narrator describes twice over. Toggle
249
+ // wins, because it carries the state as well as the action.
250
+ invoke: a11yActivatable(node) && !TOGGLES.has(role),
251
+ toggle: TOGGLES.has(role),
252
+ toggleState: hasState(states, ATSPI_STATE.INDETERMINATE)
253
+ ? 2
254
+ : hasState(states, ATSPI_STATE.CHECKED)
255
+ ? 1
256
+ : 0,
257
+ valuePattern: editable || Boolean(value?.text),
258
+ rangePattern: Boolean(value),
259
+ rangeNow: value?.now ?? 0,
260
+ rangeMin: value?.min ?? 0,
261
+ rangeMax: value?.max ?? 0,
262
+ x: Math.round(box.x ?? 0),
263
+ y: Math.round(box.y ?? 0),
264
+ width: Math.round(box.width ?? 0),
265
+ height: Math.round(box.height ?? 0),
266
+ };
267
+ }
268
+
269
+ /** Whether two snapshots say the same thing. Compared field by field rather
270
+ * than by JSON, which would allocate a string per node per commit. */
271
+ function same(a, b) {
272
+ if (!a || !b) return false;
273
+ for (const key of Object.keys(a)) {
274
+ if (key === 'children') continue;
275
+ if (a[key] !== b[key]) return false;
276
+ }
277
+ return (
278
+ a.children.length === b.children.length &&
279
+ a.children.every((id, at) => id === b.children[at])
280
+ );
281
+ }
282
+
283
+ // --------------------------------------------------------------------------
284
+ // The bridge
285
+ // --------------------------------------------------------------------------
286
+
287
+ export class Win32Accessibility {
288
+ constructor(app) {
289
+ this.app = app;
290
+ this._native = app._native;
291
+ this.toplevels = [];
292
+ /** Stable ids, which is what UIA's runtime ids are made of. */
293
+ this._ids = new WeakMap();
294
+ this._nextId = 1;
295
+ /** What the mirror was last told, by id. */
296
+ this._sent = new Map();
297
+ /** Which windows to re-walk on the next commit. */
298
+ this._dirty = new Set();
299
+ /** Windows the mirror has ever been told about. */
300
+ this._pushed = new Set();
301
+ this._unsubscribes = [];
302
+ this.dead = false;
303
+ }
304
+
305
+ _idOf(node) {
306
+ let id = this._ids.get(node);
307
+ if (id === undefined) {
308
+ id = this._nextId++;
309
+ this._ids.set(node, id);
310
+ }
311
+ return id;
312
+ }
313
+
314
+ /** The backend window a toplevel is on, or null before it is realized. */
315
+ _windowOf(win) {
316
+ const wnd = win?.window;
317
+ return wnd && typeof wnd.id === 'number' ? wnd : null;
318
+ }
319
+
320
+ // ---- the walk ---------------------------------------------------------
321
+
322
+ /**
323
+ * Walk one toplevel and push what changed.
324
+ *
325
+ * The whole window is walked rather than a subtree, deliberately: the walk
326
+ * is `a11yChildren` over nodes that are already in memory, the comparison
327
+ * that follows drops everything unchanged, and the alternative — tracking
328
+ * which subtree a prop change belongs to — is where the AT-SPI bridge's
329
+ * own complexity lives. What crosses to the bridge is the diff either way.
330
+ */
331
+ _push(win) {
332
+ const wnd = this._windowOf(win);
333
+ if (!wnd || win.destroyed) return;
334
+
335
+ const nodes = [];
336
+ const seen = new Set();
337
+ const rootId = this._idOf(win);
338
+
339
+ const visit = (node, parentId) => {
340
+ const id = this._idOf(node);
341
+ seen.add(id);
342
+ const kids = a11yChildren(node);
343
+ const childIds = kids.map((kid) => this._idOf(kid));
344
+ const snapshot = snapshotOf(node, id, parentId, childIds);
345
+ if (!same(this._sent.get(id), snapshot)) {
346
+ this._sent.set(id, snapshot);
347
+ nodes.push(snapshot);
348
+ }
349
+ for (const kid of kids) visit(kid, id);
350
+ };
351
+ visit(win, 0);
352
+
353
+ // Nodes this window used to have and no longer does. Tracked per window
354
+ // so one window's unmount cannot drop another's ids.
355
+ const removed = [];
356
+ const was = this._windowIds ?? (this._windowIds = new Map());
357
+ const before = was.get(wnd.id);
358
+ if (before) {
359
+ for (const id of before) {
360
+ if (!seen.has(id)) {
361
+ removed.push(id);
362
+ this._sent.delete(id);
363
+ }
364
+ }
365
+ }
366
+ was.set(wnd.id, seen);
367
+
368
+ if (nodes.length === 0 && removed.length === 0) return;
369
+ const focused = win.events?.focusManager?.focused ?? null;
370
+ this._native.uiaUpdate(wnd.id, {
371
+ root: rootId,
372
+ focused: focused ? this._idOf(focused) : rootId,
373
+ nodes,
374
+ removed,
375
+ });
376
+ if (TRACE) {
377
+ trace(
378
+ `window ${wnd.id}: ${nodes.length} changed, ${removed.length} gone`,
379
+ );
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Push what changed, for the windows that changed.
385
+ *
386
+ * Nobody listening means nobody to tell, and the check is the difference
387
+ * between a machine with no screen reader paying for a tree walk on every
388
+ * commit and paying nothing — the same shape as the AT-SPI bridge's "no
389
+ * bus, no work".
390
+ *
391
+ * With one exception, which is what makes the gate safe: **every window is
392
+ * pushed once regardless.** A client's first `WM_GETOBJECT` has to find a
393
+ * tree, and it arrives before anything in this process knows a client
394
+ * exists. After that first push the mirror is live, and a client attaching
395
+ * later asks for a fresh one (`uia-wanted`).
396
+ */
397
+ flush({ force = false } = {}) {
398
+ if (this.dead || this._dirty.size === 0) return;
399
+ const listening = force || this._native.uiaListening();
400
+ const windows = [...this._dirty];
401
+ this._dirty.clear();
402
+ for (const win of windows) {
403
+ const wnd = this._windowOf(win);
404
+ if (!wnd) {
405
+ // Windows are created asynchronously here, so the tree mounts and
406
+ // commits before its HWND exists. Kept dirty rather than dropped —
407
+ // dropping it was a real bug: the one push a window is owed happened
408
+ // against nothing and was never retried.
409
+ this._dirty.add(win);
410
+ continue;
411
+ }
412
+ if (!listening && this._pushed.has(wnd.id)) continue;
413
+ this._pushed.add(wnd.id);
414
+ this._push(win);
415
+ }
416
+ }
417
+
418
+ /** A window's HWND exists now, so what is owed for it can be pushed. */
419
+ windowReady(wnd) {
420
+ const win = this.toplevels.find((w) => w.window === wnd);
421
+ if (!win) return;
422
+ this._dirty.add(win);
423
+ this.flush();
424
+ }
425
+
426
+ /**
427
+ * A client just asked this window for its automation tree.
428
+ *
429
+ * It is the one moment worth building one on: the client is attaching now,
430
+ * and whatever the mirror holds is from the last commit, which for an idle
431
+ * application may be minutes old.
432
+ */
433
+ wanted(windowId) {
434
+ const win = this.toplevels.find((w) => this._windowOf(w)?.id === windowId);
435
+ if (!win) return;
436
+ this._dirty.add(win);
437
+ this.flush({ force: true });
438
+ }
439
+
440
+ /** The toplevel a node is under, or null. */
441
+ _toplevelOf(node) {
442
+ const root = node?.isWindow ? node : node?.root;
443
+ return root && this.toplevels.includes(root) ? root : null;
444
+ }
445
+
446
+ _touch(node) {
447
+ const win = this._toplevelOf(node);
448
+ if (win) this._dirty.add(win);
449
+ }
450
+
451
+ // ---- what the shell asks for ------------------------------------------
452
+
453
+ /**
454
+ * A screen reader asked for something to happen: a button invoked, a
455
+ * checkbox toggled, a value set, focus moved.
456
+ *
457
+ * These are *requests*. The tree owns the state, so each one goes through
458
+ * the same path a click or a keystroke goes through — `_activate` is what
459
+ * `events.js` runs for an AT's `DoAction` on the other backend, and the
460
+ * comment there says the two must not drift.
461
+ */
462
+ action(event) {
463
+ const id = Number(event.a ?? 0);
464
+ const what = String(event.text ?? '');
465
+ const node = this._nodeById(id);
466
+ if (!node || node.destroyed) return;
467
+ if (TRACE) trace(`action ${what} on ${id}`);
468
+
469
+ if (what === 'focus') {
470
+ node.root?.events?.focus?.(node, 'script');
471
+ return;
472
+ }
473
+ if (what === 'invoke' || what === 'toggle') {
474
+ // The same synthetic click the AT-SPI bridge's `DoAction("activate")`
475
+ // runs, from the same helper on purpose: `events.js` says the two must
476
+ // not drift, and a second activation path is exactly how they would.
477
+ if (node.a11yOwner) {
478
+ synthesizeClick(node.a11yOwner, node.abs);
479
+ } else {
480
+ synthesizeClick(node, node.abs);
481
+ }
482
+ return;
483
+ }
484
+ if (what.startsWith('value:')) {
485
+ const text = what.slice('value:'.length);
486
+ if (typeof node._setValueFromA11y === 'function') {
487
+ node._setValueFromA11y(text);
488
+ } else if (isNativeTextControl(node)) {
489
+ node.props?.onChange?.({ target: node, value: text });
490
+ }
491
+ return;
492
+ }
493
+ if (what.startsWith('range:')) {
494
+ const value = Number(event.b ?? NaN);
495
+ if (Number.isFinite(value)) {
496
+ node.props?.onValueChange?.(value) ?? node.props?.onChange?.(value);
497
+ }
498
+ }
499
+ }
500
+
501
+ _nodeById(id) {
502
+ // Ids are handed out from a WeakMap, so there is no reverse index to
503
+ // keep: the snapshot map has every id the mirror knows, and the tree is
504
+ // walked to find the node it belongs to. Actions are rare — a person
505
+ // clicking through a screen reader — and a reverse map of strong
506
+ // references would keep unmounted nodes alive.
507
+ for (const win of this.toplevels) {
508
+ const found = this._find(win, id);
509
+ if (found) return found;
510
+ }
511
+ return null;
512
+ }
513
+
514
+ _find(node, id) {
515
+ if (this._ids.get(node) === id) return node;
516
+ for (const kid of a11yChildren(node)) {
517
+ const found = this._find(kid, id);
518
+ if (found) return found;
519
+ }
520
+ return null;
521
+ }
522
+
523
+ // ---- wiring into the renderer -----------------------------------------
524
+
525
+ install() {
526
+ hooks.rootMounted = (win) => {
527
+ if (this.toplevels.includes(win)) return;
528
+ this.toplevels.push(win);
529
+ this._dirty.add(win);
530
+ };
531
+ hooks.rootUnmounted = (win) => {
532
+ const at = this.toplevels.indexOf(win);
533
+ if (at === -1) return;
534
+ this.toplevels.splice(at, 1);
535
+ this._dirty.delete(win);
536
+ };
537
+ hooks.attached = (parent) => this._touch(parent);
538
+ hooks.detach = (parent) => this._touch(parent);
539
+ hooks.propsChanged = (node) => this._touch(node);
540
+ hooks.textContent = (chunk) => this._touch(chunk.parent ?? chunk);
541
+ hooks.textState = (node) => this._touch(node);
542
+ hooks.focus = (previous, next) => {
543
+ const win = this._toplevelOf(next ?? previous);
544
+ if (!win) return;
545
+ this._dirty.add(win);
546
+ // Pushed now rather than at the next commit: a focus change is what a
547
+ // screen reader is waiting for, and the event has to follow an update
548
+ // that already carries the new state or the client reads the old one.
549
+ this.flush();
550
+ const wnd = this._windowOf(win);
551
+ if (wnd && next) this._native.uiaFocusChanged(wnd.id, this._idOf(next));
552
+ };
553
+ hooks.windowFocus = (win) => this._dirty.add(win);
554
+ hooks.commit = () => this.flush();
555
+ hooks.announce = (text, opts) => {
556
+ const win =
557
+ this.toplevels.find((w) => w.events?.windowFocused) ??
558
+ this.toplevels[0] ??
559
+ null;
560
+ const wnd = this._windowOf(win);
561
+ if (!wnd) return false;
562
+ // The window must be in the mirror before anything can be announced
563
+ // from it, which on the first announcement of a session it may not be.
564
+ this._dirty.add(win);
565
+ this.flush();
566
+ return Boolean(
567
+ this._native.uiaAnnounce(wnd.id, String(text), !opts?.assertive),
568
+ );
569
+ };
570
+
571
+ // Toplevels that mounted before this was installed.
572
+ this._unsubscribes.push(
573
+ onApp((app) => {
574
+ for (const win of app._rootChildren ?? []) hooks.rootMounted(win);
575
+ }),
576
+ );
577
+ }
578
+
579
+ bury() {
580
+ if (this.dead) return;
581
+ this.dead = true;
582
+ for (const key of Object.keys(hooks)) hooks[key] = null;
583
+ for (const unsubscribe of this._unsubscribes) unsubscribe();
584
+ this._unsubscribes = [];
585
+ }
586
+ }
587
+
588
+ function trace(line) {
589
+ process.stderr.write(`react-x11 win32: a11y ${line}\n`);
590
+ }
591
+
592
+ /**
593
+ * Start the bridge for an app whose backend has a UIA provider.
594
+ *
595
+ * Returns null where the bridge cannot be used, which `startA11y()` reads as
596
+ * "climb no further" — the same contract `atspi.js`'s `start()` keeps.
597
+ */
598
+ export function startWin32Accessibility(app) {
599
+ if (typeof app?._native?.uiaUpdate !== 'function') return null;
600
+ const bridge = new Win32Accessibility(app);
601
+ bridge.install();
602
+ app._a11y = bridge;
603
+ return bridge;
604
+ }