Skip to main content

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