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.
- package/README.md +38 -23
- package/package.json +3 -1
- package/src/Reconciler.js +82 -23
- package/src/a11y.js +18 -1
- package/src/appcontext.js +8 -0
- package/src/appearance.js +36 -0
- package/src/{cocoa → backend}/context2d.js +27 -7
- package/src/capabilities.js +99 -1
- package/src/cocoa/app.js +8 -4
- package/src/cocoa/fonts.js +1 -1
- package/src/cocoa/overlay.js +2 -2
- package/src/cocoa/panewindow.js +2 -2
- package/src/cocoa/presenter.js +2 -2
- package/src/cocoa/surface.js +3 -3
- package/src/cocoa/window.js +2 -2
- package/src/events.js +21 -0
- package/src/foreignnodes.js +8 -3
- package/src/frame/index.js +30 -4
- package/src/glnodes.js +12 -1
- package/src/idle.js +59 -1
- package/src/index.d.ts +41 -0
- package/src/index.js +30 -3
- package/src/launcher.js +17 -8
- package/src/launcherhooks.js +24 -10
- package/src/node.d.ts +1 -1
- package/src/nodes/cascade.js +9 -0
- package/src/nodes/node.js +6 -1
- package/src/nodes/window/hints.js +21 -2
- package/src/nodes/window/window.js +2 -2
- package/src/notifications.js +39 -14
- package/src/taskbarhooks.js +164 -0
- package/src/transfer.js +20 -1
- package/src/trayhooks.js +1 -1
- package/src/types/capabilities.d.ts +32 -3
- package/src/types/elements.d.ts +23 -1
- package/src/types/events.d.ts +16 -0
- package/src/types/launcher.d.ts +20 -6
- package/src/types/taskbar.d.ts +79 -0
- package/src/wayland/context2d.js +1 -1
- package/src/win32/a11y.js +604 -0
- package/src/win32/app.js +768 -0
- package/src/win32/bezels.js +158 -0
- package/src/win32/dnd.js +283 -0
- package/src/win32/fonts.js +497 -0
- package/src/win32/glarea.js +548 -0
- package/src/win32/ime.js +267 -0
- package/src/win32/keymap.js +116 -0
- package/src/win32/native.js +54 -0
- package/src/win32/panehost.js +106 -0
- package/src/win32/panewindow.js +343 -0
- package/src/win32/shell.js +426 -0
- package/src/win32/surface.js +192 -0
- package/src/win32/window.js +659 -0
- 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
|
+
}
|