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