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