xa11y_linux/atspi.rs
1//! Real AT-SPI2 backend implementation using zbus D-Bus bindings.
2
3use std::collections::{HashMap, HashSet, VecDeque};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::Mutex;
6
7use rayon::prelude::*;
8use xa11y_core::{
9 ElementData, ElementParts, Error, Provider, Rect, Result, Role, ShellSurfaceKind, StateParts,
10 StateSet, Subscription, Toggled,
11};
12use zbus::blocking::{Connection, Proxy};
13
14use crate::window::WindowManager;
15
16/// Global handle counter for mapping ElementData back to AccessibleRefs.
17static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1);
18
19/// Format a normalized state attribute as the same string `xa11y_core::selector::resolve_attr`
20/// would produce, so fast-path matching agrees byte-for-byte with the slow path.
21fn state_attr_to_string(name: &str, s: &StateSet) -> Option<String> {
22 match name {
23 "enabled" => Some(s.enabled.to_string()),
24 "visible" => Some(s.visible.to_string()),
25 "focused" => Some(s.focused.to_string()),
26 "active" => Some(s.active.to_string()),
27 "focusable" => Some(s.focusable.to_string()),
28 "selected" => Some(s.selected.to_string()),
29 "editable" => Some(s.editable.to_string()),
30 "modal" => Some(s.modal.to_string()),
31 "required" => Some(s.required.to_string()),
32 "busy" => Some(s.busy.to_string()),
33 "expanded" => s.expanded.map(|b| b.to_string()),
34 "checked" => s.checked.map(|c| {
35 match c {
36 Toggled::On => "on",
37 Toggled::Off => "off",
38 Toggled::Mixed => "mixed",
39 }
40 .to_string()
41 }),
42 _ => None,
43 }
44}
45
46/// Linux accessibility provider using AT-SPI2 over D-Bus.
47pub struct LinuxProvider {
48 a11y_bus: Connection,
49 /// Cached AT-SPI accessible refs keyed by handle ID.
50 handle_cache: Mutex<HashMap<u64, AccessibleRef>>,
51 /// Cached AT-SPI2 action indices keyed by element handle.
52 /// Maps each action name (snake_case) to the integer index used by `DoAction(i)`.
53 action_indices: Mutex<HashMap<u64, HashMap<String, i32>>>,
54 /// Native X11/Wayland window-manager channel, selected independently of
55 /// the AT-SPI accessibility connection.
56 window_manager: WindowManager,
57}
58
59/// AT-SPI2 accessible reference: (bus_name, object_path).
60#[derive(Debug, Clone)]
61pub(crate) struct AccessibleRef {
62 pub(crate) bus_name: String,
63 pub(crate) path: String,
64}
65
66impl LinuxProvider {
67 /// Create a new Linux accessibility provider.
68 ///
69 /// Connects to the AT-SPI2 bus. Falls back to the session bus
70 /// if the dedicated AT-SPI bus is unavailable.
71 pub fn new() -> Result<Self> {
72 let a11y_bus = Self::connect_a11y_bus()?;
73 Ok(Self {
74 a11y_bus,
75 handle_cache: Mutex::new(HashMap::new()),
76 action_indices: Mutex::new(HashMap::new()),
77 window_manager: WindowManager::new(),
78 })
79 }
80
81 pub(crate) fn connect_a11y_bus() -> Result<Connection> {
82 // Try getting the AT-SPI bus address from the a11y bus launcher,
83 // then connect to it. If that fails, fall back to the session bus
84 // (AT-SPI2 may use the session bus directly).
85 if let Ok(session) = Connection::session() {
86 let proxy = Proxy::new(&session, "org.a11y.Bus", "/org/a11y/bus", "org.a11y.Bus")
87 .map_err(|e| Error::Platform {
88 code: -1,
89 message: format!("Failed to create a11y bus proxy: {}", e),
90 })?;
91
92 if let Ok(addr_reply) = proxy.call_method("GetAddress", &()) {
93 if let Ok(address) = addr_reply.body().deserialize::<String>() {
94 if let Ok(addr) = zbus::Address::try_from(address.as_str()) {
95 if let Ok(Ok(conn)) =
96 zbus::blocking::connection::Builder::address(addr).map(|b| b.build())
97 {
98 return Ok(conn);
99 }
100 }
101 }
102 }
103
104 // Fall back to session bus
105 return Ok(session);
106 }
107
108 Connection::session().map_err(|e| Error::Platform {
109 code: -1,
110 message: format!("Failed to connect to D-Bus session bus: {}", e),
111 })
112 }
113
114 fn make_proxy(&self, bus_name: &str, path: &str, interface: &str) -> Result<Proxy<'_>> {
115 // Use uncached proxy to avoid GetAll calls — Qt's AT-SPI adaptor
116 // doesn't support GetAll on all objects, causing spurious errors.
117 zbus::blocking::proxy::Builder::<Proxy>::new(&self.a11y_bus)
118 .destination(bus_name.to_owned())
119 .map_err(|e| Error::Platform {
120 code: -1,
121 message: format!("Failed to set proxy destination: {}", e),
122 })?
123 .path(path.to_owned())
124 .map_err(|e| Error::Platform {
125 code: -1,
126 message: format!("Failed to set proxy path: {}", e),
127 })?
128 .interface(interface.to_owned())
129 .map_err(|e| Error::Platform {
130 code: -1,
131 message: format!("Failed to set proxy interface: {}", e),
132 })?
133 .cache_properties(zbus::proxy::CacheProperties::No)
134 .build()
135 .map_err(|e| Error::Platform {
136 code: -1,
137 message: format!("Failed to create proxy: {}", e),
138 })
139 }
140
141 /// Check whether an accessible object implements a given interface.
142 /// Queries the AT-SPI GetInterfaces method on the Accessible interface.
143 fn has_interface(&self, aref: &AccessibleRef, iface: &str) -> bool {
144 let proxy = match self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible") {
145 Ok(p) => p,
146 Err(_) => return false,
147 };
148 let reply = match proxy.call_method("GetInterfaces", &()) {
149 Ok(r) => r,
150 Err(_) => return false,
151 };
152 let interfaces: Vec<String> = match reply.body().deserialize() {
153 Ok(v) => v,
154 Err(_) => return false,
155 };
156 interfaces.iter().any(|i| i.contains(iface))
157 }
158
159 /// Get the numeric AT-SPI role via GetRole method.
160 fn get_role_number(&self, aref: &AccessibleRef) -> Result<u32> {
161 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
162 let reply = proxy
163 .call_method("GetRole", &())
164 .map_err(|e| Error::Platform {
165 code: -1,
166 message: format!("GetRole failed: {}", e),
167 })?;
168 reply
169 .body()
170 .deserialize::<u32>()
171 .map_err(|e| Error::Platform {
172 code: -1,
173 message: format!("GetRole deserialize failed: {}", e),
174 })
175 }
176
177 /// Get the AT-SPI role name string.
178 fn get_role_name(&self, aref: &AccessibleRef) -> Result<String> {
179 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
180 let reply = proxy
181 .call_method("GetRoleName", &())
182 .map_err(|e| Error::Platform {
183 code: -1,
184 message: format!("GetRoleName failed: {}", e),
185 })?;
186 reply
187 .body()
188 .deserialize::<String>()
189 .map_err(|e| Error::Platform {
190 code: -1,
191 message: format!("GetRoleName deserialize failed: {}", e),
192 })
193 }
194
195 /// Detect Chromium/Electron's "accessibility bridge disabled" signature.
196 ///
197 /// On Linux, Chromium-based apps (Electron, Chrome, VS Code, …) register
198 /// with AT-SPI but expose only an `application → frame` skeleton — the
199 /// frame's children list is empty (just the `/org/a11y/atspi/null`
200 /// sentinel) — unless the process was launched with the
201 /// `--force-renderer-accessibility` flag. Without this detection, callers
202 /// see zero results and assume their selector is wrong.
203 ///
204 /// Call this after observing an empty filtered children list. Returns an
205 /// error only when the parent is a window/frame whose AT-SPI bus reports
206 /// `Application.ToolkitName == "Chromium"`; otherwise returns `Ok(())` so
207 /// genuinely empty windows in other toolkits stay valid.
208 ///
209 /// `role_hint` lets callers that already know the role skip a `GetRole`
210 /// round-trip.
211 fn check_chromium_a11y_enabled(
212 &self,
213 parent: &AccessibleRef,
214 role_hint: Option<Role>,
215 ) -> Result<()> {
216 let app_root = AccessibleRef {
217 bus_name: parent.bus_name.clone(),
218 path: "/org/a11y/atspi/accessible/root".to_string(),
219 };
220 let toolkit = match self
221 .make_proxy(
222 &app_root.bus_name,
223 &app_root.path,
224 "org.a11y.atspi.Application",
225 )
226 .ok()
227 .and_then(|proxy| proxy.get_property::<String>("ToolkitName").ok())
228 {
229 Some(t) => t,
230 None => return Ok(()),
231 };
232 if !toolkit.eq_ignore_ascii_case("Chromium") {
233 return Ok(());
234 }
235 let role = role_hint.unwrap_or_else(|| self.resolve_role(parent));
236 if role != Role::Window {
237 return Ok(());
238 }
239 let app_name = self.get_name(&app_root).unwrap_or_default();
240 Err(Error::AccessibilityNotEnabled {
241 app: app_name,
242 instructions: "Chromium/Electron app exposes an empty accessibility tree on Linux. \
243 Relaunch with `--force-renderer-accessibility` (or set the env var \
244 `ACCESSIBILITY_ENABLED=1`) so the renderer accessibility bridge is initialised."
245 .to_string(),
246 })
247 }
248
249 /// Get the name of an accessible element.
250 fn get_name(&self, aref: &AccessibleRef) -> Result<String> {
251 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
252 proxy
253 .get_property::<String>("Name")
254 .map_err(|e| Error::Platform {
255 code: -1,
256 message: format!("Get Name property failed: {}", e),
257 })
258 }
259
260 /// Get the description of an accessible element.
261 fn get_description(&self, aref: &AccessibleRef) -> Result<String> {
262 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
263 proxy
264 .get_property::<String>("Description")
265 .map_err(|e| Error::Platform {
266 code: -1,
267 message: format!("Get Description property failed: {}", e),
268 })
269 }
270
271 /// Get children via the GetChildren method.
272 /// AT-SPI registryd doesn't always implement standard D-Bus Properties,
273 /// so we use GetChildren which is more reliable.
274 fn get_atspi_children(&self, aref: &AccessibleRef) -> Result<Vec<AccessibleRef>> {
275 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
276 let reply = proxy
277 .call_method("GetChildren", &())
278 .map_err(|e| Error::Platform {
279 code: -1,
280 message: format!("GetChildren failed: {}", e),
281 })?;
282 let children: Vec<(String, zbus::zvariant::OwnedObjectPath)> =
283 reply.body().deserialize().map_err(|e| Error::Platform {
284 code: -1,
285 message: format!("GetChildren deserialize failed: {}", e),
286 })?;
287 Ok(children
288 .into_iter()
289 .map(|(bus_name, path)| AccessibleRef {
290 bus_name,
291 path: path.to_string(),
292 })
293 .collect())
294 }
295
296 /// Get the state set as raw u32 values.
297 fn get_state(&self, aref: &AccessibleRef) -> Result<Vec<u32>> {
298 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
299 let reply = proxy
300 .call_method("GetState", &())
301 .map_err(|e| Error::Platform {
302 code: -1,
303 message: format!("GetState failed: {}", e),
304 })?;
305 reply
306 .body()
307 .deserialize::<Vec<u32>>()
308 .map_err(|e| Error::Platform {
309 code: -1,
310 message: format!("GetState deserialize failed: {}", e),
311 })
312 }
313
314 /// `GetRole` for the shell scan, separating "the peer left the bus" from
315 /// a genuine failure.
316 ///
317 /// `Ok(None)` means the owning application exited mid-scan; see
318 /// [`PEER_GONE_ERRORS`].
319 fn scan_role_number(&self, aref: &AccessibleRef) -> Result<Option<u32>> {
320 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
321 let reply = match proxy.call_method("GetRole", &()) {
322 Ok(reply) => reply,
323 Err(e) if is_peer_gone(&e) => return Ok(None),
324 Err(e) => {
325 return Err(Error::Platform {
326 code: -1,
327 message: format!("GetRole failed: {}", e),
328 })
329 }
330 };
331 reply
332 .body()
333 .deserialize::<u32>()
334 .map(Some)
335 .map_err(|e| Error::Platform {
336 code: -1,
337 message: format!("GetRole deserialize failed: {}", e),
338 })
339 }
340
341 /// `GetAttributes` for the shell scan.
342 ///
343 /// Three outcomes rather than two: `Ok(None)` is the peer having left the
344 /// bus, `Ok(Some(None))` is an object that does not implement
345 /// `GetAttributes` (an honest "not a panel"), and `Ok(Some(Some(_)))` is a
346 /// reply.
347 #[allow(
348 clippy::option_option,
349 reason = "The two Nones mean different things \
350 — peer gone versus member absent — and collapsing them would erase \
351 exactly the distinction this function exists to make."
352 )]
353 fn scan_attributes(
354 &self,
355 aref: &AccessibleRef,
356 ) -> Result<Option<Option<HashMap<String, String>>>> {
357 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
358 let reply = match proxy.call_method("GetAttributes", &()) {
359 Ok(reply) => reply,
360 Err(e) if is_peer_gone(&e) => return Ok(None),
361 Err(e) if is_absent_member(&e) => return Ok(Some(None)),
362 Err(e) => {
363 return Err(Error::Platform {
364 code: -1,
365 message: format!("GetAttributes failed: {}", e),
366 })
367 }
368 };
369 reply
370 .body()
371 .deserialize::<HashMap<String, String>>()
372 .map(|a| Some(Some(a)))
373 .map_err(|e| Error::Platform {
374 code: -1,
375 message: format!("GetAttributes deserialize failed: {}", e),
376 })
377 }
378
379 /// Return true if the element reports the AT-SPI MULTI_LINE state.
380 /// Used to distinguish multi-line text areas (TextArea) from single-line
381 /// text inputs (TextField), since both use the AT-SPI "text" role name.
382 /// Note: Qt's AT-SPI bridge does not reliably set SINGLE_LINE, so we
383 /// check MULTI_LINE and default to TextField when neither is set.
384 fn is_multi_line(&self, aref: &AccessibleRef) -> bool {
385 let state_bits = self.get_state(aref).unwrap_or_default();
386 let bits: u64 = if state_bits.len() >= 2 {
387 (state_bits[0] as u64) | ((state_bits[1] as u64) << 32)
388 } else if state_bits.len() == 1 {
389 state_bits[0] as u64
390 } else {
391 0
392 };
393 // ATSPI_STATE_MULTI_LINE = 17 in AtspiStateType enum
394 const MULTI_LINE: u64 = 1 << 17;
395 (bits & MULTI_LINE) != 0
396 }
397
398 /// Resolve TextField vs TextArea using the MULTI_LINE state, regardless of
399 /// the coarse role we started with. Different toolkits expose text inputs
400 /// under different AT-SPI roles ("text", "textbox", "entry", "embedded",
401 /// numeric 61/78/79), and WebKitGTK has changed mapping between versions
402 /// (e.g. 2.50 → 2.52 swapped what role textarea reports). MULTI_LINE is
403 /// the cross-toolkit invariant, so we let it decide.
404 ///
405 /// For any other coarse role, returns it unchanged.
406 fn refine_text_role(&self, coarse: Role, aref: &AccessibleRef) -> Role {
407 match coarse {
408 Role::TextArea | Role::TextField => {
409 if self.is_multi_line(aref) {
410 Role::TextArea
411 } else {
412 Role::TextField
413 }
414 }
415 other => other,
416 }
417 }
418
419 /// Get bounds via Component interface.
420 /// Checks for Component support first to avoid GTK CRITICAL warnings
421 /// on objects (e.g. TreeView cell renderers) that don't implement it.
422 fn get_extents(&self, aref: &AccessibleRef) -> Option<Rect> {
423 if !self.has_interface(aref, "Component") {
424 return None;
425 }
426 let proxy = self
427 .make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Component")
428 .ok()?;
429 // GetExtents(coord_type: u32) -> (x, y, width, height)
430 // coord_type 0 = screen coordinates
431 let reply = proxy.call_method("GetExtents", &(0u32,)).ok()?;
432 let (x, y, w, h): (i32, i32, i32, i32) = reply.body().deserialize().ok()?;
433 if w <= 0 && h <= 0 {
434 return None;
435 }
436 // AT-SPI screen extents are physical device pixels. Convert to logical
437 // coordinates (the cross-platform contract, same space as the
438 // screenshot/input layers) using the detected display scale. On the
439 // common 1.0 case this is a no-op. See `crate::scale`.
440 Some(
441 Rect {
442 x,
443 y,
444 width: w.max(0) as u32,
445 height: h.max(0) as u32,
446 }
447 .to_logical(crate::scale::coordinate_scale()),
448 )
449 }
450
451 /// Get available actions via Action interface, returning both the action list
452 /// and a map of each action name to its AT-SPI2 integer index for direct `DoAction(i)`.
453 ///
454 /// Probes the interface directly rather than relying on the Interfaces property,
455 /// which some AT-SPI adapters (e.g. AccessKit) don't expose.
456 fn get_actions(&self, aref: &AccessibleRef) -> (Vec<String>, HashMap<String, i32>) {
457 let mut actions = Vec::new();
458 let mut indices = HashMap::new();
459
460 // Try Action interface directly
461 if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action") {
462 // NActions may be returned as i32 or u32 depending on AT-SPI implementation.
463 let n_actions = proxy
464 .get_property::<i32>("NActions")
465 .or_else(|_| proxy.get_property::<u32>("NActions").map(|n| n as i32))
466 .unwrap_or(0);
467 for i in 0..n_actions {
468 if let Ok(reply) = proxy.call_method("GetName", &(i,)) {
469 if let Ok(name) = reply.body().deserialize::<String>() {
470 if let Some(action_name) = map_atspi_action_name(&name) {
471 if !actions.contains(&action_name) {
472 indices.insert(action_name.clone(), i);
473 actions.push(action_name);
474 }
475 }
476 }
477 }
478 }
479 }
480
481 // NOTE: We do NOT add implicit "focus" based on Component interface existence.
482 // On GTK4, GrabFocus() often returns false even when Component interface exists,
483 // violating design tenet 3 ("if action is listed, calling it must work").
484 // Only report "focus" if it's explicitly in the AT-SPI Action interface with a
485 // proper index. This ensures focus() will work when reported.
486 // Fixes GitHub issue #98.
487
488 (actions, indices)
489 }
490
491 /// Return true when the accessible's application identifies itself as GTK
492 /// via `org.a11y.atspi.Application.ToolkitName`.
493 ///
494 /// Used to scope the press-fallback heuristic for the
495 /// AdwMenuButton/GtkMenuButton wrapper pattern; other toolkits are
496 /// unaffected. Note that WebKitGTK also reports "GTK", so the answer is
497 /// not a "GtkWidget app" discriminator.
498 fn is_gtk_toolkit(&self, aref: &AccessibleRef) -> bool {
499 let app_root = AccessibleRef {
500 bus_name: aref.bus_name.clone(),
501 path: "/org/a11y/atspi/accessible/root".to_string(),
502 };
503 self.make_proxy(
504 &app_root.bus_name,
505 &app_root.path,
506 "org.a11y.atspi.Application",
507 )
508 .ok()
509 .and_then(|proxy| proxy.get_property::<String>("ToolkitName").ok())
510 .map(|t| t.eq_ignore_ascii_case("GTK"))
511 .unwrap_or(false)
512 }
513
514 /// GTK press-fallback resolver.
515 ///
516 /// Walks the accessible's descendants (BFS, depth-capped) looking for a
517 /// single activatable child that shares the outer widget's name. Returns
518 /// the (ref, action_index) pair when exactly one suitable candidate
519 /// exists at the shallowest matching depth. Returns `None` when the
520 /// subtree contains nothing suitable or multiple equally plausible
521 /// candidates — refusing to guess.
522 ///
523 /// Empirically fixes the `GtkMenuButton` / `AdwMenuButton` wrappers that
524 /// ship in every stock GNOME app (Calculator, Text Editor, Logs, Clocks,
525 /// Characters, …).
526 fn find_gtk_press_fallback(
527 &self,
528 outer: &AccessibleRef,
529 outer_name: &str,
530 ) -> Option<(AccessibleRef, i32)> {
531 let mut queue: VecDeque<(AccessibleRef, u32)> = VecDeque::new();
532 queue.push_back((outer.clone(), 0));
533 let mut visited: usize = 0;
534 let mut shallowest_depth: Option<u32> = None;
535 let mut hits: Vec<(AccessibleRef, i32)> = Vec::new();
536
537 while let Some((node, depth)) = queue.pop_front() {
538 // Once we've found the shallowest level with hits, don't look deeper.
539 if let Some(best) = shallowest_depth {
540 if depth > best {
541 continue;
542 }
543 }
544 if visited > GTK_FALLBACK_MAX_NODES {
545 break;
546 }
547
548 let role_name = if depth == 0 {
549 String::new()
550 } else {
551 visited += 1;
552 self.get_role_name(&node).unwrap_or_default().to_lowercase()
553 };
554
555 if depth > 0 && is_actionable_atspi_role(&role_name) {
556 if let Some(idx) = self.gtk_fallback_pick(&node, outer_name) {
557 match shallowest_depth {
558 Some(d) if depth < d => {
559 shallowest_depth = Some(depth);
560 hits.clear();
561 hits.push((node.clone(), idx));
562 }
563 Some(d) if depth == d => hits.push((node.clone(), idx)),
564 Some(_) => {}
565 None => {
566 shallowest_depth = Some(depth);
567 hits.push((node.clone(), idx));
568 }
569 }
570 }
571 }
572
573 // Do not descend into static/decorative roles. Descend through
574 // containers and actionable roles alike (actionable roles may
575 // themselves wrap an inner actionable — e.g. an AdwSplitButton's
576 // primary button inside a toggle-button shell).
577 let stop_descending = depth >= GTK_FALLBACK_MAX_DEPTH
578 || (depth > 0 && is_never_descend_atspi_role(&role_name));
579 if stop_descending {
580 continue;
581 }
582 if let Ok(children) = self.get_atspi_children(&node) {
583 for c in children {
584 queue.push_back((c, depth + 1));
585 }
586 }
587 }
588
589 if hits.len() == 1 {
590 Some(hits.into_iter().next().unwrap())
591 } else {
592 None
593 }
594 }
595
596 /// Per-node filter for `find_gtk_press_fallback`. Returns the AT-SPI
597 /// action index to invoke when `aref` is a valid fallback candidate.
598 fn gtk_fallback_pick(&self, aref: &AccessibleRef, outer_name: &str) -> Option<i32> {
599 let (_, index_map) = self.get_actions(aref);
600 let idx = *index_map.get("press")?;
601 if !self.is_showing_visible_sensitive(aref) {
602 return None;
603 }
604 // If the outer has a name, require the candidate to share it. Rules
605 // out unrelated suffix widgets (e.g. a switch inside an AdwActionRow
606 // that happens to expose `click`).
607 if !outer_name.is_empty() {
608 let inner_name = self.get_name(aref).unwrap_or_default();
609 if !inner_name.is_empty() && inner_name != outer_name {
610 return None;
611 }
612 }
613 Some(idx)
614 }
615
616 /// True when the accessible's state has SHOWING, VISIBLE, and SENSITIVE
617 /// set. ENABLED is deliberately excluded: in GTK4 it reflects "has an
618 /// enabled GAction bound", which is false for the exact widgets we
619 /// rescue.
620 fn is_showing_visible_sensitive(&self, aref: &AccessibleRef) -> bool {
621 let raw = self.get_state(aref).unwrap_or_default();
622 let bits: u64 = if raw.len() >= 2 {
623 (raw[0] as u64) | ((raw[1] as u64) << 32)
624 } else if raw.len() == 1 {
625 raw[0] as u64
626 } else {
627 0
628 };
629 const SENSITIVE: u64 = 1 << 24;
630 const SHOWING: u64 = 1 << 25;
631 const VISIBLE: u64 = 1 << 30;
632 (bits & (SHOWING | VISIBLE | SENSITIVE)) == (SHOWING | VISIBLE | SENSITIVE)
633 }
634
635 /// Get value via Value or Text interface.
636 /// Probes interfaces directly rather than relying on the Interfaces property.
637 fn get_value(&self, aref: &AccessibleRef) -> Option<String> {
638 // Try Text interface first for text content (text fields, labels, combo boxes).
639 // This must come before Value because some AT-SPI adapters (e.g. AccessKit)
640 // may expose both interfaces, and Value.CurrentValue returns 0.0 for text elements.
641 let text_value = self.get_text_content(aref);
642 if text_value.is_some() {
643 return text_value;
644 }
645 // Try Value interface (sliders, progress bars, scroll bars, spinners)
646 if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Value") {
647 if let Ok(val) = proxy.get_property::<f64>("CurrentValue") {
648 return Some(val.to_string());
649 }
650 }
651 None
652 }
653
654 /// Read text content via the AT-SPI Text interface.
655 fn get_text_content(&self, aref: &AccessibleRef) -> Option<String> {
656 let proxy = self
657 .make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Text")
658 .ok()?;
659 let char_count: i32 = proxy.get_property("CharacterCount").ok()?;
660 if char_count > 0 {
661 let reply = proxy.call_method("GetText", &(0i32, char_count)).ok()?;
662 let text: String = reply.body().deserialize().ok()?;
663 if !text.is_empty() {
664 return Some(text);
665 }
666 }
667 None
668 }
669
670 /// Cache an AccessibleRef and return a new handle ID.
671 fn cache_element(&self, aref: AccessibleRef) -> u64 {
672 let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
673 self.handle_cache
674 .lock()
675 .unwrap_or_else(|e| e.into_inner())
676 .insert(handle, aref);
677 handle
678 }
679
680 /// Look up a cached AccessibleRef by handle.
681 fn get_cached(&self, handle: u64) -> Result<AccessibleRef> {
682 self.handle_cache
683 .lock()
684 .unwrap_or_else(|e| e.into_inner())
685 .get(&handle)
686 .cloned()
687 .ok_or(Error::ElementStale {
688 selector: format!("handle:{}", handle),
689 })
690 }
691
692 /// Build an ElementData from an AccessibleRef, caching the ref for later lookup.
693 ///
694 /// After resolving the role (1-3 sequential D-Bus calls), all remaining
695 /// property fetches are independent and run in parallel via rayon::join.
696 fn build_element_data(&self, aref: &AccessibleRef, pid: Option<u32>) -> ElementData {
697 let role_name = self.get_role_name(aref).unwrap_or_default();
698 let role_num = self.get_role_number(aref).unwrap_or(0);
699 let coarse_role = {
700 let by_name = if !role_name.is_empty() {
701 map_atspi_role(&role_name)
702 } else {
703 Role::Unknown
704 };
705 let coarse = if by_name != Role::Unknown {
706 by_name
707 } else {
708 map_atspi_role_number(role_num)
709 };
710 self.refine_text_role(coarse, aref)
711 };
712 // GTK 3 overloads AT-SPI's `menu` role: both a popup menu container
713 // and the MenuItem that opens a submenu report `menu`. The entry is
714 // distinguishable by its native activation action (`click`, normalized
715 // to `press`); GtkMenu itself has no Action interface. Fetch once here
716 // so role refinement and the advertised action list use the same
717 // evidence without a second D-Bus round trip.
718 let prefetched_menu_actions = if coarse_role == Role::Menu {
719 Some(self.get_actions(aref))
720 } else {
721 None
722 };
723 let role = refine_menu_role(
724 coarse_role,
725 prefetched_menu_actions
726 .as_ref()
727 .map(|(actions, _)| actions.as_slice())
728 .unwrap_or(&[]),
729 );
730
731 // Fetch all independent properties in parallel.
732 // Left tree: (name+value, description)
733 // Right tree: ((states, bounds), (actions, numeric_values))
734 let (
735 ((mut name, value), description),
736 (
737 ((states, state_bits), bounds),
738 ((actions, action_index_map), (numeric_value, min_value, max_value)),
739 ),
740 ) = rayon::join(
741 || {
742 rayon::join(
743 || {
744 let name = self.get_name(aref).ok().filter(|s| !s.is_empty());
745 let value = if role_has_value(role) {
746 self.get_value(aref)
747 } else {
748 None
749 };
750 (name, value)
751 },
752 || self.get_description(aref).ok().filter(|s| !s.is_empty()),
753 )
754 },
755 || {
756 rayon::join(
757 || {
758 rayon::join(
759 || self.parse_states_with_bits(aref, role),
760 || {
761 if role != Role::Application {
762 self.get_extents(aref)
763 } else {
764 None
765 }
766 },
767 )
768 },
769 || {
770 rayon::join(
771 || {
772 if let Some(actions) = prefetched_menu_actions.as_ref() {
773 actions.clone()
774 } else if role_has_actions(role) {
775 self.get_actions(aref)
776 } else {
777 (vec![], HashMap::new())
778 }
779 },
780 || {
781 if matches!(
782 role,
783 Role::Slider
784 | Role::ProgressBar
785 | Role::ScrollBar
786 | Role::SpinButton
787 ) {
788 if let Ok(proxy) = self.make_proxy(
789 &aref.bus_name,
790 &aref.path,
791 "org.a11y.atspi.Value",
792 ) {
793 (
794 proxy.get_property::<f64>("CurrentValue").ok(),
795 proxy.get_property::<f64>("MinimumValue").ok(),
796 proxy.get_property::<f64>("MaximumValue").ok(),
797 )
798 } else {
799 (None, None, None)
800 }
801 } else {
802 (None, None, None)
803 }
804 },
805 )
806 },
807 )
808 },
809 );
810
811 // For label/static text elements, AT-SPI may put content in the Text
812 // interface (returned as value) rather than the Name property.
813 if name.is_none() && role == Role::StaticText {
814 if let Some(ref v) = value {
815 name = Some(v.clone());
816 }
817 }
818
819 // `activate` is deliberately not advertised from AT-SPI alone. A real
820 // native window backend may add it below after proving identity and
821 // checking capabilities. The AT-SPI-only path is Component.GrabFocus
822 // on the top-level frame, and Windows/macOS advertise theirs unconditionally,
823 // but the AT-SPI adapters disagree about implementing a *frame*
824 // GrabFocus: the GTK widget adapter answers true, while Chromium
825 // (Electron), WebKitGTK (Tauri — whose bridge reports itself as
826 // ToolkitName "GTK", so even a toolkit gate cannot separate it from
827 // the GTK widget app) and Qt answer false. No interface or toolkit
828 // probe discriminates them, and a snapshot-side GrabFocus probe
829 // would activate windows as a side effect. Advertising anyway would
830 // promise an action the call then declines — the false-promise shape
831 // the integration suites exist to catch (round-4 CI: it failed the
832 // activate suites on electron, tauri and qt; only the GTK cell passed).
833 // The action stays callable on every toolkit, and the call reports a
834 // `false` reply or a D-Bus NotSupported error honestly as
835 // ActionNotSupported (tenet 3; tenet 1 — no silent fallback — is
836 // why there is no substitute mechanism to advertise instead).
837
838 let raw = {
839 let raw_role = if role_name.is_empty() {
840 format!("role_num:{}", role_num)
841 } else {
842 role_name
843 };
844 let mut raw = HashMap::new();
845 raw.insert("atspi_role".into(), serde_json::Value::String(raw_role));
846 raw.insert(
847 "bus_name".into(),
848 serde_json::Value::String(aref.bus_name.clone()),
849 );
850 raw.insert(
851 "object_path".into(),
852 serde_json::Value::String(aref.path.clone()),
853 );
854 // Preserve unstripped originals so callers who need bidi marks can
855 // recover them after the strip below.
856 if let Some(ref n) = name {
857 raw.insert("atspi_name".into(), serde_json::Value::String(n.clone()));
858 }
859 if let Some(ref v) = value {
860 raw.insert("atspi_value".into(), serde_json::Value::String(v.clone()));
861 }
862 if let Some(ref d) = description {
863 raw.insert(
864 "atspi_description".into(),
865 serde_json::Value::String(d.clone()),
866 );
867 }
868 // DEFUNCT (bit 6) marks a dead accessible — the object was
869 // destroyed provider-side but a reference still resolves. It has
870 // no cross-platform StateSet field, but it's the key diagnostic
871 // for stale-reference bugs (e.g. a churning WebKit tree), so
872 // surface it whenever set.
873 if (state_bits & (1 << 6)) != 0 {
874 raw.insert("atspi_defunct".into(), serde_json::Value::Bool(true));
875 }
876 raw
877 };
878
879 // Strip Unicode bidi format controls. RTL apps on Linux embed LRM/RLM
880 // marks into reported strings; the originals are preserved in `raw`.
881 let name = xa11y_core::text::strip_bidi_opt(name);
882 let value = xa11y_core::text::strip_bidi_opt(value);
883 let description = xa11y_core::text::strip_bidi_opt(description);
884
885 let handle = self.cache_element(aref.clone());
886 if !action_index_map.is_empty() {
887 self.action_indices
888 .lock()
889 .unwrap_or_else(|e| e.into_inner())
890 .insert(handle, action_index_map);
891 }
892
893 let mut data: ElementData = ElementParts {
894 role,
895 name,
896 value,
897 description,
898 bounds,
899 actions,
900 states,
901 numeric_value,
902 min_value,
903 max_value,
904 stable_id: Some(aref.path.clone()),
905 pid,
906 raw,
907 handle,
908 }
909 .into();
910
911 if matches!(role, Role::Window | Role::Dialog) && self.is_top_level_window_ref(aref) {
912 let facts = self.window_manager.facts(&data);
913 if facts.bounds.is_some() {
914 data.bounds = facts.bounds;
915 }
916 for action in &facts.actions {
917 if !data.actions.iter().any(|existing| existing == action) {
918 data.actions.push((*action).to_string());
919 }
920 }
921 if facts.minimized.is_some() {
922 data.states.minimized = facts.minimized;
923 }
924 if facts.maximized.is_some() {
925 data.states.maximized = facts.maximized;
926 }
927 if facts.fullscreen.is_some() {
928 data.states.fullscreen = facts.fullscreen;
929 }
930 if let Some(backend) = facts.backend {
931 data.raw.insert(
932 "window_backend".to_string(),
933 serde_json::Value::String(backend.to_string()),
934 );
935 }
936 if let Some(version) = facts.protocol_version {
937 data.raw.insert(
938 "window_protocol_version".to_string(),
939 serde_json::Value::Number(version.into()),
940 );
941 }
942 let capabilities = [
943 "activate",
944 "minimize",
945 "maximize",
946 "enter_fullscreen",
947 "restore",
948 "close",
949 "move_to",
950 "resize_to",
951 ]
952 .into_iter()
953 .map(|action| {
954 let status = if facts.backend.is_none() {
955 "unknown"
956 } else if facts.actions.contains(&action) {
957 "supported"
958 } else {
959 "unsupported"
960 };
961 (
962 action.to_string(),
963 serde_json::Value::String(status.to_string()),
964 )
965 })
966 .collect();
967 data.raw.insert(
968 "window_capabilities".to_string(),
969 serde_json::Value::Object(capabilities),
970 );
971 }
972
973 data
974 }
975
976 /// Get the AT-SPI parent of an accessible ref.
977 fn get_atspi_parent(&self, aref: &AccessibleRef) -> Result<Option<AccessibleRef>> {
978 // Read the Parent property via the D-Bus Properties interface.
979 let proxy = self.make_proxy(
980 &aref.bus_name,
981 &aref.path,
982 "org.freedesktop.DBus.Properties",
983 )?;
984 let reply = proxy
985 .call_method("Get", &("org.a11y.atspi.Accessible", "Parent"))
986 .map_err(|e| Error::Platform {
987 code: -1,
988 message: format!("Get Parent property failed: {}", e),
989 })?;
990 // The reply is a Variant containing (so) — a struct of (bus_name, object_path)
991 let variant: zbus::zvariant::OwnedValue =
992 reply.body().deserialize().map_err(|e| Error::Platform {
993 code: -1,
994 message: format!("Parent deserialize variant failed: {}", e),
995 })?;
996 let (bus, path): (String, zbus::zvariant::OwnedObjectPath) =
997 zbus::zvariant::Value::from(variant).try_into().map_err(
998 |e: zbus::zvariant::Error| Error::Platform {
999 code: -1,
1000 message: format!("Parent deserialize struct failed: {}", e),
1001 },
1002 )?;
1003 let path_str = path.as_str();
1004 if path_str == "/org/a11y/atspi/null" || bus.is_empty() || path_str.is_empty() {
1005 return Ok(None);
1006 }
1007 // If the parent is the registry root, this is a top-level app — no parent
1008 if path_str == "/org/a11y/atspi/accessible/root" {
1009 return Ok(None);
1010 }
1011 Ok(Some(AccessibleRef {
1012 bus_name: bus,
1013 path: path_str.to_string(),
1014 }))
1015 }
1016
1017 /// Fetch an accessible's AT-SPI2 state bitfield as a single `u64`.
1018 ///
1019 /// AT-SPI2 returns states as two `u32`s (low/high words); collapse them
1020 /// into one mask so callers can test bit positions directly.
1021 ///
1022 /// The `bool` reports whether `GetState` itself succeeded. A failed read
1023 /// is not "all states false": window-state optionals (`minimized`) must
1024 /// stay unknown rather than guess `false` (tenet 1), just as the rest of
1025 /// the window contract treats an unread state as `None`.
1026 fn state_bits(&self, aref: &AccessibleRef) -> (u64, bool) {
1027 match self.get_state(aref) {
1028 Ok(state_bits) if state_bits.len() >= 2 => (
1029 (state_bits[0] as u64) | ((state_bits[1] as u64) << 32),
1030 true,
1031 ),
1032 Ok(state_bits) if state_bits.len() == 1 => (state_bits[0] as u64, true),
1033 Ok(_) => (0, true),
1034 Err(_) => (0, false),
1035 }
1036 }
1037
1038 /// Parse AT-SPI2 state bitfield into xa11y StateSet.
1039 fn parse_states(&self, aref: &AccessibleRef, role: Role) -> StateSet {
1040 self.parse_states_with_bits(aref, role).0
1041 }
1042
1043 /// Like [`parse_states`], but also returns the raw AT-SPI state bitset so
1044 /// callers can surface provider-only facts (notably DEFUNCT) that have no
1045 /// cross-platform `StateSet` field.
1046 fn parse_states_with_bits(&self, aref: &AccessibleRef, role: Role) -> (StateSet, u64) {
1047 let (bits, state_read_ok) = self.state_bits(aref);
1048
1049 // AT-SPI2 state bit positions: each is `1 << ordinal` of the
1050 // `AtspiStateType` enum in at-spi2-core's `atspi/atspi-constants.h`
1051 // (ICONIFIED=15, MODAL=16, SELECTED=23, VISIBLE=30, ...). Kept in
1052 // sync with `states_from_bits` in events.rs.
1053 const ACTIVE: u64 = 1 << 1;
1054 const BUSY: u64 = 1 << 3;
1055 const CHECKED: u64 = 1 << 4;
1056 const EDITABLE: u64 = 1 << 7;
1057 const ENABLED: u64 = 1 << 8;
1058 const EXPANDABLE: u64 = 1 << 9;
1059 const EXPANDED: u64 = 1 << 10;
1060 const FOCUSABLE: u64 = 1 << 11;
1061 const FOCUSED: u64 = 1 << 12;
1062 const ICONIFIED: u64 = 1 << 15;
1063 const MODAL: u64 = 1 << 16;
1064 const SELECTED: u64 = 1 << 23;
1065 const SENSITIVE: u64 = 1 << 24;
1066 const SHOWING: u64 = 1 << 25;
1067 const VISIBLE: u64 = 1 << 30;
1068 const INDETERMINATE: u64 = 1 << 32;
1069 const REQUIRED: u64 = 1 << 33;
1070
1071 let enabled = (bits & ENABLED) != 0 || (bits & SENSITIVE) != 0;
1072 let visible = (bits & VISIBLE) != 0 || (bits & SHOWING) != 0;
1073
1074 let checked = match role {
1075 Role::CheckBox | Role::RadioButton | Role::MenuItem | Role::Switch => {
1076 if (bits & INDETERMINATE) != 0 {
1077 Some(Toggled::Mixed)
1078 } else if (bits & CHECKED) != 0 {
1079 Some(Toggled::On)
1080 } else {
1081 Some(Toggled::Off)
1082 }
1083 }
1084 _ => None,
1085 };
1086
1087 let expanded = if (bits & EXPANDABLE) != 0 {
1088 Some((bits & EXPANDED) != 0)
1089 } else {
1090 None
1091 };
1092
1093 let states = StateParts {
1094 enabled,
1095 visible,
1096 focused: (bits & FOCUSED) != 0,
1097 // AT-SPI `ACTIVE` marks the foreground window/frame (see
1098 // `focused_app`, which relies on the same bit).
1099 active: (bits & ACTIVE) != 0,
1100 checked,
1101 selected: (bits & SELECTED) != 0,
1102 expanded,
1103 editable: (bits & EDITABLE) != 0,
1104 focusable: (bits & FOCUSABLE) != 0,
1105 modal: (bits & MODAL) != 0,
1106 required: (bits & REQUIRED) != 0,
1107 busy: (bits & BUSY) != 0,
1108 // `ICONIFIED` is AT-SPI's minimized state, reported on window
1109 // frames. Maximized/fullscreen are not reported by AT-SPI at all
1110 // (no state bit), so they stay `None` — unknown, not guessed
1111 // `false` (tenet 1). macOS reads the fullscreen state from
1112 // AXFullScreen, but no platform can raise a
1113 // StateChanged{fullscreen} event: the AX API has no fullscreen
1114 // notification and UIA has no fullscreen state either. A failed
1115 // `GetState` must likewise leave `minimized` unknown — an unread
1116 // state is not a state that was read as absent.
1117 minimized: if matches!(role, Role::Window | Role::Dialog) {
1118 if state_read_ok {
1119 Some((bits & ICONIFIED) != 0)
1120 } else {
1121 None
1122 }
1123 } else {
1124 None
1125 },
1126 maximized: None,
1127 fullscreen: None,
1128 }
1129 .into();
1130 (states, bits)
1131 }
1132
1133 /// Find an application by PID.
1134 ///
1135 /// Used by `subscribe` to resolve the target app's D-Bus sender name so
1136 /// signal match rules can be scoped to it.
1137 pub(crate) fn find_apps_by_pid(&self, pid: u32) -> Result<Vec<AccessibleRef>> {
1138 let mut matches = Vec::new();
1139 let registry = AccessibleRef {
1140 bus_name: "org.a11y.atspi.Registry".to_string(),
1141 path: "/org/a11y/atspi/accessible/root".to_string(),
1142 };
1143 let children = self.get_atspi_children(®istry)?;
1144
1145 for child in &children {
1146 if child.path == "/org/a11y/atspi/null" {
1147 continue;
1148 }
1149 // Prefer D-Bus connection PID — it's authoritative for the
1150 // process owning the bus connection. Application.Id is *not* a
1151 // process pid: AT-SPI assigns it as a registry-local index
1152 // (1, 2, 3, …) for some bridges (notably GTK4), so matching
1153 // against Application.Id alone misses real processes.
1154 if let Some(app_pid) = self.get_dbus_pid(&child.bus_name) {
1155 if app_pid == pid {
1156 matches.push(child.clone());
1157 continue;
1158 }
1159 }
1160 // Fall back to Application.Id for adapters that do set it to pid.
1161 if let Ok(proxy) =
1162 self.make_proxy(&child.bus_name, &child.path, "org.a11y.atspi.Application")
1163 {
1164 if let Ok(app_pid) = proxy.get_property::<i32>("Id") {
1165 if app_pid as u32 == pid {
1166 matches.push(child.clone());
1167 }
1168 }
1169 }
1170 }
1171
1172 Ok(matches)
1173 }
1174
1175 /// Get PID via D-Bus GetConnectionUnixProcessID.
1176 fn get_dbus_pid(&self, bus_name: &str) -> Option<u32> {
1177 let proxy = self
1178 .make_proxy(
1179 "org.freedesktop.DBus",
1180 "/org/freedesktop/DBus",
1181 "org.freedesktop.DBus",
1182 )
1183 .ok()?;
1184 let reply = proxy
1185 .call_method("GetConnectionUnixProcessID", &(bus_name,))
1186 .ok()?;
1187 let pid: u32 = reply.body().deserialize().ok()?;
1188 if pid > 0 {
1189 Some(pid)
1190 } else {
1191 None
1192 }
1193 }
1194
1195 /// Perform an AT-SPI2 action by its integer index (from discovery).
1196 fn do_atspi_action_by_index(&self, aref: &AccessibleRef, index: i32) -> Result<()> {
1197 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action")?;
1198 let reply = proxy
1199 .call_method("DoAction", &(index,))
1200 .map_err(|e| Error::Platform {
1201 code: -1,
1202 message: format!("DoAction({}) failed: {}", index, e),
1203 })?;
1204 // AT-SPI's DoAction returns a boolean: true means the action ran. A
1205 // false reply means the provider declined — reporting Ok would let
1206 // activate()/focus() claim success while the window was never activated.
1207 let performed: bool = reply.body().deserialize().map_err(|e| Error::Platform {
1208 code: -1,
1209 message: format!("DoAction({}) reply decode failed: {}", index, e),
1210 })?;
1211 if !performed {
1212 return Err(Error::Platform {
1213 code: -1,
1214 message: format!(
1215 "DoAction({}) returned false; the accessibility provider declined the action",
1216 index
1217 ),
1218 });
1219 }
1220 Ok(())
1221 }
1222
1223 /// Look up the stored AT-SPI2 action index for the given element and action.
1224 fn get_action_index(&self, handle: u64, action: &str) -> Result<i32> {
1225 self.action_indices
1226 .lock()
1227 .unwrap_or_else(|e| e.into_inner())
1228 .get(&handle)
1229 .and_then(|map| map.get(action).copied())
1230 .ok_or_else(|| Error::ActionNotSupported {
1231 action: action.to_string(),
1232 role: Role::Unknown, // caller will provide better context
1233 })
1234 }
1235
1236 /// Get the process PID for an AT-SPI accessible. Prefers the D-Bus
1237 /// connection PID over `Application.Id`: the latter is a registry-local
1238 /// index (1, 2, 3, …) on some bridges (e.g. GTK4), not a real pid, so
1239 /// reading it first produces apps whose `pid` property doesn't match the
1240 /// process the user launched.
1241 fn get_app_pid(&self, aref: &AccessibleRef) -> Option<u32> {
1242 if let Some(pid) = self.get_dbus_pid(&aref.bus_name) {
1243 return Some(pid);
1244 }
1245
1246 // Fall back to Application.Id for adapters that set it to pid.
1247 // Require that a process with that id actually exists — a registry
1248 // index from a misbehaving bridge usually names a long-dead or absurd
1249 // pid. This is only a cheap sanity filter: a low index can still
1250 // collide with a real process (1 = init), which is why the D-Bus
1251 // connection pid above is authoritative whenever available.
1252 if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Application")
1253 {
1254 if let Ok(pid) = proxy.get_property::<i32>("Id") {
1255 if pid > 0 && std::path::Path::new(&format!("/proc/{pid}")).exists() {
1256 return Some(pid as u32);
1257 }
1258 }
1259 }
1260
1261 None
1262 }
1263
1264 /// Resolve the mapped Role for an accessible ref (1-3 D-Bus calls, plus
1265 /// an Action probe for AT-SPI `menu`, whose meaning is ambiguous on GTK 3).
1266 fn resolve_role(&self, aref: &AccessibleRef) -> Role {
1267 let role_name = self.get_role_name(aref).unwrap_or_default();
1268 let by_name = if !role_name.is_empty() {
1269 map_atspi_role(&role_name)
1270 } else {
1271 Role::Unknown
1272 };
1273 let coarse = if by_name != Role::Unknown {
1274 by_name
1275 } else {
1276 // Unmapped or missing role name — fall back to numeric role.
1277 let role_num = self.get_role_number(aref).unwrap_or(0);
1278 map_atspi_role_number(role_num)
1279 };
1280 let role = self.refine_text_role(coarse, aref);
1281 if role == Role::Menu {
1282 let (actions, _) = self.get_actions(aref);
1283 refine_menu_role(role, &actions)
1284 } else {
1285 role
1286 }
1287 }
1288
1289 /// AT-SPI role-name probe for top-level decisions: is `parent` the
1290 /// application node?
1291 ///
1292 /// AccessKit's AT-SPI bridge reports role names as empty strings and
1293 /// carries the actual role as the numeric one (see `resolve_role`); an
1294 /// empty name fed to `map_atspi_role` would hit the strict mapper's panic
1295 /// arm — the exact failure the integration suite surfaced (every element's
1296 /// snapshot went through the round-4 top-level probe and died). The empty
1297 /// name is resolved numerically, exactly like `resolve_role`, so the
1298 /// probe agrees with the snapshot path, and a D-Bus failure is the
1299 /// caller's decision to make (propagated for the runtime guard).
1300 fn parent_role_is_application(&self, parent: &AccessibleRef) -> Result<bool> {
1301 let role_name = self.get_role_name(parent)?;
1302 let role = if role_name.is_empty() {
1303 map_atspi_role_number(self.get_role_number(parent)?)
1304 } else {
1305 map_atspi_role(&role_name)
1306 };
1307 Ok(role == Role::Application)
1308 }
1309
1310 fn ensure_top_level_window_target(&self, element: &ElementData, action: &str) -> Result<()> {
1311 // AT-SPI window discovery is "direct child of the application node";
1312 // descendants with role=dialog are document structure, not OS windows.
1313 //
1314 // A Parent of the registry root (or the null address) is the AT-SPI
1315 // top-level shape: some adapters — AccessKit's, per the
1316 // `app_node_contract` integ test — report a top-level frame's Parent
1317 // there instead of on the application, and `get_atspi_parent` maps
1318 // it to `None` (see `get_parent`). `None` is therefore a
1319 // top-level-window answer, not a rejection: only an explicit
1320 // non-application parent (an in-page ARIA dialog's document ancestor)
1321 // means "not a real OS window".
1322 let target = self.get_cached(element.handle)?;
1323 let Some(parent) = self.get_atspi_parent(&target)? else {
1324 return Ok(());
1325 };
1326 // Same resolution as the advertisement probe (`is_atspi_top_level`),
1327 // so a window the snapshot advertised and the runtime guard can never
1328 // disagree; empty role names resolve numerically (AccessKit's bridge)
1329 // instead of feeding the strict mapper's panic arm.
1330 if self.parent_role_is_application(&parent)? {
1331 Ok(())
1332 } else {
1333 Err(Error::ActionNotSupported {
1334 action: action.to_string(),
1335 role: element.role,
1336 })
1337 }
1338 }
1339
1340 fn is_top_level_window_ref(&self, target: &AccessibleRef) -> bool {
1341 match self.get_atspi_parent(target) {
1342 Ok(None) => true,
1343 Ok(Some(parent)) => self.parent_role_is_application(&parent).unwrap_or(false),
1344 Err(_) => false,
1345 }
1346 }
1347
1348 /// Check if an accessible ref matches a simple selector, fetching only the
1349 /// attributes the selector actually requires.
1350 ///
1351 /// Filter routing (cheapest first):
1352 /// * `role` / `name` / `value` / `description` — single targeted D-Bus
1353 /// call against this accessible.
1354 /// * Normalized state attrs (`enabled`, `checked`, `focused`, …) —
1355 /// one shared `GetState` call, cached for the rest of this match.
1356 /// * Anything else (custom `raw` keys, `bounds`, numeric values) — fall
1357 /// through to building a full `ElementData` and delegating to
1358 /// [`xa11y_core::selector::matches_simple`]. This is slower but keeps
1359 /// selectors with rare attribute filters semantically equivalent to
1360 /// the default tree-traversal path.
1361 fn matches_ref(
1362 &self,
1363 aref: &AccessibleRef,
1364 simple: &xa11y_core::selector::SimpleSelector,
1365 pid: Option<u32>,
1366 ) -> bool {
1367 // Resolve role only if the selector needs it (for either the role
1368 // segment or any role/checked filter — checked depends on role).
1369 let needs_role = simple.role.is_some()
1370 || simple
1371 .filters
1372 .iter()
1373 .any(|f| matches!(f.attr.as_str(), "role" | "checked"));
1374 let role = if needs_role {
1375 Some(self.resolve_role(aref))
1376 } else {
1377 None
1378 };
1379
1380 if let Some(ref role_match) = simple.role {
1381 match role_match {
1382 xa11y_core::selector::RoleMatch::Normalized(expected) => {
1383 if role != Some(*expected) {
1384 return false;
1385 }
1386 }
1387 xa11y_core::selector::RoleMatch::Platform(platform_role) => {
1388 let raw_role = self.get_role_name(aref).unwrap_or_default();
1389 if raw_role != *platform_role {
1390 return false;
1391 }
1392 }
1393 }
1394 }
1395
1396 let mut state_set: Option<StateSet> = None;
1397
1398 for filter in &simple.filters {
1399 let attr = filter.attr.as_str();
1400 let resolved: Option<Option<String>> = match attr {
1401 "role" => Some(role.map(|r| r.to_snake_case().to_string())),
1402 "name" => {
1403 let name = self.get_name(aref).ok().filter(|s| !s.is_empty());
1404 // Mirror build_element_data: StaticText carries its name
1405 // in the Text interface's Value when Name is empty.
1406 let resolved = if name.is_none() && role == Some(Role::StaticText) {
1407 self.get_value(aref)
1408 } else {
1409 name
1410 };
1411 Some(resolved)
1412 }
1413 "value" => Some(self.get_value(aref)),
1414 "description" => Some(self.get_description(aref).ok().filter(|s| !s.is_empty())),
1415 "enabled" | "visible" | "focused" | "active" | "focusable" | "selected"
1416 | "editable" | "modal" | "required" | "busy" | "expanded" | "checked" => {
1417 let s = state_set.get_or_insert_with(|| {
1418 // `parse_states` reads `checked` based on role; pass
1419 // whatever we already resolved (Unknown is a no-op for
1420 // the role-gated `checked` mapping).
1421 self.parse_states(aref, role.unwrap_or(Role::Unknown))
1422 });
1423 Some(state_attr_to_string(attr, s))
1424 }
1425 _ => None, // Routed through full ElementData below.
1426 };
1427
1428 match resolved {
1429 Some(value) => {
1430 if !xa11y_core::selector::match_op(&filter.op, &filter.value, value.as_deref())
1431 {
1432 return false;
1433 }
1434 }
1435 None => {
1436 // Filter targets an attribute the fast path doesn't know
1437 // (e.g. `bounds`, `numeric_value`, a custom `raw` key).
1438 // Build the full ElementData once and let the shared
1439 // matcher handle every remaining filter — it dispatches
1440 // to `ElementData` fields and the `raw` map identically
1441 // to the default tree-traversal path.
1442 let data = self.build_element_data(aref, pid);
1443 return xa11y_core::selector::matches_simple(&data, simple);
1444 }
1445 }
1446 }
1447
1448 true
1449 }
1450
1451 /// ONE DFS over the subtree, evaluating each clause's first SimpleSelector
1452 /// against every visited node. Emits `(clause_idx, AccessibleRef)` pairs
1453 /// in document order; a node that matches multiple clauses is emitted
1454 /// once per matching clause (the merge step in `find_elements_group`
1455 /// collapses these by `(bus_name, path)` identity).
1456 ///
1457 /// This is the "push-down to the platform query per group" core: a
1458 /// selector group like `button, text_field` traverses the AT-SPI tree
1459 /// exactly once regardless of clause count, instead of N separate walks.
1460 fn collect_matching_refs_group(
1461 &self,
1462 parent: &AccessibleRef,
1463 clauses: &[&xa11y_core::selector::SimpleSelector],
1464 depth: u32,
1465 max_depth: u32,
1466 limit: Option<usize>,
1467 pid: Option<u32>,
1468 ) -> Result<Vec<(usize, AccessibleRef)>> {
1469 if depth > max_depth {
1470 return Ok(vec![]);
1471 }
1472
1473 let children = self.get_atspi_children(parent)?;
1474
1475 // Filter invalid refs. Application-node flattening (collapsing a
1476 // redundant `application` accessible into its grandchildren) is only
1477 // valid when the parent is itself an application — i.e. we have already
1478 // descended past the registry. At the registry root, the children *are*
1479 // the applications and must not be collapsed, otherwise selectors like
1480 // `application` (used by `App::list` / `App::by_name`) match nothing
1481 // because the app accessibles get dissolved into their windows.
1482 let parent_is_registry = parent.bus_name == "org.a11y.atspi.Registry";
1483 let mut to_search: Vec<AccessibleRef> = Vec::new();
1484 for child in children {
1485 if child.path == "/org/a11y/atspi/null"
1486 || child.bus_name.is_empty()
1487 || child.path.is_empty()
1488 {
1489 continue;
1490 }
1491
1492 if !parent_is_registry {
1493 let child_role = self.get_role_name(&child).unwrap_or_default();
1494 if child_role == "application" {
1495 let grandchildren = self.get_atspi_children(&child).unwrap_or_default();
1496 for gc in grandchildren {
1497 if gc.path == "/org/a11y/atspi/null"
1498 || gc.bus_name.is_empty()
1499 || gc.path.is_empty()
1500 {
1501 continue;
1502 }
1503 let gc_role = self.get_role_name(&gc).unwrap_or_default();
1504 if gc_role == "application" {
1505 continue;
1506 }
1507 to_search.push(gc);
1508 }
1509 continue;
1510 }
1511 }
1512 to_search.push(child);
1513 }
1514
1515 // Detect Chromium/Electron's empty-tree signature whenever we
1516 // descend into a parent and find no real children.
1517 if to_search.is_empty() {
1518 self.check_chromium_a11y_enabled(parent, None)?;
1519 }
1520
1521 // Process each child subtree in parallel: check match against every
1522 // clause + recurse. Tagging each match with `clause_idx` lets the
1523 // caller route phase-2 narrowing to the right clause without
1524 // re-running the first-segment match.
1525 //
1526 // We deliberately swallow transient sibling errors here — a single
1527 // flaky D-Bus call on one child shouldn't fail the whole locator
1528 // query (the rest of this file is similarly tolerant via
1529 // `unwrap_or_default()`). But `AccessibilityNotEnabled` is *not* a
1530 // transient error: it's the signal that a Chromium renderer bridge
1531 // isn't initialised, and callers need to see it. So we propagate
1532 // that variant specifically and keep tolerating everything else.
1533 // Per-child batches: matches collected during that child's subtree
1534 // walk, plus the first AccessibilityNotEnabled error (if any).
1535 type ChildBatch = (Vec<(usize, AccessibleRef)>, Option<Error>);
1536 let per_child: Vec<ChildBatch> = to_search
1537 .par_iter()
1538 .map(|child| {
1539 let mut child_results: Vec<(usize, AccessibleRef)> = Vec::new();
1540 for (idx, simple) in clauses.iter().enumerate() {
1541 if self.matches_ref(child, simple, pid) {
1542 child_results.push((idx, child.clone()));
1543 }
1544 }
1545 match self.collect_matching_refs_group(
1546 child,
1547 clauses,
1548 depth + 1,
1549 max_depth,
1550 limit,
1551 pid,
1552 ) {
1553 Ok(sub) => {
1554 child_results.extend(sub);
1555 (child_results, None)
1556 }
1557 Err(e @ Error::AccessibilityNotEnabled { .. }) => (Vec::new(), Some(e)),
1558 Err(_) => (child_results, None),
1559 }
1560 })
1561 .collect();
1562
1563 // Merge results, respecting limit. The first AccessibilityNotEnabled
1564 // error seen wins — any child subtree raising it means the whole
1565 // query is untrustworthy, so surface it rather than return partial
1566 // data.
1567 //
1568 // Note: `limit` is the *caller's* outer limit; for a multi-clause
1569 // group it must not be applied per-clause (a low-priority clause's
1570 // hit could come before a high-priority clause's in doc order, so
1571 // truncating mid-walk would drop legitimate matches). Callers pass
1572 // `None` for multi-clause queries and apply the limit after the
1573 // cross-clause merge.
1574 let mut results = Vec::new();
1575 for (batch, maybe_err) in per_child {
1576 if let Some(err) = maybe_err {
1577 return Err(err);
1578 }
1579 for r in batch {
1580 results.push(r);
1581 if let Some(limit) = limit {
1582 if results.len() >= limit {
1583 return Ok(results);
1584 }
1585 }
1586 }
1587 }
1588 Ok(results)
1589 }
1590}
1591
1592impl Provider for LinuxProvider {
1593 fn get_children(&self, element: Option<&ElementData>) -> Result<Vec<ElementData>> {
1594 match element {
1595 None => {
1596 // Top-level: delegate to `list_apps()`, the canonical
1597 // discovery primitive.
1598 self.list_apps()
1599 }
1600 Some(element_data) => {
1601 let aref = self.get_cached(element_data.handle)?;
1602 // Propagate, never default to an empty list (tenet 1): this
1603 // path backs `App::windows()` / `Element::children()`, and a
1604 // D-Bus/AT-SPI failure must surface as the error the CLI
1605 // and MCP partial-error reporting expect. An `Ok([])` read as
1606 // "the app has no windows" would print "No windows found."
1607 // over a live failure. (The selector-search path
1608 // `find_elements_group` keeps its own documented tolerance —
1609 // a flaky sibling must not fail a whole locator query — but
1610 // this is a direct enumeration, not a search.)
1611 let children = self.get_atspi_children(&aref)?;
1612 let pid = element_data.pid;
1613
1614 // Pre-filter invalid refs and flatten nested application nodes,
1615 // collecting the concrete refs to build in parallel.
1616 let mut to_build: Vec<AccessibleRef> = Vec::new();
1617 for child_ref in &children {
1618 if child_ref.path == "/org/a11y/atspi/null"
1619 || child_ref.bus_name.is_empty()
1620 || child_ref.path.is_empty()
1621 {
1622 continue;
1623 }
1624 let child_role = self.get_role_name(child_ref).unwrap_or_default();
1625 if child_role == "application" {
1626 // Same rule as above: dropping the nested app's
1627 // windows on a failure would quietly return a partial
1628 // `windows()` list that reads as complete.
1629 let grandchildren = self.get_atspi_children(child_ref)?;
1630 for gc_ref in grandchildren {
1631 if gc_ref.path == "/org/a11y/atspi/null"
1632 || gc_ref.bus_name.is_empty()
1633 || gc_ref.path.is_empty()
1634 {
1635 continue;
1636 }
1637 let gc_role = self.get_role_name(&gc_ref).unwrap_or_default();
1638 if gc_role == "application" {
1639 continue;
1640 }
1641 to_build.push(gc_ref);
1642 }
1643 continue;
1644 }
1645 to_build.push(child_ref.clone());
1646 }
1647
1648 if to_build.is_empty() {
1649 self.check_chromium_a11y_enabled(&aref, Some(element_data.role))?;
1650 }
1651
1652 let results: Vec<ElementData> = to_build
1653 .par_iter()
1654 .map(|r| self.build_element_data(r, pid))
1655 .collect();
1656
1657 Ok(results)
1658 }
1659 }
1660 }
1661
1662 fn find_elements_group(
1663 &self,
1664 root: &ElementData,
1665 group: &xa11y_core::selector::SelectorGroup,
1666 limit: Option<usize>,
1667 max_depth: Option<u32>,
1668 ) -> Result<Vec<ElementData>> {
1669 if group.clauses.is_empty() {
1670 return Ok(vec![]);
1671 }
1672 // Reject any clause with zero segments early — `clause.segments[0]`
1673 // below would otherwise panic.
1674 if group.clauses.iter().any(|c| c.segments.is_empty()) {
1675 return Ok(vec![]);
1676 }
1677
1678 // ONE AT-SPI walk that evaluates every clause's first SimpleSelector
1679 // against each visited node, then per-clause phase-2 narrowing on
1680 // the small candidate sets that fall out. Dedup uses
1681 // `(bus_name, path)` — the only AT-SPI identifier that's stable
1682 // across the per-clause narrowings (handles are minted fresh on
1683 // each `build_element_data` call).
1684 //
1685 // App discovery is handled separately by `list_apps()` — `root` is
1686 // always present here.
1687 let max_depth_val = max_depth.unwrap_or(xa11y_core::MAX_TREE_DEPTH);
1688
1689 let firsts: Vec<&xa11y_core::selector::SimpleSelector> = group
1690 .clauses
1691 .iter()
1692 .map(|c| &c.segments[0].simple)
1693 .collect();
1694
1695 let start_ref = self.get_cached(root.handle)?;
1696
1697 // ── Phase-1 limit short-circuit ───────────────────────────
1698 // When there's exactly one clause, propagate the user's `limit`
1699 // (adjusted for `:nth`) to the AT-SPI walk so e.g.
1700 // `app.locator("button").first()` stops at the first match. With
1701 // multiple clauses, phase-1 hits for clause N might come before
1702 // clause M's in doc order, so we need the union before truncating.
1703 // (Critical on Electron-scale apps where this prevents 50k-node
1704 // walks.)
1705 let phase1_limit = if group.clauses.len() == 1 {
1706 let clause = &group.clauses[0];
1707 let first = firsts[0];
1708 let outer = if clause.segments.len() == 1 {
1709 limit
1710 } else {
1711 None
1712 };
1713 match (outer, first.nth) {
1714 (Some(l), Some(n)) => Some(l.max(n)),
1715 (_, Some(n)) => Some(n),
1716 (l, None) => l,
1717 }
1718 } else {
1719 None
1720 };
1721
1722 let pid_from_root = root.pid;
1723 let phase1: Vec<(usize, AccessibleRef)> = self.collect_matching_refs_group(
1724 &start_ref,
1725 &firsts,
1726 0,
1727 max_depth_val,
1728 phase1_limit,
1729 pid_from_root,
1730 )?;
1731
1732 // Bucket phase-1 hits by clause so each clause's tail can narrow
1733 // independently. `walk_pos` preserves the doc-order rank from the
1734 // single walk for the final merge.
1735 let mut by_clause: Vec<Vec<(usize, AccessibleRef)>> =
1736 (0..group.clauses.len()).map(|_| Vec::new()).collect();
1737 for (walk_pos, (clause_idx, aref)) in phase1.into_iter().enumerate() {
1738 by_clause[clause_idx].push((walk_pos, aref));
1739 }
1740
1741 // For each clause, build ElementData for its phase-1 hits, then
1742 // narrow through any trailing segments. Single-segment clauses
1743 // skip phase 2 entirely.
1744 //
1745 // Each narrowed result keeps a (walk_pos, ElementData) pair: the
1746 // walk_pos of its phase-1 ancestor is the doc-order anchor we use
1747 // to merge across clauses at the end.
1748 let mut merged: Vec<(usize, ElementData)> = Vec::new();
1749 for (clause_idx, hits) in by_clause.into_iter().enumerate() {
1750 if hits.is_empty() {
1751 continue;
1752 }
1753 let clause = &group.clauses[clause_idx];
1754 // Build ElementData for this clause's phase-1 hits in parallel.
1755 let phase1_data: Vec<(usize, ElementData)> = hits
1756 .par_iter()
1757 .map(|(pos, aref)| {
1758 // `root` is always present now, so the PID comes from
1759 // the root element. No need to re-resolve via
1760 // `get_app_pid` / `get_dbus_pid`.
1761 (*pos, self.build_element_data(aref, pid_from_root))
1762 })
1763 .collect();
1764
1765 if clause.segments.len() == 1 {
1766 // Apply :nth on the first segment (per-clause, then merged).
1767 let mut candidates = phase1_data;
1768 if let Some(nth) = clause.segments[0].simple.nth {
1769 if nth <= candidates.len() {
1770 let kept = candidates.remove(nth - 1);
1771 candidates.clear();
1772 candidates.push(kept);
1773 } else {
1774 candidates.clear();
1775 }
1776 }
1777 merged.extend(candidates);
1778 continue;
1779 }
1780
1781 // Multi-segment narrowing: re-use the trait helper. Each
1782 // narrowed result is anchored at the phase-1 ancestor's
1783 // walk_pos so the final sort puts it in doc order against
1784 // other clauses.
1785 for (anchor_pos, head) in phase1_data {
1786 let narrowed = self.narrow_multi_segment(
1787 vec![head],
1788 &clause.segments[1..],
1789 max_depth_val,
1790 None,
1791 )?;
1792 for n in narrowed {
1793 merged.push((anchor_pos, n));
1794 }
1795 }
1796 }
1797
1798 // Doc-order merge: stable sort by walk position. Within the same
1799 // walk position multiple clauses can have emitted the same node —
1800 // dedup by AT-SPI identity (`(bus_name, path)`) keeps the first.
1801 merged.sort_by_key(|(pos, _)| *pos);
1802 let mut seen: HashSet<(String, String)> = HashSet::new();
1803 let mut out: Vec<ElementData> = Vec::with_capacity(merged.len());
1804 for (_, data) in merged {
1805 // Use the cached AccessibleRef for identity, not the (handle).
1806 // Handle is freshly minted per `build_element_data` call so it
1807 // would never collide here.
1808 if let Ok(aref) = self.get_cached(data.handle) {
1809 if !seen.insert((aref.bus_name.clone(), aref.path.clone())) {
1810 continue;
1811 }
1812 }
1813 out.push(data);
1814 }
1815 if let Some(l) = limit {
1816 out.truncate(l);
1817 }
1818 Ok(out)
1819 }
1820
1821 fn get_parent(&self, element: &ElementData) -> Result<Option<ElementData>> {
1822 let aref = self.get_cached(element.handle)?;
1823 match self.get_atspi_parent(&aref)? {
1824 Some(parent_ref) => {
1825 let data = self.build_element_data(&parent_ref, element.pid);
1826 Ok(Some(data))
1827 }
1828 None => Ok(None),
1829 }
1830 }
1831
1832 /// Enumerate desktop panels and docks.
1833 ///
1834 /// A Linux shell surface is a top-level AT-SPI frame whose attribute set
1835 /// carries `window-type:dock` — how panels self-identify, from
1836 /// xfce4-panel's panel and dock rows to GNOME's and KDE's shells. One
1837 /// surface per such frame, so a panel process that owns two rows yields
1838 /// two [`ShellSurfaceKind::Panel`] entries, each with the panel process's
1839 /// pid. Panels are commonly unnamed; the frame's `name` is then `None` and
1840 /// the core falls back to the kind.
1841 ///
1842 /// `Panel` is the only kind Linux vends: the desktop has no menu bar,
1843 /// taskbar, or dock object of its own, and StatusNotifierItem *menus* live
1844 /// on `com.canonical.dbusmenu`, a different subsystem from AT-SPI. Their
1845 /// absence from the listing is honest scope, not failure.
1846 ///
1847 /// The scan runs over [`list_apps`](Self::list_apps) rather than its own
1848 /// registry walk, so it inherits that enumeration's empty-name filter: a
1849 /// panel that registers on the bus without a name is invisible here for
1850 /// the same reason it is invisible to `App::list`.
1851 ///
1852 /// # Errors
1853 ///
1854 /// A top-level that was just enumerated but will not answer `GetRole` or
1855 /// `GetAttributes` fails the listing (tenet 1). Those two calls are how a
1856 /// frame is classified, and `Panel` is the only kind Linux vends — so
1857 /// treating a D-Bus failure as "not a panel" turns one broken process into
1858 /// an empty listing that reads exactly like a desktop with no panels. The
1859 /// error names the app and object path that would not answer.
1860 ///
1861 /// An object that answers `GetAttributes` with `UnknownMethod` is the one
1862 /// exception, and it is absence rather than failure — see the call site.
1863 ///
1864 /// Enumerating one app's top-levels is the one step that still skips:
1865 /// [`get_atspi_children`](Self::get_atspi_children) failing is how a
1866 /// process that has left the bus presents, and `focused_app` already
1867 /// treats it that way.
1868 fn list_shell_surfaces(&self) -> Result<Vec<(ShellSurfaceKind, ElementData)>> {
1869 let mut surfaces = Vec::new();
1870 'apps: for app in self.list_apps()? {
1871 let app_ref = self.get_cached(app.handle)?;
1872 let top_levels = match self.get_atspi_children(&app_ref) {
1873 Ok(t) => t,
1874 // Same policy as `focused_app`: a single app failing to
1875 // enumerate its windows shouldn't abort the whole scan — skip
1876 // it and keep going.
1877 Err(_) => continue,
1878 };
1879 for top in top_levels
1880 .iter()
1881 .filter(|t| t.path != "/org/a11y/atspi/null")
1882 {
1883 // Both answer for a child the app has *just* enumerated, so a
1884 // D-Bus failure here is a failure, not absence — and "not a
1885 // panel" is the wrong reading of it (tenet 1). Say which
1886 // top-level of which app would not answer (tenet 6).
1887 //
1888 // `GetRole` rather than `GetRoleName` because every AT-SPI
1889 // object implements the numeric one, while AccessKit's bridge
1890 // (egui, and the other AccessKit-backed toolkits) implements
1891 // no `GetRoleName` at all — propagating that reply failed the
1892 // whole listing for a desktop running one such app.
1893 let Some(role_number) = self
1894 .scan_role_number(top)
1895 .map_err(|e| classify_failure(&app, top, e))?
1896 else {
1897 // The application exited between `list_apps()` and this
1898 // read. Its remaining top-levels are gone too, so move to
1899 // the next application rather than re-asking a bus name
1900 // that no longer has an owner.
1901 continue 'apps;
1902 };
1903 // The same absence/failure split on the second read: an object
1904 // that does not implement `GetAttributes` cannot advertise
1905 // `window-type`, which is an honest "not a panel" — the answer
1906 // is in the reply, not missing from it. (AccessKit does serve
1907 // this one; the bridges that skip it are the reason the split
1908 // is here rather than only on the role.) Every other D-Bus
1909 // outcome — ServiceUnknown, NoReply, a timeout, a dropped
1910 // connection — still fails the listing.
1911 let Some(attributes) = self
1912 .scan_attributes(top)
1913 .map_err(|e| classify_failure(&app, top, e))?
1914 else {
1915 continue 'apps;
1916 };
1917 let Some(attributes) = attributes else {
1918 continue;
1919 };
1920 if is_dock_frame(role_number, &attributes) {
1921 surfaces.push((
1922 ShellSurfaceKind::Panel,
1923 self.build_element_data(top, app.pid),
1924 ));
1925 }
1926 }
1927 }
1928 Ok(surfaces)
1929 }
1930
1931 /// A single process can register several AT-SPI Application entries
1932 /// (per-process instance plus per-event-loop pieces), so `App::windows`
1933 /// must merge their window children by pid. The same-pid merge and the
1934 /// stable identities it deduplicates by live in `xa11y-core`.
1935 fn splits_app_across_entries(&self) -> bool {
1936 true
1937 }
1938
1939 fn app_roots(&self, app: &ElementData) -> Result<Vec<ElementData>> {
1940 let Some(pid) = app.pid else {
1941 return Ok(vec![app.clone()]);
1942 };
1943 let mut roots: Vec<_> = self
1944 .list_apps()?
1945 .into_iter()
1946 .filter(|candidate| candidate.pid == Some(pid))
1947 .collect();
1948 if roots.is_empty() {
1949 roots.push(app.clone());
1950 }
1951 Ok(roots)
1952 }
1953
1954 /// Enumerate top-level applications by listing direct children of the
1955 /// AT-SPI registry root — every running accessibility-enabled app
1956 /// registers a child accessible there. Apps with empty names are
1957 /// filtered out (toolkits sometimes register transient/system
1958 /// accessibles before the real app appears). PID resolution uses the
1959 /// AT-SPI `Application` interface, falling back to the D-Bus owner.
1960 fn list_apps(&self) -> Result<Vec<ElementData>> {
1961 let registry = AccessibleRef {
1962 bus_name: "org.a11y.atspi.Registry".to_string(),
1963 path: "/org/a11y/atspi/accessible/root".to_string(),
1964 };
1965 let children = self.get_atspi_children(®istry)?;
1966
1967 // Filter valid children first, then build in parallel
1968 let valid: Vec<(&AccessibleRef, String)> = children
1969 .iter()
1970 .filter(|c| c.path != "/org/a11y/atspi/null")
1971 .filter_map(|c| {
1972 let name = self.get_name(c).unwrap_or_default();
1973 if name.is_empty() {
1974 None
1975 } else {
1976 Some((c, name))
1977 }
1978 })
1979 .collect();
1980
1981 let results: Vec<ElementData> = valid
1982 .par_iter()
1983 .map(|(child, app_name)| {
1984 let pid = self.get_app_pid(child);
1985 let mut data = self.build_element_data(child, pid);
1986 data.name = Some(app_name.clone());
1987 data
1988 })
1989 .collect();
1990
1991 Ok(results)
1992 }
1993
1994 /// Find an application by pid directly against the AT-SPI registry.
1995 ///
1996 /// Differs from filtering `list_apps()` in two ways that matter for a
1997 /// freshly launched process:
1998 ///
1999 /// * No empty-name filter — toolkits can register on the bus before the
2000 /// app name is set, and a pid match is precise enough on its own.
2001 /// * The not-matched error reports how many registry entries were
2002 /// examined and how many had no resolvable pid (the misreported-pid
2003 /// bridge quirk `get_app_pid` documents), so a timeout on CI says why
2004 /// the app was invisible instead of just "not found".
2005 fn app_by_pid(&self, pid: u32) -> Result<ElementData> {
2006 let registry = AccessibleRef {
2007 bus_name: "org.a11y.atspi.Registry".to_string(),
2008 path: "/org/a11y/atspi/accessible/root".to_string(),
2009 };
2010 let children = self.get_atspi_children(®istry)?;
2011
2012 let mut total = 0usize;
2013 let mut unresolved = 0usize;
2014 for child in children.iter().filter(|c| c.path != "/org/a11y/atspi/null") {
2015 total += 1;
2016 match self.get_app_pid(child) {
2017 Some(p) if p == pid => {
2018 let mut data = self.build_element_data(child, Some(p));
2019 // Mirror list_apps: prefer the registry-reported name.
2020 let name = self.get_name(child).unwrap_or_default();
2021 if !name.is_empty() {
2022 data.name = Some(name);
2023 }
2024 return Ok(data);
2025 }
2026 Some(_) => {}
2027 None => unresolved += 1,
2028 }
2029 }
2030 Err(
2031 Error::selector_not_matched(format!("application[pid={pid}]")).diagnose(
2032 xa11y_core::Diagnosis::new().last_observed(format!(
2033 "{total} AT-SPI registry entries examined, {unresolved} without a \
2034 resolvable pid"
2035 )),
2036 ),
2037 )
2038 }
2039
2040 /// Identify the foreground application via the active top-level window.
2041 ///
2042 /// AT-SPI has no registry-level "focused application" call, and the
2043 /// `Application` accessibles themselves don't carry an active/focused
2044 /// state across toolkits. The portable signal is the `ACTIVE` state
2045 /// (`AtspiStateType::ACTIVE`) set on the foreground *window* (frame): we
2046 /// enumerate each registered application's top-level children and return
2047 /// the first application that owns a window reporting `ACTIVE`.
2048 ///
2049 /// Returns [`Error::SelectorNotMatched`] when no window is active (no app
2050 /// has the foreground — e.g. focus is on the shell or a non-AT-SPI
2051 /// surface). The core reads that as "nothing focused", not a failure.
2052 fn focused_app(&self) -> Result<ElementData> {
2053 // AtspiStateType::ACTIVE = 1 → bit position 1.
2054 const ACTIVE: u64 = 1 << 1;
2055
2056 let registry = AccessibleRef {
2057 bus_name: "org.a11y.atspi.Registry".to_string(),
2058 path: "/org/a11y/atspi/accessible/root".to_string(),
2059 };
2060 let apps = self.get_atspi_children(®istry)?;
2061
2062 for app in apps.iter().filter(|c| c.path != "/org/a11y/atspi/null") {
2063 let windows = match self.get_atspi_children(app) {
2064 Ok(w) => w,
2065 // A single app failing to enumerate its windows shouldn't abort
2066 // the whole foreground probe — skip it and keep scanning.
2067 Err(_) => continue,
2068 };
2069 let has_active_window = windows
2070 .iter()
2071 .filter(|w| w.path != "/org/a11y/atspi/null")
2072 .any(|w| self.state_bits(w).0 & ACTIVE != 0);
2073 if has_active_window {
2074 let pid = self.get_app_pid(app);
2075 let mut data = self.build_element_data(app, pid);
2076 let name = self.get_name(app).unwrap_or_default();
2077 if !name.is_empty() {
2078 data.name = Some(name);
2079 }
2080 return Ok(data);
2081 }
2082 }
2083
2084 Err(Error::selector_not_matched("focused application"))
2085 }
2086
2087 fn press(&self, element: &ElementData) -> Result<()> {
2088 let target = self.get_cached(element.handle)?;
2089 // Fast path: the widget exposes `press` on its own Action interface.
2090 if let Ok(index) = self.get_action_index(element.handle, "press") {
2091 return self.do_atspi_action_by_index(&target, index);
2092 }
2093 // TENET-BREAK(1): substitute target. Human approval granted for this
2094 // break (see batch B6 / PR #125 / commit daeaf59). Justification: GTK4
2095 // menu-button widgets (GtkMenuButton, AdwMenuButton, AdwSplitButton)
2096 // expose their AT-SPI Action interface on an *inner* toggle-button
2097 // child, not the outer push-button accessible the app author addresses
2098 // by name. Without this workaround, `press()` silently does nothing on
2099 // every GtkMenuButton in every stock GNOME app (Calculator, Text
2100 // Editor, Logs, …). Alternatives considered and rejected:
2101 // 1. Expose the inner widget in the tree — breaks AT-SPI tree
2102 // fidelity and leaks a GTK implementation detail into every
2103 // consumer.
2104 // 2. Return ActionNotSupported — every GTK consumer would have to
2105 // reimplement the same subtree search to work around it.
2106 // The break is narrowly scoped: gated on (a) the owning toolkit being
2107 // GTK and (b) the outer role being Role::Button (push-button, which is
2108 // what all three menu-button variants present as). Non-GtkMenuButton
2109 // roles and non-GTK toolkits continue to fail-fast with
2110 // ActionNotSupported.
2111 if element.role == Role::Button && self.is_gtk_toolkit(&target) {
2112 let outer_name = self.get_name(&target).unwrap_or_default();
2113 if let Some((inner, index)) = self.find_gtk_press_fallback(&target, &outer_name) {
2114 return self.do_atspi_action_by_index(&inner, index);
2115 }
2116 }
2117 Err(Error::ActionNotSupported {
2118 action: "press".to_string(),
2119 role: element.role,
2120 })
2121 }
2122
2123 fn focus(&self, element: &ElementData) -> Result<()> {
2124 let target = self.get_cached(element.handle)?;
2125 // The only mechanism is Component.GrabFocus, which returns a boolean
2126 // indicating success — we must check it. Fixes GitHub issue #98.
2127 //
2128 // There is deliberately no stored-action-index fallback: one cannot
2129 // fire. `action_indices` is populated only for roles in
2130 // `role_has_actions` (Window/Dialog excluded) from names
2131 // `map_atspi_action_name` produces, and no mapping yields "focus" (see
2132 // `get_actions`'s note: "focus" is only ever reported when explicitly
2133 // in the AT-SPI Action interface). Keeping the dead lookup here would
2134 // pretend to a second mechanism that never succeeds — the same
2135 // decision `activate` documents above (tenet 1).
2136 let proxy = self
2137 .make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")
2138 .map_err(|e| Error::Platform {
2139 code: -1,
2140 message: format!("Component proxy while focusing failed: {}", e),
2141 })?;
2142 let reply = grab_focus_result(
2143 proxy.call_method("GrabFocus", &()),
2144 "focusing",
2145 "focus",
2146 element.role,
2147 )?;
2148 let grabbed: bool = reply.body().deserialize().map_err(|e| Error::Platform {
2149 code: -1,
2150 message: format!("Component.GrabFocus reply while focusing failed: {}", e),
2151 })?;
2152 if grabbed {
2153 return Ok(());
2154 }
2155 // GrabFocus(false) is an honest "cannot focus this element" — the
2156 // AT-SPI adapter declined, not a platform failure.
2157 Err(Error::ActionNotSupported {
2158 action: "focus".to_string(),
2159 role: element.role,
2160 })
2161 }
2162
2163 fn blur(&self, element: &ElementData) -> Result<()> {
2164 let target = self.get_cached(element.handle)?;
2165 // Grab focus on parent element to blur the current one. We propagate
2166 // every failure — no silent fallbacks (tenet 1): callers need to see
2167 // when blur can't do anything useful.
2168 if let Some(parent_ref) = self.get_atspi_parent(&target)? {
2169 if parent_ref.path != "/org/a11y/atspi/null" {
2170 let p = self.make_proxy(
2171 &parent_ref.bus_name,
2172 &parent_ref.path,
2173 "org.a11y.atspi.Component",
2174 )?;
2175 p.call_method("GrabFocus", &())
2176 .map_err(|e| Error::Platform {
2177 code: -1,
2178 message: format!("Component.GrabFocus on parent failed: {}", e),
2179 })?;
2180 return Ok(());
2181 }
2182 }
2183 Err(Error::ActionNotSupported {
2184 action: "blur".to_string(),
2185 role: element.role,
2186 })
2187 }
2188
2189 fn toggle(&self, element: &ElementData) -> Result<()> {
2190 let target = self.get_cached(element.handle)?;
2191 // Fast path: the widget exposes the literal AT-SPI action name "toggle"
2192 // (or one of its toolkit aliases — "check" / "uncheck" — both
2193 // canonicalised to "toggle" by map_atspi_action_name during cache
2194 // population).
2195 if let Ok(index) = self.get_action_index(element.handle, "toggle") {
2196 return self.do_atspi_action_by_index(&target, index);
2197 }
2198 // Fallback for AccessKit-backed apps. accesskit_unix's AT-SPI bridge
2199 // hard-codes the action name as "click" for every clickable node
2200 // regardless of role — see platforms/atspi-common/src/node.rs in
2201 // AccessKit. That means egui, eframe, winit-based apps, Bevy, Tauri
2202 // (when using AccessKit) and any other AccessKit consumer never
2203 // advertise "toggle" for CheckBox/RadioButton/Switch, even though
2204 // activating those widgets via "click" is functionally a toggle on
2205 // every platform. Without this fallback, xa11y.toggle() is
2206 // unreachable on every AccessKit-on-Linux widget.
2207 //
2208 // This is tenet-3-consistent: the semantic verb `toggle` on a
2209 // toggleable role means "activate this toggleable element," and on
2210 // Linux the canonical AccessKit implementation of that is the same
2211 // action node entry we'd dispatch for press(). It's the mirror image
2212 // of the Windows `press` dispatch (uia.rs) which fans out to Invoke,
2213 // Toggle, SelectionItem.Select, or ExpandCollapse depending on the
2214 // element's primary-activation pattern.
2215 //
2216 // Narrowly scoped: only fires for toggleable primary-activation
2217 // roles. Other roles still fail-fast with ActionNotSupported so a
2218 // misuse of toggle() on, e.g., a Button does not silently dispatch
2219 // press().
2220 if matches!(
2221 element.role,
2222 Role::CheckBox | Role::RadioButton | Role::Switch
2223 ) {
2224 if let Ok(index) = self.get_action_index(element.handle, "press") {
2225 return self.do_atspi_action_by_index(&target, index);
2226 }
2227 }
2228 Err(Error::ActionNotSupported {
2229 action: "toggle".to_string(),
2230 role: element.role,
2231 })
2232 }
2233
2234 fn select(&self, element: &ElementData) -> Result<()> {
2235 let target = self.get_cached(element.handle)?;
2236 let index = self
2237 .get_action_index(element.handle, "select")
2238 .map_err(|_| Error::ActionNotSupported {
2239 action: "select".to_string(),
2240 role: element.role,
2241 })?;
2242 self.do_atspi_action_by_index(&target, index)
2243 }
2244
2245 fn expand(&self, element: &ElementData) -> Result<()> {
2246 let target = self.get_cached(element.handle)?;
2247 let index = self
2248 .get_action_index(element.handle, "expand")
2249 .map_err(|_| Error::ActionNotSupported {
2250 action: "expand".to_string(),
2251 role: element.role,
2252 })?;
2253 self.do_atspi_action_by_index(&target, index)
2254 }
2255
2256 fn collapse(&self, element: &ElementData) -> Result<()> {
2257 let target = self.get_cached(element.handle)?;
2258 let index = self
2259 .get_action_index(element.handle, "collapse")
2260 .map_err(|_| Error::ActionNotSupported {
2261 action: "collapse".to_string(),
2262 role: element.role,
2263 })?;
2264 self.do_atspi_action_by_index(&target, index)
2265 }
2266
2267 fn show_menu(&self, element: &ElementData) -> Result<()> {
2268 let target = self.get_cached(element.handle)?;
2269 let index = self
2270 .get_action_index(element.handle, "show_menu")
2271 .map_err(|_| Error::ActionNotSupported {
2272 action: "show_menu".to_string(),
2273 role: element.role,
2274 })?;
2275 self.do_atspi_action_by_index(&target, index)
2276 }
2277
2278 fn increment(&self, element: &ElementData) -> Result<()> {
2279 let target = self.get_cached(element.handle)?;
2280 // Try stored AT-SPI2 action index first, fall back to Value interface
2281 if let Ok(index) = self.get_action_index(element.handle, "increment") {
2282 return self.do_atspi_action_by_index(&target, index);
2283 }
2284 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
2285 let current: f64 = proxy
2286 .get_property("CurrentValue")
2287 .map_err(|e| Error::Platform {
2288 code: -1,
2289 message: format!("Value.CurrentValue failed: {}", e),
2290 })?;
2291 let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
2292 let step = if step <= 0.0 { 1.0 } else { step };
2293 proxy
2294 .set_property("CurrentValue", current + step)
2295 .map_err(|e| Error::Platform {
2296 code: -1,
2297 message: format!("Value.SetCurrentValue failed: {}", e),
2298 })
2299 }
2300
2301 fn decrement(&self, element: &ElementData) -> Result<()> {
2302 let target = self.get_cached(element.handle)?;
2303 if let Ok(index) = self.get_action_index(element.handle, "decrement") {
2304 return self.do_atspi_action_by_index(&target, index);
2305 }
2306 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
2307 let current: f64 = proxy
2308 .get_property("CurrentValue")
2309 .map_err(|e| Error::Platform {
2310 code: -1,
2311 message: format!("Value.CurrentValue failed: {}", e),
2312 })?;
2313 let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
2314 let step = if step <= 0.0 { 1.0 } else { step };
2315 proxy
2316 .set_property("CurrentValue", current - step)
2317 .map_err(|e| Error::Platform {
2318 code: -1,
2319 message: format!("Value.SetCurrentValue failed: {}", e),
2320 })
2321 }
2322
2323 fn scroll_into_view(&self, element: &ElementData) -> Result<()> {
2324 let target = self.get_cached(element.handle)?;
2325 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
2326 proxy
2327 .call_method("ScrollTo", &(0u32,))
2328 .map_err(|e| Error::Platform {
2329 code: -1,
2330 message: format!("ScrollTo failed: {}", e),
2331 })?;
2332 Ok(())
2333 }
2334
2335 // ── Window management ──────────────────────────────────────────
2336 //
2337 // Native X11/Sway window-manager requests handle the verbs they advertise.
2338 // On Wayland compositors without a native provider, AT-SPI still exposes
2339 // `activate` (Component.GrabFocus on the frame) and two geometry setters:
2340 // SetPosition and SetSize. There is no simulated-shortcut path.
2341 //
2342 // Window discovery is `App::windows` — `get_children(app)` filtered to
2343 // Window|Dialog — as on every platform.
2344
2345 fn activate(&self, element: &ElementData) -> Result<()> {
2346 // The AT-SPI implementation of "activate this window" is the same
2347 // Component.GrabFocus path `focus` uses: the frame that receives
2348 // focus becomes the active window. No separate activate API exists.
2349 //
2350 // Implemented here rather than delegating to `focus` so a failure
2351 // reports the verb the caller requested — `activate` — not `focus`'s
2352 // name (the two share a mechanism, not an identity).
2353 //
2354 // A GrabFocus(false) reply is an honest "cannot activate this window",
2355 // reported as ActionNotSupported: the action-index fallback that
2356 // `focus` carries cannot fire for window-like roles, because
2357 // `action_indices` is populated only for roles in `role_has_actions`
2358 // (Window/Dialog excluded) and `map_atspi_action_name` maps no
2359 // "focus" action name. Unlike Windows/macOS, `activate` is deliberately
2360 // NOT advertised on AT-SPI — the adapters disagree about a frame
2361 // GrabFocus and no interface probe discriminates them (see the
2362 // reasons in `build_element_data`); advertised or not, the call here
2363 // and the `focus` call use the same mechanism, which is why the
2364 // deleted `focus` lookup would only ever pretend to a second path
2365 // (tenet 1). If GTK4's GrabFocus(false) is ever reproduced with an
2366 // actual focus-named Action interface entry (issue #98), the right
2367 // fix is a direct Action probe on this failure path, not a cache
2368 // lookup the role/name mapping rules can never fill.
2369 self.ensure_top_level_window_target(element, "activate")?;
2370 if self.window_manager.uses_native() {
2371 return self.window_manager.activate(element);
2372 }
2373 let target = self.get_cached(element.handle)?;
2374 let proxy = self
2375 .make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")
2376 .map_err(|e| Error::Platform {
2377 code: -1,
2378 message: format!("Component proxy while activating failed: {}", e),
2379 })?;
2380 let reply = grab_focus_result(
2381 proxy.call_method("GrabFocus", &()),
2382 "activating",
2383 "activate",
2384 element.role,
2385 )?;
2386 let grabbed: bool = reply.body().deserialize().map_err(|e| Error::Platform {
2387 code: -1,
2388 message: format!("Component.GrabFocus reply while activating failed: {}", e),
2389 })?;
2390 if grabbed {
2391 return Ok(());
2392 }
2393 Err(Error::ActionNotSupported {
2394 action: "activate".to_string(),
2395 role: element.role,
2396 })
2397 }
2398
2399 fn minimize(&self, element: &ElementData) -> Result<()> {
2400 self.ensure_top_level_window_target(element, "minimize")?;
2401 self.window_manager.minimize(element)
2402 }
2403
2404 fn maximize(&self, element: &ElementData) -> Result<()> {
2405 self.ensure_top_level_window_target(element, "maximize")?;
2406 self.window_manager.maximize(element)
2407 }
2408
2409 fn enter_fullscreen(&self, element: &ElementData) -> Result<()> {
2410 self.ensure_top_level_window_target(element, "enter_fullscreen")?;
2411 self.window_manager.enter_fullscreen(element)
2412 }
2413
2414 fn restore(&self, element: &ElementData) -> Result<()> {
2415 self.ensure_top_level_window_target(element, "restore")?;
2416 self.window_manager.restore(element)
2417 }
2418
2419 fn close(&self, element: &ElementData) -> Result<()> {
2420 self.ensure_top_level_window_target(element, "close")?;
2421 self.window_manager.close(element)
2422 }
2423
2424 fn move_to(&self, element: &ElementData, x: i32, y: i32) -> Result<()> {
2425 self.ensure_top_level_window_target(element, "move_to")?;
2426 if self.window_manager.uses_native() {
2427 return self.window_manager.move_to(element, x, y);
2428 }
2429 let target = self.get_cached(element.handle)?;
2430 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
2431 let scale = crate::scale::coordinate_scale();
2432 let point = xa11y_core::Point::new(x, y).to_physical(scale);
2433 // coord_type 0 is screen coordinates, matching GetExtents above.
2434 let reply = proxy
2435 .call_method("SetPosition", &(point.x, point.y, 0u32))
2436 .map_err(|e| {
2437 if is_absent_member(&e) {
2438 Error::ActionNotSupported {
2439 action: "move_to".to_string(),
2440 role: element.role,
2441 }
2442 } else {
2443 Error::Platform {
2444 code: -1,
2445 message: format!("Component.SetPosition while moving window failed: {e}"),
2446 }
2447 }
2448 })?;
2449 let moved: bool = reply.body().deserialize().map_err(|e| Error::Platform {
2450 code: -1,
2451 message: format!("Component.SetPosition reply while moving window failed: {e}"),
2452 })?;
2453 if moved {
2454 Ok(())
2455 } else {
2456 Err(Error::ActionNotSupported {
2457 action: "move_to".to_string(),
2458 role: element.role,
2459 })
2460 }
2461 }
2462
2463 fn resize_to(&self, element: &ElementData, w: u32, h: u32) -> Result<()> {
2464 self.ensure_top_level_window_target(element, "resize_to")?;
2465 if self.window_manager.uses_native() {
2466 return self.window_manager.resize_to(element, w, h);
2467 }
2468 let target = self.get_cached(element.handle)?;
2469 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
2470 let physical = Rect {
2471 x: 0,
2472 y: 0,
2473 width: w,
2474 height: h,
2475 }
2476 .to_physical(crate::scale::coordinate_scale());
2477 let width = i32::try_from(physical.width).map_err(|_| Error::InvalidActionData {
2478 message: format!("resize width {w} is too large for AT-SPI"),
2479 })?;
2480 let height = i32::try_from(physical.height).map_err(|_| Error::InvalidActionData {
2481 message: format!("resize height {h} is too large for AT-SPI"),
2482 })?;
2483 let reply = proxy
2484 .call_method("SetSize", &(width, height))
2485 .map_err(|e| {
2486 if is_absent_member(&e) {
2487 Error::ActionNotSupported {
2488 action: "resize_to".to_string(),
2489 role: element.role,
2490 }
2491 } else {
2492 Error::Platform {
2493 code: -1,
2494 message: format!("Component.SetSize while resizing window failed: {e}"),
2495 }
2496 }
2497 })?;
2498 let resized: bool = reply.body().deserialize().map_err(|e| Error::Platform {
2499 code: -1,
2500 message: format!("Component.SetSize reply while resizing window failed: {e}"),
2501 })?;
2502 if resized {
2503 Ok(())
2504 } else {
2505 Err(Error::ActionNotSupported {
2506 action: "resize_to".to_string(),
2507 role: element.role,
2508 })
2509 }
2510 }
2511
2512 fn set_value(&self, element: &ElementData, value: &str) -> Result<()> {
2513 let target = self.get_cached(element.handle)?;
2514 let proxy = self
2515 .make_proxy(
2516 &target.bus_name,
2517 &target.path,
2518 "org.a11y.atspi.EditableText",
2519 )
2520 .map_err(|_| Error::TextValueNotSupported)?;
2521 // Try SetTextContents first (WebKit2GTK exposes this but not InsertText).
2522 if proxy.call_method("SetTextContents", &(value,)).is_ok() {
2523 return Ok(());
2524 }
2525 // Fall back to delete-then-insert for other AT-SPI2 implementations.
2526 // Capture the underlying D-Bus error so callers can distinguish an
2527 // absent `EditableText` interface (common on Chromium — the Chrome
2528 // URL bar only exposes read-only `Text`; see issue #101) from other
2529 // failures. Collapsing to `TextValueNotSupported` hides the reason.
2530 let classify_editable_text_error = |op: &str, e: zbus::Error| -> Error {
2531 let msg = e.to_string();
2532 if msg.contains("UnknownMethod") || msg.contains("UnknownInterface") {
2533 Error::TextValueNotSupported
2534 } else {
2535 Error::Platform {
2536 code: -1,
2537 message: format!("EditableText.{} failed: {}", op, msg),
2538 }
2539 }
2540 };
2541 if let Err(e) = proxy.call_method("DeleteText", &(0i32, i32::MAX)) {
2542 return Err(classify_editable_text_error("DeleteText", e));
2543 }
2544 if let Err(e) = proxy.call_method("InsertText", &(0i32, value, value.len() as i32)) {
2545 return Err(classify_editable_text_error("InsertText", e));
2546 }
2547 Ok(())
2548 }
2549
2550 fn set_numeric_value(&self, element: &ElementData, value: f64) -> Result<()> {
2551 let target = self.get_cached(element.handle)?;
2552 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
2553 proxy
2554 .set_property("CurrentValue", value)
2555 .map_err(|e| Error::Platform {
2556 code: -1,
2557 message: format!("SetValue failed: {}", e),
2558 })
2559 }
2560
2561 fn type_text(&self, element: &ElementData, text: &str) -> Result<()> {
2562 let target = self.get_cached(element.handle)?;
2563 // Insert text via EditableText interface (accessibility API, not input simulation).
2564 // Get cursor position from Text interface, then insert at that position.
2565 let text_proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text");
2566 let insert_pos = text_proxy
2567 .as_ref()
2568 .ok()
2569 .and_then(|p| p.get_property::<i32>("CaretOffset").ok())
2570 .unwrap_or(-1); // -1 = append at end
2571
2572 let proxy = self
2573 .make_proxy(
2574 &target.bus_name,
2575 &target.path,
2576 "org.a11y.atspi.EditableText",
2577 )
2578 .map_err(|_| Error::TextValueNotSupported)?;
2579 let pos = if insert_pos >= 0 {
2580 insert_pos
2581 } else {
2582 i32::MAX
2583 };
2584 proxy
2585 .call_method("InsertText", &(pos, text, text.len() as i32))
2586 .map_err(|e| Error::Platform {
2587 code: -1,
2588 message: format!("EditableText.InsertText failed: {}", e),
2589 })?;
2590 Ok(())
2591 }
2592
2593 fn set_text_selection(&self, element: &ElementData, start: u32, end: u32) -> Result<()> {
2594 let target = self.get_cached(element.handle)?;
2595 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text")?;
2596 proxy
2597 .call_method("SetSelection", &(0i32, start as i32, end as i32))
2598 .map_err(|e| Error::Platform {
2599 code: -1,
2600 message: format!("Text.SetSelection failed: {}", e),
2601 })?;
2602 Ok(())
2603 }
2604
2605 fn perform_action(&self, element: &ElementData, action: &str) -> Result<()> {
2606 match action {
2607 "press" => self.press(element),
2608 "focus" => self.focus(element),
2609 "blur" => self.blur(element),
2610 "toggle" => self.toggle(element),
2611 "select" => self.select(element),
2612 "expand" => self.expand(element),
2613 "collapse" => self.collapse(element),
2614 "show_menu" => self.show_menu(element),
2615 "increment" => self.increment(element),
2616 "decrement" => self.decrement(element),
2617 "scroll_into_view" => self.scroll_into_view(element),
2618 "activate" => self.activate(element),
2619 "minimize" => self.minimize(element),
2620 "maximize" => self.maximize(element),
2621 "enter_fullscreen" => self.enter_fullscreen(element),
2622 "restore" => self.restore(element),
2623 "close" => self.close(element),
2624 // Payload verbs have no arguments on the generic escape hatch;
2625 // fail surfaceably with how to call them instead of guessing
2626 // (tenet 1: no silent fallback).
2627 "move_to" => Err(Error::InvalidActionData {
2628 message: "perform_action(\"move_to\") requires coordinates; call move_to(x, y)"
2629 .to_string(),
2630 }),
2631 "resize_to" => Err(Error::InvalidActionData {
2632 message: "perform_action(\"resize_to\") requires dimensions; call \
2633 resize_to(width, height)"
2634 .to_string(),
2635 }),
2636 _ => Err(Error::ActionNotSupported {
2637 action: action.to_string(),
2638 role: element.role,
2639 }),
2640 }
2641 }
2642
2643 fn subscribe(&self, element: &ElementData) -> Result<Subscription> {
2644 let pid = element.pid.ok_or(Error::Platform {
2645 code: -1,
2646 message: "Element has no PID for subscribe".to_string(),
2647 })?;
2648 let app_name = element.name.clone().unwrap_or_default();
2649 crate::events::subscribe_for_pid(self, pid, app_name)
2650 }
2651}
2652
2653/// Whether a role typically has text or Value interface content.
2654/// Container/structural roles are skipped to save D-Bus round-trips.
2655fn role_has_value(role: Role) -> bool {
2656 !matches!(
2657 role,
2658 Role::Application
2659 | Role::Window
2660 | Role::Dialog
2661 | Role::Group
2662 | Role::MenuBar
2663 | Role::Toolbar
2664 | Role::TabGroup
2665 | Role::SplitGroup
2666 | Role::Table
2667 | Role::TableRow
2668 | Role::Separator
2669 )
2670}
2671
2672/// Whether a role typically supports actions via the Action interface.
2673/// Container and display-only roles are skipped to save D-Bus round-trips.
2674///
2675/// Window and Dialog are deliberately excluded: their semantic verbs do not
2676/// come from the Action interface. `activate` (the one Linux supports) is also
2677/// deliberately NOT advertised — see the note in `build_element_data` on why
2678/// (the AT-SPI adapters disagree about implementing a frame GrabFocus, so
2679/// advertising it would promise an action the call then declines); it stays
2680/// callable, and the state-mutating verbs are `Unsupported` on AT-SPI — so
2681/// admitting them here would only earn a pointless GetActions probe
2682/// returning no usable entries.
2683fn role_has_actions(role: Role) -> bool {
2684 matches!(
2685 role,
2686 Role::Button
2687 | Role::CheckBox
2688 | Role::RadioButton
2689 | Role::MenuItem
2690 | Role::Link
2691 | Role::ComboBox
2692 | Role::TextField
2693 | Role::TextArea
2694 | Role::SpinButton
2695 | Role::Tab
2696 | Role::TreeItem
2697 | Role::ListItem
2698 | Role::ScrollBar
2699 | Role::Slider
2700 | Role::Menu
2701 | Role::Image
2702 | Role::Unknown
2703 )
2704}
2705
2706/// Classify a `Component.GrabFocus` call result, used by
2707/// [`focus`](LinuxProvider::focus) and [`activate`](LinuxProvider::activate).
2708///
2709/// The two verbs share the AT-SPI mechanism (there is no separate activate API),
2710/// and an adapter signals "this element cannot take focus" in one of two
2711/// honest shapes: a `false` reply (checked by both callers) or the D-Bus
2712/// `NotSupported` error, which is what the GTK4 adapter raises for
2713/// non-focusable controls instead of answering false (issue #98). Both mean
2714/// the adapter declined — `ActionNotSupported`, the documented contract that
2715/// the python/JS suites treat as xfail — and every other failure stays a
2716/// `Platform` error (tenet 1: no silent swallowing of unexpected failures).
2717fn grab_focus_result(
2718 call: std::result::Result<zbus::Message, zbus::Error>,
2719 verb: &str,
2720 action: &str,
2721 role: Role,
2722) -> Result<zbus::Message> {
2723 call.map_err(|e| match e {
2724 zbus::Error::MethodError(name, _, _) => {
2725 grab_focus_error(name.as_str().to_string(), verb, action, role)
2726 }
2727 other => Error::Platform {
2728 code: -1,
2729 message: format!("Component.GrabFocus while {verb} failed: {other}"),
2730 },
2731 })
2732}
2733
2734/// Pure classification half of `grab_focus_result`, unit-testable without
2735/// constructing zbus internals.
2736fn grab_focus_error(name: String, verb: &str, action: &str, role: Role) -> Error {
2737 if name == "org.freedesktop.DBus.Error.NotSupported" {
2738 Error::ActionNotSupported {
2739 action: action.to_string(),
2740 role,
2741 }
2742 } else {
2743 Error::Platform {
2744 code: -1,
2745 message: format!("Component.GrabFocus while {verb} failed: {name}"),
2746 }
2747 }
2748}
2749
2750/// Map AT-SPI2 role name to xa11y Role.
2751pub(crate) fn map_atspi_role(role_name: &str) -> Role {
2752 match role_name.to_lowercase().as_str() {
2753 "application" => Role::Application,
2754 "window" | "frame" => Role::Window,
2755 "dialog" | "file chooser" => Role::Dialog,
2756 "alert" | "notification" | "info bar" => Role::Alert,
2757 "push button" | "push button menu" => Role::Button,
2758 "toggle button" => Role::Switch,
2759 "check box" | "check menu item" => Role::CheckBox,
2760 "radio button" | "radio menu item" => Role::RadioButton,
2761 "entry" | "password text" => Role::TextField,
2762 "spin button" | "spinbutton" => Role::SpinButton,
2763 // "textbox" is the ARIA role name returned by WebKit2GTK for both
2764 // <input type="text"> and <textarea>. Map to TextArea here so the
2765 // multi-line refinement below can downgrade single-line ones to TextField.
2766 // "embedded" is WebKit2GTK's numeric-path equivalent (role 78);
2767 // "terminal" is VTE — text is reachable via the Text interface.
2768 "text" | "textbox" | "embedded" | "terminal" => Role::TextArea,
2769 "label" | "static" | "caption" => Role::StaticText,
2770 "combo box" | "combobox" => Role::ComboBox,
2771 // "listbox" is the ARIA role name returned by WebKit2GTK for role="listbox".
2772 "list" | "list box" | "listbox" => Role::List,
2773 "list item" => Role::ListItem,
2774 "menu" | "popup menu" => Role::Menu,
2775 "menu item" | "tearoff menu item" => Role::MenuItem,
2776 "menu bar" => Role::MenuBar,
2777 "page tab" => Role::Tab,
2778 "page tab list" => Role::TabGroup,
2779 "table" | "tree table" => Role::Table,
2780 "table row" => Role::TableRow,
2781 // Bare "column header"/"row header" are WebKit's <th> and GTK
2782 // column-view headers; the "table …" pair is the classic toolkit one.
2783 "table cell"
2784 | "table column header"
2785 | "table row header"
2786 | "column header"
2787 | "row header" => Role::TableCell,
2788 "tool bar" => Role::Toolbar,
2789 "scroll bar" => Role::ScrollBar,
2790 "slider" => Role::Slider,
2791 "image" | "icon" | "desktop icon" => Role::Image,
2792 "link" => Role::Link,
2793 "panel" | "section" | "form" | "filler" | "viewport" | "scroll pane" | "paragraph"
2794 | "header" | "footer" | "grouping" | "block quote" | "redundant object" | "tree"
2795 | "comment" | "title bar" | "calendar" => Role::Group,
2796 "progress bar" | "level bar" => Role::ProgressBar,
2797 "tree item" => Role::TreeItem,
2798 "document web"
2799 | "document frame"
2800 | "document spreadsheet"
2801 | "document presentation"
2802 | "document text"
2803 | "document email" => Role::WebArea,
2804 "heading" => Role::Heading,
2805 "separator" => Role::Separator,
2806 "split pane" => Role::SplitGroup,
2807 "tooltip" | "tool tip" => Role::Tooltip,
2808 "status bar" | "statusbar" => Role::Status,
2809 "landmark" | "navigation" => Role::Navigation,
2810 _ => xa11y_core::unknown_role(role_name),
2811 }
2812}
2813
2814/// `AtspiRole::Frame` — the numeric role a top-level window frame reports.
2815///
2816/// Named because `is_dock_frame` needs the number itself: the table below
2817/// folds `Frame` and `Window` (69) into the same `Role::Window`, and a panel
2818/// registers only as the former. `test_frame_role_number_is_the_atspi_frame_role`
2819/// pins it against the AtspiRole enum the tests carry.
2820const ATSPI_ROLE_FRAME: u32 = 23;
2821
2822/// Map AT-SPI2 numeric role (AtspiRole enum) to xa11y Role.
2823/// Values from atspi-common Role enum (repr(u32)).
2824pub(crate) fn map_atspi_role_number(role: u32) -> Role {
2825 match role {
2826 2 => Role::Alert, // Alert
2827 5 => Role::Group, // Calendar (mirrors UIA CalendarControlType)
2828 7 => Role::CheckBox, // CheckBox
2829 8 => Role::CheckBox, // CheckMenuItem
2830 // Bare ColumnHeader/RowHeader (10/47): WebKit and GTK emit these for
2831 // <th> / column-view header cells, distinct from the
2832 // TableColumnHeader/TableRowHeader pair (57/58) below.
2833 10 => Role::TableCell, // ColumnHeader
2834 11 => Role::ComboBox, // ComboBox
2835 13 => Role::Image, // DesktopIcon
2836 16 => Role::Dialog, // Dialog
2837 19 => Role::Dialog, // FileChooser
2838 20 => Role::Group, // Filler
2839 23 => Role::Window, // Frame
2840 26 => Role::Image, // Icon
2841 27 => Role::Image, // Image
2842 29 => Role::StaticText, // Label
2843 31 => Role::List, // List
2844 32 => Role::ListItem, // ListItem
2845 33 => Role::Menu, // Menu
2846 34 => Role::MenuBar, // MenuBar
2847 35 => Role::MenuItem, // MenuItem
2848 37 => Role::Tab, // PageTab
2849 38 => Role::TabGroup, // PageTabList
2850 39 => Role::Group, // Panel
2851 40 => Role::TextField, // PasswordText
2852 41 => Role::Menu, // PopupMenu
2853 42 => Role::ProgressBar, // ProgressBar
2854 43 => Role::Button, // Button (push button)
2855 44 => Role::RadioButton, // RadioButton
2856 45 => Role::RadioButton, // RadioMenuItem
2857 47 => Role::TableCell, // RowHeader
2858 48 => Role::ScrollBar, // ScrollBar
2859 49 => Role::Group, // ScrollPane
2860 50 => Role::Separator, // Separator
2861 51 => Role::Slider, // Slider
2862 52 => Role::SpinButton, // SpinButton
2863 53 => Role::SplitGroup, // SplitPane
2864 54 => Role::Status, // StatusBar
2865 55 => Role::Table, // Table
2866 56 => Role::TableCell, // TableCell
2867 57 => Role::TableCell, // TableColumnHeader
2868 58 => Role::TableCell, // TableRowHeader
2869 59 => Role::MenuItem, // TearoffMenuItem
2870 60 => Role::TextArea, // Terminal (VTE and friends — text via the Text interface)
2871 61 => Role::TextArea, // Text
2872 62 => Role::Switch, // ToggleButton
2873 63 => Role::Toolbar, // ToolBar
2874 64 => Role::Tooltip, // ToolTip
2875 65 => Role::Group, // Tree
2876 66 => Role::Table, // TreeTable
2877 67 => Role::Unknown, // Unknown
2878 68 => Role::Group, // Viewport
2879 69 => Role::Window, // Window
2880 // Text-document containers WebKit emits for plain markup: a bare
2881 // <p> (no ARIA naming) is Paragraph; header/footer/caption/block
2882 // quote come from the matching HTML elements. Containers normalize
2883 // to Group, caption to StaticText, mirroring the by-name map.
2884 71 => Role::Group, // Header
2885 72 => Role::Group, // Footer
2886 73 => Role::Group, // Paragraph
2887 75 => Role::Application, // Application
2888 78 => Role::TextArea, // Embedded — WebKit2GTK uses this for <input type="text"> and <textarea>;
2889 // multi-line refinement below downgrades single-line ones to TextField
2890 79 => Role::TextField, // Entry
2891 81 => Role::StaticText, // Caption
2892 82 => Role::WebArea, // DocumentFrame
2893 83 => Role::Heading, // Heading
2894 85 => Role::Group, // Section
2895 86 => Role::Group, // RedundantObject
2896 87 => Role::Group, // Form
2897 88 => Role::Link, // Link
2898 90 => Role::TableRow, // TableRow
2899 91 => Role::TreeItem, // TreeItem
2900 // Document roots (92-96): LibreOffice exposes spreadsheet /
2901 // presentation / text / email documents with these; all normalize to
2902 // WebArea alongside DocumentFrame/DocumentWeb above.
2903 92 => Role::WebArea, // DocumentSpreadsheet
2904 93 => Role::WebArea, // DocumentPresentation
2905 94 => Role::WebArea, // DocumentText
2906 95 => Role::WebArea, // DocumentWeb
2907 96 => Role::WebArea, // DocumentEmail
2908 97 => Role::Group, // Comment (ARIA comment container)
2909 98 => Role::List, // ListBox — WebKit2GTK emits this for <ul role="listbox">
2910 99 => Role::Group, // Grouping
2911 101 => Role::Alert, // Notification
2912 102 => Role::Alert, // InfoBar (GtkInfoBar message strip)
2913 103 => Role::ProgressBar, // LevelBar (GtkLevelBar)
2914 104 => Role::Group, // TitleBar (mirrors UIA TitleBar)
2915 105 => Role::Group, // BlockQuote
2916 110 => Role::Navigation, // Landmark
2917 116 => Role::StaticText, // Static
2918 129 => Role::Button, // PushButtonMenu
2919 _ => xa11y_core::unknown_role(&format!("AT-SPI role number {role}")),
2920 }
2921}
2922
2923/// Depth cap for the GTK press-fallback BFS. Wrapper patterns nest at most
2924/// two levels (e.g. `AdwSplitButton` → inner toggle-button → inner label);
2925/// depth 3 covers them with headroom without letting the walk wander.
2926const GTK_FALLBACK_MAX_DEPTH: u32 = 3;
2927
2928/// Hard cap on visited accessibles per fallback resolution. Defensive — the
2929/// depth cap already bounds typical cases to ≤ 20 nodes.
2930const GTK_FALLBACK_MAX_NODES: usize = 200;
2931
2932/// Whether an AT-SPI2 role name represents an activatable widget we are
2933/// willing to invoke via the fallback path. Deliberately narrow: roles that
2934/// could carry destructive or misleading semantics (e.g. `label` with the
2935/// synthesised clipboard/selection actions) are excluded.
2936fn is_actionable_atspi_role(role: &str) -> bool {
2937 matches!(
2938 role,
2939 "push button"
2940 | "toggle button"
2941 | "check box"
2942 | "radio button"
2943 | "menu item"
2944 | "check menu item"
2945 | "radio menu item"
2946 | "link"
2947 | "page tab"
2948 | "tab"
2949 | "list item"
2950 | "tree item"
2951 )
2952}
2953
2954/// Roles the BFS refuses to descend into. Static and decorative roles never
2955/// lead anywhere useful, and `label` in particular carries a fan-out of
2956/// text-editing actions (`clipboard.copy`, `selection.delete`, …) that we
2957/// must never reach via a press heuristic.
2958fn is_never_descend_atspi_role(role: &str) -> bool {
2959 matches!(
2960 role,
2961 "label" | "separator" | "image" | "icon" | "static" | "caption"
2962 )
2963}
2964
2965/// Whether a top-level accessible is a desktop panel or dock: an AT-SPI2
2966/// `frame` (numeric role [`ATSPI_ROLE_FRAME`]) whose attribute set carries
2967/// `window-type:dock`.
2968///
2969/// The role arrives as the number `GetRole` reports rather than the
2970/// `GetRoleName` string: the numeric method is the one every AT-SPI object
2971/// implements. `map_atspi_role_number` is no help here — `Frame` (23) and
2972/// `Window` (69) both map to `Role::Window`, and only the former is what a
2973/// panel registers as.
2974///
2975/// `window-type` mirrors the window manager's `_NET_WM_WINDOW_TYPE` hint, so
2976/// it is set by the panel itself rather than inferred — an ordinary
2977/// application window carries `normal`, or no `window-type` at all.
2978fn is_dock_frame(role_number: u32, attributes: &HashMap<String, String>) -> bool {
2979 role_number == ATSPI_ROLE_FRAME && attributes.get("window-type").is_some_and(|t| t == "dock")
2980}
2981
2982/// D-Bus error names that mean the member was never there, as opposed to the
2983/// call having failed.
2984///
2985/// A reply of `UnknownMethod` / `UnknownInterface` / `UnknownObject` is the
2986/// bus answering the question: this object does not implement that. AT-SPI
2987/// bridges implement the interface partially — AccessKit's serves `GetRole`
2988/// and `GetLocalizedRoleName` but no `GetRoleName` at all — so a caller that
2989/// reads absence as failure fails on a perfectly healthy desktop.
2990const ABSENT_MEMBER_ERRORS: [&str; 3] = [
2991 "org.freedesktop.DBus.Error.UnknownMethod",
2992 "org.freedesktop.DBus.Error.UnknownInterface",
2993 "org.freedesktop.DBus.Error.UnknownObject",
2994];
2995
2996/// D-Bus error names that mean the *peer* is gone, as opposed to the call
2997/// having failed.
2998///
2999/// The shell scan reads every top-level of every application on the bus, and an
3000/// application that quits between `list_apps()` and those reads answers with
3001/// one of these. That is genuine absence — the process and its surfaces are
3002/// gone — not a failure to hide (tenet 1): there is nothing left to report and
3003/// nothing a caller could do about it. A hung or broken application is a
3004/// different outcome and stays a failure: it answers `NoReply` or `Timeout`, or
3005/// does not answer at all.
3006///
3007/// Kept separate from [`ABSENT_MEMBER_ERRORS`] because the two are different
3008/// axes — "this object does not implement that member" versus "this bus name
3009/// has no owner any more".
3010const PEER_GONE_ERRORS: [&str; 2] = [
3011 "org.freedesktop.DBus.Error.ServiceUnknown",
3012 "org.freedesktop.DBus.Error.NameHasNoOwner",
3013];
3014
3015/// Whether a failed D-Bus call means the peer left the bus (see
3016/// [`PEER_GONE_ERRORS`]).
3017///
3018/// Matches the structured error name for the same reason
3019/// [`is_absent_member`] does, never a substring of a formatted message.
3020fn is_peer_gone(err: &zbus::Error) -> bool {
3021 match err {
3022 zbus::Error::MethodError(name, ..) => PEER_GONE_ERRORS.contains(&name.as_str()),
3023 _ => false,
3024 }
3025}
3026
3027/// Whether a failed D-Bus call means the member is absent (see
3028/// [`ABSENT_MEMBER_ERRORS`]) rather than that the call failed.
3029///
3030/// `Proxy::call_method` surfaces an error *reply* as
3031/// `zbus::Error::MethodError`, which keeps the error name structured — so
3032/// this matches the name itself, never a substring of a formatted message.
3033/// Every other `zbus::Error` (no reply, a timeout, a service that left the
3034/// bus, a broken connection) is a genuine failure and stays one.
3035fn is_absent_member(err: &zbus::Error) -> bool {
3036 match err {
3037 zbus::Error::MethodError(name, ..) => ABSENT_MEMBER_ERRORS.contains(&name.as_str()),
3038 _ => false,
3039 }
3040}
3041
3042/// Re-word a classification failure so it names the top-level that would not
3043/// answer and the application that had just listed it.
3044///
3045/// The shell scan classifies a frame by two D-Bus reads, and either failing
3046/// aborts the listing rather than being read as "not a panel" (tenet 1). What
3047/// the raw `GetRole failed: …` does not say is *whose* window it was — which
3048/// is the whole of what a consumer needs to know to go look (tenet 6).
3049fn classify_failure(app: &ElementData, top: &AccessibleRef, err: Error) -> Error {
3050 let name = app.name.as_deref().unwrap_or("<unnamed>");
3051 let pid = app.pid.map(|p| format!(" pid={p}")).unwrap_or_default();
3052 Error::Platform {
3053 code: match &err {
3054 Error::Platform { code, .. } => *code,
3055 _ => -1,
3056 },
3057 message: format!(
3058 "could not classify top-level {} of application \"{name}\"{pid} as a shell \
3059 surface: {err}",
3060 top.path
3061 ),
3062 }
3063}
3064
3065/// Map an AT-SPI2 action name to its canonical `snake_case` xa11y action name.
3066///
3067/// Toolkit-specific aliases are normalised to the single canonical name:
3068/// "click" / "activate" / "press" / "invoke" / "dodefault" → "press"
3069/// "toggle" / "check" / "uncheck" → "toggle"
3070/// "expand" / "open" → "expand"
3071/// "collapse" / "close" → "collapse"
3072/// "menu" / "showmenu" / "showcontextmenu" / "popup" / "show menu" → "show_menu"
3073/// "select" → "select"
3074/// "increment" → "increment"
3075/// "decrement" → "decrement"
3076///
3077/// Returns `None` for unrecognised names.
3078fn map_atspi_action_name(action_name: &str) -> Option<String> {
3079 // Normalise by lowercasing and stripping underscores/spaces so that
3080 // "show_menu", "show menu", "showMenu" and "showContextMenu" all collapse
3081 // to the same canonical form. Chromium is the main motivator: it uses
3082 // `doDefault` as the default activation action on ~190 elements per
3083 // window (Back/Forward buttons, toolbar icons, menu items, sliders,
3084 // notifications) and `showContextMenu` as the context-menu action. The
3085 // previous table dropped both, leaving those elements with no `press`
3086 // mapping at all. See issue #101.
3087 let lower = action_name.to_lowercase();
3088 let collapsed: String = lower.chars().filter(|c| !matches!(c, '_' | ' ')).collect();
3089 let canonical = match collapsed.as_str() {
3090 "click" | "activate" | "press" | "invoke" | "dodefault" => "press",
3091 "toggle" | "check" | "uncheck" => "toggle",
3092 "expand" | "open" => "expand",
3093 "collapse" | "close" => "collapse",
3094 "select" => "select",
3095 "menu" | "showmenu" | "showcontextmenu" | "contextmenu" | "popup" => "show_menu",
3096 "increment" => "increment",
3097 "decrement" => "decrement",
3098 _ => return None,
3099 };
3100 Some(canonical.to_string())
3101}
3102
3103/// Resolve AT-SPI's overloaded `menu` role using positive activation evidence.
3104///
3105/// GTK 3 reports both popup menu containers and submenu-bearing entries as
3106/// `menu`. Its entries implement Action with `click`, while the GtkMenu
3107/// container does not implement Action. Parentage and children cannot separate
3108/// the two: GTK reparents the popup under its owning entry and exposes submenu
3109/// items directly as children of that same entry.
3110fn refine_menu_role(role: Role, actions: &[String]) -> Role {
3111 if role == Role::Menu && actions.iter().any(|action| action == "press") {
3112 Role::MenuItem
3113 } else {
3114 role
3115 }
3116}
3117
3118#[cfg(test)]
3119mod tests {
3120 use super::*;
3121
3122 #[test]
3123 fn test_role_mapping() {
3124 assert_eq!(map_atspi_role("push button"), Role::Button);
3125 assert_eq!(map_atspi_role("toggle button"), Role::Switch);
3126 assert_eq!(map_atspi_role("check box"), Role::CheckBox);
3127 assert_eq!(map_atspi_role("entry"), Role::TextField);
3128 assert_eq!(map_atspi_role("label"), Role::StaticText);
3129 assert_eq!(map_atspi_role("window"), Role::Window);
3130 assert_eq!(map_atspi_role("frame"), Role::Window);
3131 assert_eq!(map_atspi_role("dialog"), Role::Dialog);
3132 assert_eq!(map_atspi_role("combo box"), Role::ComboBox);
3133 assert_eq!(map_atspi_role("slider"), Role::Slider);
3134 assert_eq!(map_atspi_role("panel"), Role::Group);
3135 assert_eq!(map_atspi_role("unknown_thing"), Role::Unknown);
3136 }
3137
3138 #[test]
3139 fn menu_with_native_activation_is_a_menu_item() {
3140 assert_eq!(
3141 refine_menu_role(Role::Menu, &["press".to_string()]),
3142 Role::MenuItem
3143 );
3144 }
3145
3146 #[test]
3147 fn non_activatable_menu_stays_a_container() {
3148 assert_eq!(refine_menu_role(Role::Menu, &[]), Role::Menu);
3149 assert_eq!(
3150 refine_menu_role(Role::Menu, &["show_menu".to_string()]),
3151 Role::Menu
3152 );
3153 }
3154
3155 #[test]
3156 fn activation_does_not_change_other_roles() {
3157 assert_eq!(
3158 refine_menu_role(Role::MenuBar, &["press".to_string()]),
3159 Role::MenuBar
3160 );
3161 assert_eq!(
3162 refine_menu_role(Role::MenuItem, &["press".to_string()]),
3163 Role::MenuItem
3164 );
3165 }
3166
3167 #[test]
3168 fn test_numeric_role_mapping() {
3169 // ToggleButton (62) must map to Switch, not Button.
3170 // GTK4's Gtk.Switch and Gtk.ToggleButton both report numeric role 62.
3171 assert_eq!(map_atspi_role_number(62), Role::Switch);
3172 // Sanity-check a few well-established numeric mappings.
3173 assert_eq!(map_atspi_role_number(43), Role::Button); // PushButton
3174 assert_eq!(map_atspi_role_number(7), Role::CheckBox);
3175 assert_eq!(map_atspi_role_number(67), Role::Unknown); // AT-SPI Unknown
3176 }
3177
3178 #[test]
3179 fn grab_focus_notsupported_is_action_not_supported() {
3180 // The GTK4 adapter answers a button GrabFocus with the D-Bus
3181 // NotSupported error instead of a false reply (issue #98); the
3182 // classifier must surface it as ActionNotSupported so the suites'
3183 // xfail contract holds, and keep everything else a platform error
3184 // (tenet 1).
3185 let declined = grab_focus_error(
3186 "org.freedesktop.DBus.Error.NotSupported".into(),
3187 "focusing",
3188 "focus",
3189 Role::Button,
3190 );
3191 match declined {
3192 Error::ActionNotSupported { action, role } => {
3193 assert_eq!(action, "focus");
3194 assert_eq!(role, Role::Button);
3195 }
3196 other => panic!("expected ActionNotSupported, got {other:?}"),
3197 }
3198
3199 let broken = grab_focus_error(
3200 "org.freedesktop.DBus.Error.UnknownMethod".into(),
3201 "activating",
3202 "activate",
3203 Role::Window,
3204 );
3205 match broken {
3206 Error::Platform { code, .. } => assert_eq!(code, -1),
3207 other => panic!("expected Platform error, got {other:?}"),
3208 }
3209 }
3210
3211 /// The full AtspiRole enum (atspi 2.52) as (number, GetRoleName-style
3212 /// name) pairs. The name form is the enum nick with dashes replaced by
3213 /// spaces, which is what providers hand back over D-Bus.
3214 const ATSPI_ROLE_ENUM: &[(u32, &str)] = &[
3215 (0, "invalid"),
3216 (1, "accelerator label"),
3217 (2, "alert"),
3218 (3, "animation"),
3219 (4, "arrow"),
3220 (5, "calendar"),
3221 (6, "canvas"),
3222 (7, "check box"),
3223 (8, "check menu item"),
3224 (9, "color chooser"),
3225 (10, "column header"),
3226 (11, "combo box"),
3227 (12, "date editor"),
3228 (13, "desktop icon"),
3229 (14, "desktop frame"),
3230 (15, "dial"),
3231 (16, "dialog"),
3232 (17, "directory pane"),
3233 (18, "drawing area"),
3234 (19, "file chooser"),
3235 (20, "filler"),
3236 (21, "focus traversable"),
3237 (22, "font chooser"),
3238 (23, "frame"),
3239 (24, "glass pane"),
3240 (25, "html container"),
3241 (26, "icon"),
3242 (27, "image"),
3243 (28, "internal frame"),
3244 (29, "label"),
3245 (30, "layered pane"),
3246 (31, "list"),
3247 (32, "list item"),
3248 (33, "menu"),
3249 (34, "menu bar"),
3250 (35, "menu item"),
3251 (36, "option pane"),
3252 (37, "page tab"),
3253 (38, "page tab list"),
3254 (39, "panel"),
3255 (40, "password text"),
3256 (41, "popup menu"),
3257 (42, "progress bar"),
3258 (43, "push button"),
3259 (44, "radio button"),
3260 (45, "radio menu item"),
3261 (46, "root pane"),
3262 (47, "row header"),
3263 (48, "scroll bar"),
3264 (49, "scroll pane"),
3265 (50, "separator"),
3266 (51, "slider"),
3267 (52, "spin button"),
3268 (53, "split pane"),
3269 (54, "status bar"),
3270 (55, "table"),
3271 (56, "table cell"),
3272 (57, "table column header"),
3273 (58, "table row header"),
3274 (59, "tearoff menu item"),
3275 (60, "terminal"),
3276 (61, "text"),
3277 (62, "toggle button"),
3278 (63, "tool bar"),
3279 (64, "tool tip"),
3280 (65, "tree"),
3281 (66, "tree table"),
3282 (67, "unknown"),
3283 (68, "viewport"),
3284 (69, "window"),
3285 (70, "extended"),
3286 (71, "header"),
3287 (72, "footer"),
3288 (73, "paragraph"),
3289 (74, "ruler"),
3290 (75, "application"),
3291 (76, "autocomplete"),
3292 (77, "editbar"),
3293 (78, "embedded"),
3294 (79, "entry"),
3295 (80, "chart"),
3296 (81, "caption"),
3297 (82, "document frame"),
3298 (83, "heading"),
3299 (84, "page"),
3300 (85, "section"),
3301 (86, "redundant object"),
3302 (87, "form"),
3303 (88, "link"),
3304 (89, "input method window"),
3305 (90, "table row"),
3306 (91, "tree item"),
3307 (92, "document spreadsheet"),
3308 (93, "document presentation"),
3309 (94, "document text"),
3310 (95, "document web"),
3311 (96, "document email"),
3312 (97, "comment"),
3313 (98, "list box"),
3314 (99, "grouping"),
3315 (100, "image map"),
3316 (101, "notification"),
3317 (102, "info bar"),
3318 (103, "level bar"),
3319 (104, "title bar"),
3320 (105, "block quote"),
3321 (106, "audio"),
3322 (107, "video"),
3323 (108, "definition"),
3324 (109, "article"),
3325 (110, "landmark"),
3326 (111, "log"),
3327 (112, "marquee"),
3328 (113, "math"),
3329 (114, "rating"),
3330 (115, "timer"),
3331 (116, "static"),
3332 (117, "math fraction"),
3333 (118, "math root"),
3334 (119, "subscript"),
3335 (120, "superscript"),
3336 (121, "description list"),
3337 (122, "description term"),
3338 (123, "description value"),
3339 (124, "footnote"),
3340 (125, "content deletion"),
3341 (126, "content insertion"),
3342 (127, "mark"),
3343 (128, "suggestion"),
3344 (129, "push button menu"),
3345 ];
3346
3347 #[test]
3348 fn numeric_and_name_role_maps_agree() {
3349 // The provider resolves roles by number on some paths (resolve_role)
3350 // and by name on others (build_element_data). If the two maps drift,
3351 // the same element reports different roles depending on how it was
3352 // reached — this happened for StatusBar (54, name-only), ToolTip
3353 // (64, and 93 was mis-numbered as Tooltip), and Landmark (110).
3354 // Sweep the whole enum so any future addition lands in both maps.
3355 for &(num, name) in ATSPI_ROLE_ENUM {
3356 let by_num = map_atspi_role_number(num);
3357 let by_name = map_atspi_role(name);
3358 assert_eq!(
3359 by_num, by_name,
3360 "AT-SPI role {num} ({name:?}) diverges: numeric map gives \
3361 {by_num:?}, name map gives {by_name:?}"
3362 );
3363 }
3364 }
3365
3366 #[test]
3367 fn test_action_name_mapping() {
3368 assert_eq!(map_atspi_action_name("click"), Some("press".to_string()));
3369 assert_eq!(map_atspi_action_name("activate"), Some("press".to_string()));
3370 assert_eq!(map_atspi_action_name("press"), Some("press".to_string()));
3371 assert_eq!(map_atspi_action_name("invoke"), Some("press".to_string()));
3372 // Chromium uses `doDefault` for default activation on ~190 elements.
3373 assert_eq!(
3374 map_atspi_action_name("doDefault"),
3375 Some("press".to_string())
3376 );
3377 assert_eq!(
3378 map_atspi_action_name("do_default"),
3379 Some("press".to_string())
3380 );
3381 assert_eq!(map_atspi_action_name("toggle"), Some("toggle".to_string()));
3382 assert_eq!(map_atspi_action_name("check"), Some("toggle".to_string()));
3383 assert_eq!(map_atspi_action_name("uncheck"), Some("toggle".to_string()));
3384 assert_eq!(map_atspi_action_name("expand"), Some("expand".to_string()));
3385 assert_eq!(map_atspi_action_name("open"), Some("expand".to_string()));
3386 assert_eq!(
3387 map_atspi_action_name("collapse"),
3388 Some("collapse".to_string())
3389 );
3390 assert_eq!(map_atspi_action_name("close"), Some("collapse".to_string()));
3391 assert_eq!(map_atspi_action_name("select"), Some("select".to_string()));
3392 assert_eq!(map_atspi_action_name("menu"), Some("show_menu".to_string()));
3393 assert_eq!(
3394 map_atspi_action_name("showmenu"),
3395 Some("show_menu".to_string())
3396 );
3397 assert_eq!(
3398 map_atspi_action_name("popup"),
3399 Some("show_menu".to_string())
3400 );
3401 assert_eq!(
3402 map_atspi_action_name("show menu"),
3403 Some("show_menu".to_string())
3404 );
3405 // Chrome / Chromium expose the URL-bar context-menu action as
3406 // `showContextMenu`; the previous table missed both spellings.
3407 assert_eq!(
3408 map_atspi_action_name("showContextMenu"),
3409 Some("show_menu".to_string())
3410 );
3411 assert_eq!(
3412 map_atspi_action_name("show_context_menu"),
3413 Some("show_menu".to_string())
3414 );
3415 assert_eq!(
3416 map_atspi_action_name("increment"),
3417 Some("increment".to_string())
3418 );
3419 assert_eq!(
3420 map_atspi_action_name("decrement"),
3421 Some("decrement".to_string())
3422 );
3423 assert_eq!(map_atspi_action_name("foobar"), None);
3424 }
3425
3426 /// All known AT-SPI2 aliases map to one of the well-known action names,
3427 /// and re-mapping the canonical name produces the same canonical name.
3428 #[test]
3429 fn test_action_name_aliases_roundtrip() {
3430 let atspi_names = [
3431 "click",
3432 "activate",
3433 "press",
3434 "invoke",
3435 "doDefault",
3436 "do_default",
3437 "toggle",
3438 "check",
3439 "uncheck",
3440 "expand",
3441 "open",
3442 "collapse",
3443 "close",
3444 "select",
3445 "menu",
3446 "showmenu",
3447 "showContextMenu",
3448 "show_context_menu",
3449 "popup",
3450 "show menu",
3451 "increment",
3452 "decrement",
3453 ];
3454 for name in atspi_names {
3455 let canonical = map_atspi_action_name(name).unwrap_or_else(|| {
3456 panic!("AT-SPI2 name {:?} should map to a canonical name", name)
3457 });
3458 // Re-mapping the canonical name must produce itself.
3459 let back = map_atspi_action_name(&canonical)
3460 .unwrap_or_else(|| panic!("canonical {:?} should map back to itself", canonical));
3461 assert_eq!(
3462 canonical, back,
3463 "AT-SPI2 {:?} -> {:?} -> {:?} (expected {:?})",
3464 name, canonical, back, canonical
3465 );
3466 }
3467 }
3468
3469 /// Case-insensitive mapping works.
3470 #[test]
3471 fn test_action_name_case_insensitive() {
3472 assert_eq!(map_atspi_action_name("Click"), Some("press".to_string()));
3473 assert_eq!(map_atspi_action_name("TOGGLE"), Some("toggle".to_string()));
3474 assert_eq!(
3475 map_atspi_action_name("Increment"),
3476 Some("increment".to_string())
3477 );
3478 }
3479
3480 /// The GTK press-fallback's actionable-role set must include the inner
3481 /// toggle-button pattern used by `GtkMenuButton` / `AdwMenuButton`, plus
3482 /// the other standard activatable roles we're willing to synthesise a
3483 /// click for.
3484 #[test]
3485 fn test_gtk_fallback_actionable_roles() {
3486 for role in [
3487 "push button",
3488 "toggle button",
3489 "check box",
3490 "radio button",
3491 "menu item",
3492 "link",
3493 "tab",
3494 "list item",
3495 "tree item",
3496 ] {
3497 assert!(
3498 is_actionable_atspi_role(role),
3499 "{role:?} should be actionable"
3500 );
3501 }
3502 }
3503
3504 /// Never treat static / decorative accessibles as fallback candidates.
3505 /// Particularly important for `label`, whose synthesised text-editing
3506 /// actions (`clipboard.copy`, `selection.delete`) must never be invoked
3507 /// by a press heuristic.
3508 #[test]
3509 fn test_gtk_fallback_non_actionable_roles() {
3510 for role in [
3511 "label",
3512 "panel",
3513 "filler",
3514 "section",
3515 "group",
3516 "image",
3517 "separator",
3518 "static",
3519 "frame",
3520 "window",
3521 ] {
3522 assert!(
3523 !is_actionable_atspi_role(role),
3524 "{role:?} must not be treated as actionable"
3525 );
3526 }
3527 }
3528
3529 /// The BFS stops at static/decorative roles. `label` in particular must
3530 /// never be descended into — its children are text spans with bogus
3531 /// actions.
3532 #[test]
3533 fn test_gtk_fallback_never_descend_roles() {
3534 for role in ["label", "separator", "image", "icon", "static", "caption"] {
3535 assert!(
3536 is_never_descend_atspi_role(role),
3537 "{role:?} must block BFS descent"
3538 );
3539 }
3540 // Containers — panel / filler / section / group / frame — stay
3541 // walkable so the BFS can reach a wrapped inner actionable.
3542 for role in ["panel", "filler", "section", "group", "frame"] {
3543 assert!(
3544 !is_never_descend_atspi_role(role),
3545 "container role {role:?} must remain descendable"
3546 );
3547 }
3548 }
3549
3550 fn attrs(pairs: &[(&str, &str)]) -> HashMap<String, String> {
3551 pairs
3552 .iter()
3553 .map(|(k, v)| (k.to_string(), v.to_string()))
3554 .collect()
3555 }
3556
3557 /// A panel frame is the `window-type:dock` attribute and nothing else —
3558 /// xfce4-panel's rows are unnamed frames, so no name test can stand in
3559 /// for it.
3560 #[test]
3561 fn test_dock_frame_detection() {
3562 assert!(is_dock_frame(
3563 ATSPI_ROLE_FRAME,
3564 &attrs(&[("toolkit", "GTK"), ("window-type", "dock")])
3565 ));
3566 // An ordinary application window: same role, different window type.
3567 assert!(!is_dock_frame(
3568 ATSPI_ROLE_FRAME,
3569 &attrs(&[("window-type", "normal")])
3570 ));
3571 // Most toolkits set no `window-type` at all.
3572 assert!(!is_dock_frame(
3573 ATSPI_ROLE_FRAME,
3574 &attrs(&[("toolkit", "GTK")])
3575 ));
3576 assert!(!is_dock_frame(ATSPI_ROLE_FRAME, &HashMap::new()));
3577 }
3578
3579 /// The frame role number is the one `GetRoleName` would spell "frame" —
3580 /// the classification moved to `GetRole` because AccessKit's bridge
3581 /// implements only the numeric method.
3582 #[test]
3583 fn test_frame_role_number_is_the_atspi_frame_role() {
3584 let (number, name) = ATSPI_ROLE_ENUM
3585 .iter()
3586 .find(|(_, name)| *name == "frame")
3587 .copied()
3588 .expect("the AtspiRole table must carry `frame`");
3589 assert_eq!(number, ATSPI_ROLE_FRAME, "{name} moved");
3590 }
3591
3592 /// The dock attribute only makes a surface on a top-level frame. `panel`
3593 /// (39) is the AT-SPI role for an ordinary grouping container (it maps to
3594 /// `Role::Group`), and `window` (69) / `dialog` (16) are not what panels
3595 /// register as — `Window` in particular shares `Role::Window` with
3596 /// `Frame`, so only the raw number tells them apart.
3597 #[test]
3598 fn test_dock_frame_requires_frame_role() {
3599 let dock = attrs(&[("window-type", "dock")]);
3600 for (number, name) in ATSPI_ROLE_ENUM
3601 .iter()
3602 .filter(|(number, _)| *number != ATSPI_ROLE_FRAME)
3603 {
3604 assert!(
3605 !is_dock_frame(*number, &dock),
3606 "role {number} ({name:?}) must not be reported as a shell surface"
3607 );
3608 }
3609 }
3610
3611 /// Build the `zbus::Error` a D-Bus error reply of `name` arrives as, the
3612 /// way `Proxy::call_method` hands it back.
3613 fn method_error(name: &str) -> zbus::Error {
3614 let call = zbus::Message::method_call("/org/a11y/atspi/accessible/7", "GetAttributes")
3615 .expect("method-call builder")
3616 .serial(std::num::NonZeroU32::new(1).expect("nonzero"))
3617 .build(&())
3618 .expect("build method call");
3619 let reply = zbus::Message::error(&call.header(), name)
3620 .expect("error builder")
3621 .serial(std::num::NonZeroU32::new(2).expect("nonzero"))
3622 .build(&"boom")
3623 .expect("build error reply");
3624 zbus::Error::from(reply)
3625 }
3626
3627 /// An object that does not implement the method it was asked for is
3628 /// absence, not failure: AccessKit's AT-SPI bridge (egui and friends)
3629 /// answers `GetRole` but has no `GetRoleName`, and reading that reply as a
3630 /// platform error failed the whole shell listing for every desktop running
3631 /// one such app.
3632 #[test]
3633 fn an_unimplemented_member_reads_as_absence() {
3634 for name in ABSENT_MEMBER_ERRORS {
3635 assert!(
3636 is_absent_member(&method_error(name)),
3637 "{name} must read as absence"
3638 );
3639 }
3640 }
3641
3642 /// Everything else stays a failure — including the D-Bus errors that look
3643 /// superficially similar but mean the far end is broken or gone.
3644 /// An application that quits between `list_apps()` and the shell scan's
3645 /// reads answers `ServiceUnknown`. That is the process being gone, not a
3646 /// failure worth aborting a whole-desktop listing for — and it used to
3647 /// abort it, making `ShellSurface::list()` nondeterministic on a busy
3648 /// desktop. A hung application is a different outcome and still fails.
3649 #[test]
3650 fn a_departed_peer_is_absence_not_failure() {
3651 for name in [
3652 "org.freedesktop.DBus.Error.ServiceUnknown",
3653 "org.freedesktop.DBus.Error.NameHasNoOwner",
3654 ] {
3655 assert!(
3656 is_peer_gone(&method_error(name)),
3657 "{name} means the peer left"
3658 );
3659 // The two axes stay separate: a departed peer is not a statement
3660 // about which members an object implements.
3661 assert!(!is_absent_member(&method_error(name)), "{name}");
3662 }
3663 }
3664
3665 #[test]
3666 fn a_hung_or_broken_peer_is_still_a_failure() {
3667 for name in [
3668 "org.freedesktop.DBus.Error.NoReply",
3669 "org.freedesktop.DBus.Error.Timeout",
3670 "org.freedesktop.DBus.Error.Disconnected",
3671 "org.freedesktop.DBus.Error.AccessDenied",
3672 "org.freedesktop.DBus.Error.UnknownMethod",
3673 ] {
3674 assert!(
3675 !is_peer_gone(&method_error(name)),
3676 "{name} must not be read as the peer having left"
3677 );
3678 }
3679 assert!(!is_peer_gone(&zbus::Error::InvalidReply));
3680 }
3681
3682 #[test]
3683 fn other_dbus_failures_are_not_absence() {
3684 for name in [
3685 "org.freedesktop.DBus.Error.ServiceUnknown",
3686 "org.freedesktop.DBus.Error.NoReply",
3687 "org.freedesktop.DBus.Error.Timeout",
3688 "org.freedesktop.DBus.Error.Disconnected",
3689 "org.freedesktop.DBus.Error.AccessDenied",
3690 "org.freedesktop.DBus.Error.UnknownProperty",
3691 ] {
3692 assert!(
3693 !is_absent_member(&method_error(name)),
3694 "{name} must stay a failure"
3695 );
3696 }
3697 // A transport-level failure carries no error name at all.
3698 assert!(!is_absent_member(&zbus::Error::InvalidReply));
3699 }
3700
3701 /// A frame the scan could not classify must fail the listing, naming the
3702 /// app and the object path. `Panel` is the only kind Linux vends, so
3703 /// swallowing this would render a broken panel process as a desktop with
3704 /// no panels at all — indistinguishable from the honest empty answer.
3705 #[test]
3706 fn a_classification_failure_names_the_app_and_the_top_level() {
3707 let mut app = ElementData::for_role(Role::Application);
3708 app.name = Some("xfce4-panel".to_string());
3709 app.pid = Some(4242);
3710 let top = AccessibleRef {
3711 bus_name: ":1.42".to_string(),
3712 path: "/org/a11y/atspi/accessible/7".to_string(),
3713 };
3714 let err = classify_failure(
3715 &app,
3716 &top,
3717 Error::Platform {
3718 code: -1,
3719 message: "GetRole failed: connection closed".to_string(),
3720 },
3721 );
3722 let Error::Platform { code, message } = err else {
3723 panic!("a D-Bus failure must stay a platform error");
3724 };
3725 assert_eq!(code, -1);
3726 assert!(message.contains("xfce4-panel"), "{message}");
3727 assert!(message.contains("pid=4242"), "{message}");
3728 assert!(
3729 message.contains("/org/a11y/atspi/accessible/7"),
3730 "{message}"
3731 );
3732 // Never masks the underlying D-Bus error (tenet 1).
3733 assert!(message.contains("GetRole failed"), "{message}");
3734 }
3735
3736 /// An unnamed application still produces a usable message — the object
3737 /// path is what a consumer actually goes looking with.
3738 #[test]
3739 fn a_classification_failure_survives_an_unnamed_app() {
3740 let app = ElementData::for_role(Role::Application);
3741 let top = AccessibleRef {
3742 bus_name: ":1.9".to_string(),
3743 path: "/org/a11y/atspi/accessible/3".to_string(),
3744 };
3745 let err = classify_failure(
3746 &app,
3747 &top,
3748 Error::Platform {
3749 code: -1,
3750 message: "GetAttributes failed".to_string(),
3751 },
3752 );
3753 let msg = err.to_string();
3754 assert!(msg.contains("<unnamed>"), "{msg}");
3755 assert!(msg.contains("/org/a11y/atspi/accessible/3"), "{msg}");
3756 }
3757}