Skip to main content

computer_use_linux/
atspi_tree.rs

1use crate::diagnostics::hydrate_session_bus_env;
2use anyhow::{anyhow, Context, Result};
3use atspi::{
4    proxy::{
5        accessible::{AccessibleProxy, ObjectRefExt},
6        action::ActionProxy,
7        proxy_ext::ProxyExt,
8    },
9    CoordType, ObjectRef, ObjectRefOwned, StateSet,
10};
11// Direct dependency (p2p feature off) — see Cargo.toml for why we bypass
12// atspi's "connection" re-export.
13use atspi_connection::AccessibilityConnection;
14use futures_util::{stream, StreamExt};
15use schemars::JsonSchema;
16use serde::Serialize;
17use std::{collections::VecDeque, future::Future, time::Duration};
18use tokio::time::timeout;
19use zbus::{
20    fdo::DBusProxy,
21    names::{BusName, UniqueName},
22    zvariant::{ObjectPath, OwnedObjectPath},
23};
24
25#[derive(Debug, Clone, Serialize, JsonSchema)]
26pub struct AccessibleAppSummary {
27    pub object_ref: String,
28    pub name: Option<String>,
29    pub pid: Option<u32>,
30    pub role: String,
31    pub child_count: i32,
32    pub bounds: Option<Bounds>,
33}
34
35#[derive(Debug, Clone, Serialize, JsonSchema)]
36pub struct AccessibilityNode {
37    pub index: u32,
38    pub parent_index: Option<u32>,
39    pub depth: u32,
40    pub object_ref: String,
41    pub role: String,
42    pub name: Option<String>,
43    pub description: Option<String>,
44    pub child_count: i32,
45    pub bounds: Option<Bounds>,
46    pub states: Vec<String>,
47    pub actions: Vec<AccessibilityAction>,
48    pub value: Option<AccessibilityValue>,
49    pub text: Option<AccessibilityText>,
50    pub supports_editable_text: bool,
51}
52
53#[derive(Debug, Clone, Serialize, JsonSchema)]
54pub struct Bounds {
55    pub x: i32,
56    pub y: i32,
57    pub width: i32,
58    pub height: i32,
59}
60
61#[derive(Debug, Clone, Serialize, JsonSchema)]
62pub struct AccessibilityAction {
63    pub index: i32,
64    pub name: String,
65    pub description: String,
66    pub keybinding: String,
67}
68
69#[derive(Debug, Clone, Serialize, JsonSchema)]
70pub struct AccessibilityValue {
71    pub current: f64,
72    pub minimum: f64,
73    pub maximum: f64,
74    pub minimum_increment: f64,
75    pub text: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize, JsonSchema)]
79pub struct AccessibilityText {
80    pub character_count: i32,
81    pub caret_offset: Option<i32>,
82    pub content: Option<String>,
83    pub truncated: bool,
84    pub selections: Vec<AccessibilityTextSelection>,
85}
86
87#[derive(Debug, Clone, Serialize, JsonSchema)]
88pub struct AccessibilityTextSelection {
89    pub start_offset: i32,
90    pub end_offset: i32,
91}
92
93#[derive(Debug, Clone)]
94pub struct ActionInvocation {
95    pub action_index: i32,
96    pub action_name: Option<String>,
97    pub ok: bool,
98}
99
100#[derive(Debug, Clone)]
101pub enum ValueSetInvocation {
102    Numeric { value: f64 },
103    EditableText,
104}
105
106const MAX_TEXT_READBACK_CHARS: i32 = 4096;
107const MAX_TEXT_SELECTIONS: i32 = 8;
108const DEFAULT_SNAPSHOT_MAX_NODES: usize = 1_000;
109const HARD_SNAPSHOT_MAX_NODES: usize = 2_000;
110const DEFAULT_SNAPSHOT_MAX_DEPTH: u32 = 32;
111const HARD_SNAPSHOT_MAX_DEPTH: u32 = 64;
112const CHILD_READ_CONCURRENCY: usize = 16;
113const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
114const MAX_ACTIONS_PER_OBJECT: i32 = 64;
115const ACTION_QUERY_TIMEOUT: Duration = Duration::from_secs(5);
116const MAX_DISCOVERY_ROOTS: usize = 256;
117const ROOT_MATCH_CHILD_LIMIT: usize = 8;
118const MAX_DISCOVERY_CHILD_READS: usize = MAX_DISCOVERY_ROOTS * ROOT_MATCH_CHILD_LIMIT;
119
120fn snapshot_child_read_budgets(max_nodes: usize) -> (usize, usize, usize) {
121    (MAX_DISCOVERY_ROOTS, MAX_DISCOVERY_CHILD_READS, max_nodes)
122}
123
124pub(crate) fn snapshot_limits(
125    requested_max_nodes: Option<usize>,
126    requested_max_depth: Option<u32>,
127) -> (usize, u32) {
128    (
129        requested_max_nodes
130            .unwrap_or(DEFAULT_SNAPSHOT_MAX_NODES)
131            .clamp(1, HARD_SNAPSHOT_MAX_NODES),
132        requested_max_depth
133            .unwrap_or(DEFAULT_SNAPSHOT_MAX_DEPTH)
134            .min(HARD_SNAPSHOT_MAX_DEPTH),
135    )
136}
137
138struct BoundedTraversal<T> {
139    queue: VecDeque<T>,
140    attempted: usize,
141    max_items: usize,
142    /// Set once an enqueue had to drop offered items for lack of capacity.
143    dropped: bool,
144}
145
146impl<T> BoundedTraversal<T> {
147    fn new(max_items: usize) -> Self {
148        Self {
149            queue: VecDeque::new(),
150            attempted: 0,
151            max_items,
152            dropped: false,
153        }
154    }
155
156    fn enqueue(&mut self, items: impl IntoIterator<Item = T>) {
157        let capacity = self.remaining_capacity();
158        let mut items = items.into_iter();
159        self.queue.extend(items.by_ref().take(capacity));
160        if items.next().is_some() {
161            self.dropped = true;
162        }
163    }
164
165    fn pop(&mut self) -> Option<T> {
166        if self.attempted >= self.max_items {
167            return None;
168        }
169        let item = self.queue.pop_front()?;
170        self.attempted += 1;
171        Some(item)
172    }
173
174    fn remaining_capacity(&self) -> usize {
175        self.max_items
176            .saturating_sub(self.attempted.saturating_add(self.queue.len()))
177    }
178
179    /// True when the budget stopped work that was actually offered. `enqueue`
180    /// is the only gate: it never exceeds `remaining_capacity`, so
181    /// `attempted + queue.len() <= max_items` always holds and the queue can
182    /// never be non-empty at the attempt cap. Dropped items are the whole story.
183    fn truncated(&self) -> bool {
184        self.dropped
185    }
186}
187
188fn bounded_child_count(reported: i32, limit: usize) -> usize {
189    usize::try_from(reported).unwrap_or_default().min(limit)
190}
191
192struct IndexedReadBatch<T> {
193    items: Vec<T>,
194    attempted: usize,
195    /// True when fewer children were attempted than the parent reported, because
196    /// the caller's limit or the shared read budget ran out. Failed reads do not
197    /// count; they were attempted.
198    incomplete: bool,
199}
200
201impl<T> IndexedReadBatch<T> {
202    fn all_failed(&self) -> bool {
203        self.attempted > 0 && self.items.is_empty()
204    }
205}
206
207async fn fetch_indexed_up_to<T, E, F, Fut>(
208    reported: i32,
209    limit: usize,
210    remaining_attempts: &mut usize,
211    fetch: F,
212) -> IndexedReadBatch<T>
213where
214    F: Fn(i32) -> Fut,
215    Fut: Future<Output = std::result::Result<T, E>>,
216{
217    let attempt_count = bounded_child_count(reported, limit).min(*remaining_attempts);
218    *remaining_attempts = (*remaining_attempts).saturating_sub(attempt_count);
219    let end_index = i32::try_from(attempt_count).unwrap_or(i32::MAX);
220
221    let items = stream::iter(0..end_index)
222        .map(fetch)
223        .buffered(CHILD_READ_CONCURRENCY)
224        .filter_map(|result| async move { result.ok() })
225        .collect()
226        .await;
227
228    IndexedReadBatch {
229        items,
230        attempted: attempt_count,
231        incomplete: attempt_count < usize::try_from(reported).unwrap_or_default(),
232    }
233}
234
235async fn children_up_to(
236    proxy: &AccessibleProxy<'_>,
237    limit: usize,
238    remaining_attempts: &mut usize,
239) -> zbus::Result<IndexedReadBatch<ObjectRefOwned>> {
240    if limit == 0 || *remaining_attempts == 0 {
241        // Budget already spent: stay I/O-free. `incomplete` is false here because
242        // nothing was asked of the parent; callers that already hold the node's
243        // child_count (snapshot_tree_inner) derive truncation from that instead.
244        return Ok(IndexedReadBatch {
245            items: Vec::new(),
246            attempted: 0,
247            incomplete: false,
248        });
249    }
250
251    let child_count = proxy.child_count().await?;
252    Ok(
253        fetch_indexed_up_to(child_count, limit, remaining_attempts, |index| {
254            child_at_index(proxy, index)
255        })
256        .await,
257    )
258}
259
260async fn child_at_index(proxy: &AccessibleProxy<'_>, index: i32) -> zbus::Result<ObjectRefOwned> {
261    // WebKit sockets return a well-known bus name. atspi's ObjectRef decoder
262    // only accepts unique names, so resolve the wire reference before using it.
263    let (name, path): (String, OwnedObjectPath) =
264        proxy.inner().call("GetChildAtIndex", &(index,)).await?;
265    if path.as_str() == "/org/a11y/atspi/null" {
266        return Ok(ObjectRef::Null.into());
267    }
268    match BusName::try_from(name)? {
269        BusName::Unique(name) => Ok(ObjectRef::new_owned(name, path)),
270        name => {
271            let owner = DBusProxy::new(proxy.inner().connection())
272                .await?
273                .get_name_owner(name)
274                .await?;
275            Ok(ObjectRef::new_owned(owner, path))
276        }
277    }
278}
279
280pub async fn list_accessible_apps(limit: usize) -> Result<Vec<AccessibleAppSummary>> {
281    let conn = connect().await?;
282    let mut remaining_child_reads = limit;
283    let roots = registry_children(&conn, limit, &mut remaining_child_reads).await?;
284    let dbus = DBusProxy::new(conn.connection()).await.ok();
285    let mut apps = Vec::new();
286
287    for object_ref in roots.into_iter().take(limit) {
288        if let Ok(proxy) = open_accessible(&conn, &object_ref).await {
289            apps.push(read_app_summary(&proxy, &object_ref, dbus.as_ref()).await);
290        }
291    }
292
293    Ok(apps)
294}
295
296#[derive(Debug, Clone)]
297pub(crate) struct AccessibilitySnapshot {
298    pub nodes: Vec<AccessibilityNode>,
299    /// True when registry roots were filtered to an app name and/or pid.
300    pub scoped: bool,
301    /// The pid every root belongs to, when roots were matched by pid. None
302    /// when they were matched by app name alone or not filtered at all: those
303    /// roots can belong to any app, so no single pid describes the tree.
304    pub root_pid: Option<u32>,
305    /// True when max_nodes, max_depth, or the child read budget stopped
306    /// traversal with unread elements left. Failed element reads do not count.
307    pub truncated: bool,
308}
309
310pub async fn snapshot_tree(
311    app_name_or_bundle_identifier: Option<&str>,
312    target_pid: Option<u32>,
313    max_nodes: usize,
314    max_depth: u32,
315) -> Result<Vec<AccessibilityNode>> {
316    Ok(snapshot_accessibility_tree(
317        app_name_or_bundle_identifier,
318        target_pid,
319        max_nodes,
320        max_depth,
321    )
322    .await?
323    .nodes)
324}
325
326pub(crate) async fn snapshot_accessibility_tree(
327    app_name_or_bundle_identifier: Option<&str>,
328    target_pid: Option<u32>,
329    max_nodes: usize,
330    max_depth: u32,
331) -> Result<AccessibilitySnapshot> {
332    let (max_nodes, max_depth) = snapshot_limits(Some(max_nodes), Some(max_depth));
333    timeout(
334        SNAPSHOT_TIMEOUT,
335        snapshot_tree_inner(
336            app_name_or_bundle_identifier,
337            target_pid,
338            max_nodes,
339            max_depth,
340        ),
341    )
342    .await
343    .context("AT-SPI snapshot exceeded its 10-second deadline")?
344}
345
346async fn snapshot_tree_inner(
347    app_name_or_bundle_identifier: Option<&str>,
348    target_pid: Option<u32>,
349    max_nodes: usize,
350    max_depth: u32,
351) -> Result<AccessibilitySnapshot> {
352    let conn = connect().await?;
353    // App discovery is bounded independently so a tiny requested tree still
354    // finds a target registered after the first accessibility root.
355    let (mut remaining_registry_reads, mut remaining_filter_reads, mut remaining_traversal_reads) =
356        snapshot_child_read_budgets(max_nodes);
357    let roots =
358        registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
359    let selected_roots = select_roots(
360        &conn,
361        roots,
362        app_name_or_bundle_identifier,
363        target_pid,
364        &mut remaining_filter_reads,
365    )
366    .await;
367    let scoped = selected_roots.scoped;
368    let root_pid = selected_roots.pid;
369    let mut nodes = Vec::new();
370    let mut truncated = false;
371    let mut traversal = BoundedTraversal::new(max_nodes);
372
373    traversal.enqueue(
374        selected_roots
375            .roots
376            .into_iter()
377            .map(|object_ref| (object_ref, 0_u32, None)),
378    );
379
380    while let Some((object_ref, depth, parent_index)) = traversal.pop() {
381        let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
382            continue;
383        };
384        let index = nodes.len() as u32;
385        let remaining = traversal.remaining_capacity();
386        let node = read_node(&proxy, &object_ref, index, parent_index, depth).await;
387        let child_refs = if depth < max_depth && remaining > 0 {
388            match children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await {
389                Ok(batch) => {
390                    // A read-budget cut inside the fetch reports `incomplete`; an
391                    // already-exhausted budget returns the I/O-free empty batch, so
392                    // fall back to the child_count read_node already fetched.
393                    truncated |= batch.incomplete || (batch.attempted == 0 && node.child_count > 0);
394                    batch.items
395                }
396                Err(_) => Vec::new(),
397            }
398        } else {
399            // Depth or node cap reached: any child this node reports is unread.
400            truncated |= node.child_count > 0;
401            Vec::new()
402        };
403
404        nodes.push(node);
405
406        traversal.enqueue(
407            child_refs
408                .into_iter()
409                .map(|child| (child, depth + 1, Some(index))),
410        );
411    }
412    truncated |= traversal.truncated();
413
414    Ok(AccessibilitySnapshot {
415        nodes,
416        scoped,
417        root_pid,
418        truncated,
419    })
420}
421
422/// Compact description of the AT-SPI element that currently holds keyboard
423/// focus, used as post-input feedback for type_text/press_key.
424#[derive(Debug, Clone, Serialize, JsonSchema)]
425pub struct FocusedElementSummary {
426    pub role: String,
427    pub name: Option<String>,
428    pub editable: bool,
429    pub states: Vec<String>,
430    /// AT-SPI `Role::Terminal`, read from the role enum rather than the
431    /// localized role name. Internal routing only; not part of the output.
432    #[serde(skip)]
433    #[schemars(skip)]
434    pub is_terminal: bool,
435}
436
437const FOCUS_PROBE_MAX_NODES: usize = 400;
438const FOCUS_PROBE_MAX_DEPTH: u32 = 16;
439
440/// Find the element with the `focused` state inside the target app (by pid) or
441/// across all apps. Best-effort and bounded: returns Ok(None) when no focused
442/// element is reachable through AT-SPI (common for apps without accessibility
443/// support, e.g. Electron without --force-renderer-accessibility).
444/// Outcome of the bounded focused-element search.
445#[derive(Debug, Clone)]
446pub(crate) enum FocusProbe {
447    Found(FocusedElementSummary),
448    /// The search covered the target's whole tree and nothing holds focus.
449    NoneFocused,
450    /// A complete scoped search saw only window/menu chrome, no content.
451    ShellOnly,
452    /// A node, depth, or read limit stopped the search before it finished, so
453    /// the focused element may exist past the limit.
454    Incomplete,
455    /// A pid was given but no AT-SPI app belongs to it (xterm, or Electron
456    /// without --force-renderer-accessibility), so focus cannot be read.
457    NoAccessibleApp,
458}
459
460/// Focused element in the app owning `target_pid`, or across all apps when no
461/// pid is given. With a pid, only that app's tree is searched: falling back to
462/// every other app would report some unrelated widget as the focus.
463pub(crate) async fn probe_focused_element(target_pid: Option<u32>) -> Result<FocusProbe> {
464    focused_element_probe(target_pid, target_pid.is_some()).await
465}
466
467/// The focused element in the app that owns `target_pid`, for callers that
468/// only need an answer when one exists. Every non-`Found` probe result maps to
469/// `None`; see [`probe_focused_element`] for the distinctions.
470pub(crate) async fn focused_element_summary_in_app(
471    target_pid: u32,
472) -> Result<Option<FocusedElementSummary>> {
473    Ok(match focused_element_probe(Some(target_pid), true).await? {
474        FocusProbe::Found(summary) => Some(summary),
475        _ => None,
476    })
477}
478
479async fn focused_element_probe(
480    target_pid: Option<u32>,
481    require_scoped: bool,
482) -> Result<FocusProbe> {
483    let conn = connect().await?;
484    let mut remaining_registry_reads = MAX_DISCOVERY_ROOTS;
485    let roots =
486        registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
487    let mut remaining_filter_reads = MAX_DISCOVERY_CHILD_READS;
488    let selected_roots =
489        select_roots(&conn, roots, None, target_pid, &mut remaining_filter_reads).await;
490    if require_scoped && !selected_roots.scoped {
491        return Ok(FocusProbe::NoAccessibleApp);
492    }
493    let mut traversal = BoundedTraversal::new(FOCUS_PROBE_MAX_NODES);
494    let mut remaining_traversal_reads = FOCUS_PROBE_MAX_NODES;
495    let mut incomplete = false;
496    let mut shell_only = true;
497    let mut observed_nodes = 0;
498
499    traversal.enqueue(
500        selected_roots
501            .roots
502            .into_iter()
503            .map(|object_ref| (object_ref, 0_u32, false)),
504    );
505
506    while let Some((object_ref, depth, in_menu)) = traversal.pop() {
507        let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
508            incomplete = true;
509            continue;
510        };
511        let Ok(state) = proxy.get_state().await else {
512            incomplete = true;
513            continue;
514        };
515        observed_nodes += 1;
516        let mut child_menu = false;
517        if shell_only {
518            match accessibility_chrome_context(&role_name(&proxy).await, in_menu) {
519                Some(menu) => child_menu = menu,
520                None => shell_only = false,
521            }
522        }
523        if state.contains(atspi::State::Focused) {
524            let proxies = proxy.proxies().await.ok();
525            let is_terminal = matches!(proxy.get_role().await, Ok(atspi::Role::Terminal));
526            return Ok(FocusProbe::Found(FocusedElementSummary {
527                role: role_name(&proxy).await,
528                name: optional_string(proxy.name().await.ok()),
529                editable: supports_editable_text(proxies.as_ref()).await,
530                states: state_labels(state),
531                is_terminal,
532            }));
533        }
534        let remaining = traversal.remaining_capacity();
535        if depth < FOCUS_PROBE_MAX_DEPTH && remaining > 0 {
536            if let Ok(batch) =
537                children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await
538            {
539                incomplete |= batch.incomplete;
540                // An exhausted shared read budget takes children_up_to's
541                // I/O-free path (attempted 0, incomplete false), so the node's
542                // own child count has to say whether children went unread.
543                // With budget left, attempted 0 just means a leaf: no read.
544                if batch.attempted == 0 && remaining_traversal_reads == 0 && !incomplete {
545                    incomplete = proxy.child_count().await.map_or(true, |count| count > 0);
546                }
547                traversal.enqueue(
548                    batch
549                        .items
550                        .into_iter()
551                        .map(|child| (child, depth + 1, child_menu)),
552                );
553            } else {
554                incomplete = true;
555            }
556        } else if !incomplete {
557            // Depth or node cap: any child this node reports goes unread. One
558            // child-count read settles it, and only until the first cut is seen.
559            incomplete = proxy.child_count().await.map_or(true, |count| count > 0);
560        }
561    }
562    if incomplete || traversal.truncated() {
563        return Ok(FocusProbe::Incomplete);
564    }
565
566    Ok(if require_scoped && shell_only && observed_nodes > 0 {
567        FocusProbe::ShellOnly
568    } else {
569        FocusProbe::NoneFocused
570    })
571}
572
573/// Window and native menu chrome can exist without Electron renderer content.
574/// This is evidence about tree coverage, not positive Electron detection.
575pub(crate) fn accessibility_chrome_context(role: &str, in_menu: bool) -> Option<bool> {
576    match role {
577        "application" | "frame" | "window" | "panel" | "filler" | "separator" => Some(in_menu),
578        "menu bar" | "menu" | "menu item" | "check menu item" | "radio menu item"
579        | "tearoff menu item" => Some(true),
580        // Electron's native menu buttons are exposed as buttons, not menu items.
581        "button" | "push button" | "label" | "static" | "image" | "icon" if in_menu => Some(true),
582        _ => None,
583    }
584}
585
586pub async fn perform_action(
587    object_ref_id: &str,
588    requested_action: Option<&str>,
589) -> Result<ActionInvocation> {
590    perform_action_inner(object_ref_id, requested_action, false).await
591}
592
593pub(crate) async fn perform_named_action(
594    object_ref_id: &str,
595    name: &str,
596) -> Result<ActionInvocation> {
597    perform_action_inner(object_ref_id, Some(name), true).await
598}
599
600async fn perform_action_inner(
601    object_ref_id: &str,
602    requested_action: Option<&str>,
603    name_only: bool,
604) -> Result<ActionInvocation> {
605    let object_ref = object_ref_from_id(object_ref_id)?;
606    let (action, action_index, action_name) = timeout(ACTION_QUERY_TIMEOUT, async {
607        let conn = connect().await?;
608        let proxy = open_accessible(&conn, &object_ref)
609            .await
610            .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
611        let action = proxy
612            .proxies()
613            .await?
614            .action()
615            .await
616            .context("element does not expose the AT-SPI Action interface")?;
617        let actions = read_actions(&action).await?;
618        let action_index = if name_only {
619            select_named_action_index(&actions, requested_action.unwrap_or_default())?
620        } else {
621            select_action_index(&actions, requested_action)?
622        };
623        let action_name = actions
624            .get(action_index as usize)
625            .map(|action| action.name.clone());
626        Ok::<_, anyhow::Error>((action, action_index, action_name))
627    })
628    .await
629    .context("AT-SPI action revalidation exceeded its 5-second deadline")??;
630    // Only time out the reads: once DoAction is sent, it may already have acted.
631    let ok = action
632        .do_action(action_index)
633        .await
634        .with_context(|| format!("failed to invoke AT-SPI action {action_index}"))?;
635
636    Ok(ActionInvocation {
637        action_index,
638        action_name,
639        ok,
640    })
641}
642
643pub async fn set_element_value(object_ref_id: &str, value: &str) -> Result<ValueSetInvocation> {
644    let conn = connect().await?;
645    let object_ref = object_ref_from_id(object_ref_id)?;
646    let proxy = open_accessible(&conn, &object_ref)
647        .await
648        .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
649    let proxies = proxy.proxies().await?;
650
651    if let Ok(numeric_value) = value.parse::<f64>() {
652        if let Ok(value_proxy) = proxies.value().await {
653            value_proxy
654                .set_current_value(numeric_value)
655                .await
656                .with_context(|| {
657                    format!("failed to set AT-SPI numeric value to {numeric_value}")
658                })?;
659            return Ok(ValueSetInvocation::Numeric {
660                value: numeric_value,
661            });
662        }
663    }
664
665    if let Ok(editable_text) = proxies.editable_text().await {
666        let ok = editable_text
667            .set_text_contents(value)
668            .await
669            .context("failed to set AT-SPI editable text contents")?;
670        if ok {
671            return Ok(ValueSetInvocation::EditableText);
672        }
673        return Err(anyhow!("AT-SPI EditableText rejected the new contents"));
674    }
675
676    if value.parse::<f64>().is_err() && proxies.value().await.is_ok() {
677        return Err(anyhow!(
678            "element exposes the AT-SPI Value interface, but the requested value is not numeric"
679        ));
680    }
681
682    Err(anyhow!(
683        "element does not expose AT-SPI Value or EditableText interfaces"
684    ))
685}
686
687async fn connect() -> Result<AccessibilityConnection> {
688    hydrate_session_bus_env();
689    AccessibilityConnection::new()
690        .await
691        .context("failed to connect to AT-SPI bus")
692}
693
694/// Open an `AccessibleProxy` for an object on the a11y bus.
695///
696/// We deliberately avoid `AccessibilityConnection::object_as_accessible` (the
697/// `P2P` trait). For apps that advertise a peer-to-peer bus address it routes
698/// reads over that socket, but for apps that don't (notably GTK4 apps such as
699/// Nautilus / Text Editor / baobab, which don't implement the legacy
700/// `GetApplicationBusAddress`) it falls back to a proxy built with only a path
701/// and *no destination*. On the shared a11y bus that proxy can't address the
702/// app and every call fails with `ServiceUnknown`, which surfaces as an empty
703/// tree (`role: "unknown"`, `child_count: 0`). `as_accessible_proxy` always
704/// pins the destination to the object's bus name, so it works for every app
705/// regardless of P2P support. See issue #31.
706async fn open_accessible<'r>(
707    conn: &AccessibilityConnection,
708    object_ref: &'r ObjectRefOwned,
709) -> Result<AccessibleProxy<'r>, atspi::AtspiError> {
710    object_ref.as_accessible_proxy(conn.connection()).await
711}
712
713async fn registry_children(
714    conn: &AccessibilityConnection,
715    limit: usize,
716    remaining_child_reads: &mut usize,
717) -> Result<Vec<ObjectRefOwned>> {
718    let root = conn
719        .root_accessible_on_registry()
720        .await
721        .context("failed to open AT-SPI registry root")?;
722    let batch = children_up_to(&root, limit, remaining_child_reads)
723        .await
724        .context("failed to read AT-SPI registry children")?;
725    if batch.all_failed() {
726        return Err(anyhow!(
727            "AT-SPI registry reported children, but every indexed child read failed"
728        ));
729    }
730    Ok(batch.items)
731}
732
733struct SelectedRoots {
734    roots: Vec<ObjectRefOwned>,
735    scoped: bool,
736    /// Set only when the roots were chosen because they belong to this pid.
737    pid: Option<u32>,
738}
739
740/// Normalized app-name filter, or `None` when the caller passed nothing usable.
741/// A `None` needle with no pid match means the snapshot covers the whole desktop.
742fn app_name_needle(app_name_or_bundle_identifier: Option<&str>) -> Option<String> {
743    app_name_or_bundle_identifier
744        .map(str::trim)
745        .filter(|value| !value.is_empty())
746        .map(|value| value.to_ascii_lowercase())
747}
748
749async fn select_roots(
750    conn: &AccessibilityConnection,
751    roots: Vec<ObjectRefOwned>,
752    app_name_or_bundle_identifier: Option<&str>,
753    target_pid: Option<u32>,
754    remaining_child_reads: &mut usize,
755) -> SelectedRoots {
756    let needle = app_name_needle(app_name_or_bundle_identifier);
757    let dbus = DBusProxy::new(conn.connection()).await.ok();
758    let mut remaining = roots;
759
760    if let Some(target_pid) = target_pid {
761        let mut pid_and_filter_matches = Vec::new();
762        let mut pid_matches = Vec::new();
763        let mut non_pid_matches = Vec::new();
764
765        for object_ref in remaining {
766            if object_ref_pid(dbus.as_ref(), &object_ref).await == Some(target_pid) {
767                if let Some(needle) = needle.as_deref() {
768                    if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
769                        pid_and_filter_matches.push(object_ref);
770                    } else {
771                        pid_matches.push(object_ref);
772                    }
773                } else {
774                    pid_matches.push(object_ref);
775                }
776            } else {
777                non_pid_matches.push(object_ref);
778            }
779        }
780
781        if !pid_and_filter_matches.is_empty() {
782            return SelectedRoots {
783                roots: pid_and_filter_matches,
784                scoped: true,
785                pid: Some(target_pid),
786            };
787        }
788        if !pid_matches.is_empty() {
789            return SelectedRoots {
790                roots: pid_matches,
791                scoped: true,
792                pid: Some(target_pid),
793            };
794        }
795
796        remaining = non_pid_matches;
797    }
798
799    let Some(needle) = needle.as_deref() else {
800        return SelectedRoots {
801            roots: remaining,
802            scoped: false,
803            pid: None,
804        };
805    };
806
807    let mut selected = Vec::new();
808    for object_ref in remaining {
809        if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
810            selected.push(object_ref);
811        }
812    }
813
814    SelectedRoots {
815        roots: selected,
816        scoped: true,
817        pid: None,
818    }
819}
820
821async fn root_matches(
822    conn: &AccessibilityConnection,
823    object_ref: &ObjectRefOwned,
824    needle: &str,
825    remaining_child_reads: &mut usize,
826) -> bool {
827    let Ok(proxy) = open_accessible(conn, object_ref).await else {
828        return object_ref_id(object_ref)
829            .to_ascii_lowercase()
830            .contains(needle);
831    };
832
833    if proxy_matches(&proxy, object_ref, needle).await {
834        return true;
835    }
836
837    for child_ref in children_up_to(&proxy, ROOT_MATCH_CHILD_LIMIT, remaining_child_reads)
838        .await
839        .map(|batch| batch.items)
840        .unwrap_or_default()
841    {
842        let Ok(child_proxy) = open_accessible(conn, &child_ref).await else {
843            continue;
844        };
845        if proxy_matches(&child_proxy, &child_ref, needle).await {
846            return true;
847        }
848    }
849
850    false
851}
852
853async fn proxy_matches(
854    proxy: &AccessibleProxy<'_>,
855    object_ref: &ObjectRefOwned,
856    needle: &str,
857) -> bool {
858    let name = proxy.name().await.unwrap_or_default();
859    let role = proxy.get_role_name().await.unwrap_or_default();
860    format!("{} {} {}", object_ref_id(object_ref), name, role)
861        .to_ascii_lowercase()
862        .contains(needle)
863}
864
865async fn read_app_summary(
866    proxy: &AccessibleProxy<'_>,
867    object_ref: &ObjectRefOwned,
868    dbus: Option<&DBusProxy<'_>>,
869) -> AccessibleAppSummary {
870    AccessibleAppSummary {
871        object_ref: object_ref_id(object_ref),
872        name: optional_string(proxy.name().await.ok()),
873        pid: object_ref_pid(dbus, object_ref).await,
874        role: role_name(proxy).await,
875        child_count: proxy.child_count().await.unwrap_or_default(),
876        bounds: bounds(proxy).await,
877    }
878}
879
880async fn read_node(
881    proxy: &AccessibleProxy<'_>,
882    object_ref: &ObjectRefOwned,
883    index: u32,
884    parent_index: Option<u32>,
885    depth: u32,
886) -> AccessibilityNode {
887    let proxies = proxy.proxies().await.ok();
888
889    AccessibilityNode {
890        index,
891        parent_index,
892        depth,
893        object_ref: object_ref_id(object_ref),
894        role: role_name(proxy).await,
895        name: optional_string(proxy.name().await.ok()),
896        description: optional_string(proxy.description().await.ok()),
897        child_count: proxy.child_count().await.unwrap_or_default(),
898        bounds: bounds_from_proxies(proxies.as_ref(), proxy).await,
899        states: states_from_proxy(proxy).await,
900        actions: actions_from_proxies(proxies.as_ref()).await,
901        value: value_from_proxies(proxies.as_ref()).await,
902        text: text_from_proxies(proxies.as_ref()).await,
903        supports_editable_text: supports_editable_text(proxies.as_ref()).await,
904    }
905}
906
907/// Role reported when neither AT-SPI role read succeeds.
908pub(crate) const UNKNOWN_ROLE: &str = "unknown";
909
910async fn role_name(proxy: &AccessibleProxy<'_>) -> String {
911    if let Ok(role) = proxy.get_role_name().await {
912        if !role.trim().is_empty() {
913            return role;
914        }
915    }
916    proxy
917        .get_role()
918        .await
919        .map(|role| format!("{role:?}"))
920        .unwrap_or_else(|_| UNKNOWN_ROLE.to_string())
921}
922
923async fn bounds(proxy: &AccessibleProxy<'_>) -> Option<Bounds> {
924    bounds_from_proxies(proxy.proxies().await.ok().as_ref(), proxy).await
925}
926
927/// Pid of the process that owns an AT-SPI object ref (`:bus/path`), from the
928/// accessibility bus. `None` when the owner is gone or the bus cannot say.
929pub(crate) async fn object_ref_owner_pid(object_ref_id: &str) -> Result<Option<u32>> {
930    let object_ref = object_ref_from_id(object_ref_id)?;
931    let conn = connect().await?;
932    let dbus = DBusProxy::new(conn.connection()).await.ok();
933    Ok(object_ref_pid(dbus.as_ref(), &object_ref).await)
934}
935
936async fn object_ref_pid(dbus: Option<&DBusProxy<'_>>, object_ref: &ObjectRefOwned) -> Option<u32> {
937    let dbus = dbus?;
938    let bus_name = BusName::try_from(object_ref.name_as_str()?.to_string()).ok()?;
939    dbus.get_connection_unix_process_id(bus_name).await.ok()
940}
941
942async fn bounds_from_proxies(
943    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
944    proxy: &AccessibleProxy<'_>,
945) -> Option<Bounds> {
946    let owned_proxies;
947    let proxies = if let Some(proxies) = proxies {
948        proxies
949    } else {
950        owned_proxies = proxy.proxies().await.ok()?;
951        &owned_proxies
952    };
953    let component = proxies.component().await.ok()?;
954    let (x, y, width, height) = component.get_extents(CoordType::Screen).await.ok()?;
955    normalize_bounds(Bounds {
956        x,
957        y,
958        width,
959        height,
960    })
961}
962
963fn normalize_bounds(bounds: Bounds) -> Option<Bounds> {
964    if bounds.width <= 0 || bounds.height <= 0 {
965        return None;
966    }
967    if bounds.x <= i32::MIN / 2 || bounds.y <= i32::MIN / 2 {
968        return None;
969    }
970    Some(bounds)
971}
972
973async fn actions_from_proxies(
974    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
975) -> Vec<AccessibilityAction> {
976    let Some(proxies) = proxies else {
977        return Vec::new();
978    };
979    let Ok(action_proxy) = proxies.action().await else {
980        return Vec::new();
981    };
982
983    read_actions(&action_proxy)
984        .await
985        .unwrap_or_default()
986        .into_iter()
987        .enumerate()
988        .map(|(index, action)| AccessibilityAction {
989            index: index as i32,
990            name: action.name,
991            description: action.description,
992            keybinding: action.keybinding,
993        })
994        .collect()
995}
996
997async fn read_actions(proxy: &ActionProxy<'_>) -> Result<Vec<atspi::Action>> {
998    // WebKit can advertise GetActions without replying to it. The indexed
999    // methods work, and must also be used when revalidating a cached action.
1000    // Keep the wire spelling: the current atspi binding asks for "Nactions".
1001    timeout(ACTION_QUERY_TIMEOUT, async {
1002        let count = proxy
1003            .inner()
1004            .get_property::<i32>("NActions")
1005            .await
1006            .context("failed to read AT-SPI action count")?;
1007        if count > MAX_ACTIONS_PER_OBJECT {
1008            return Err(anyhow!(
1009                "AT-SPI action count {count} exceeds the {MAX_ACTIONS_PER_OBJECT}-action read budget"
1010            ));
1011        }
1012        if count < 0 {
1013            return Err(anyhow!("invalid negative AT-SPI action count {count}"));
1014        }
1015        Ok(stream::iter(0..count)
1016            .map(|index| async move {
1017                let (name, description, keybinding) = tokio::join!(
1018                    proxy.get_localized_name(index),
1019                    proxy.get_description(index),
1020                    proxy.get_key_binding(index),
1021                );
1022                atspi::Action {
1023                    name: name.unwrap_or_default(),
1024                    description: description.unwrap_or_default(),
1025                    keybinding: keybinding.unwrap_or_default(),
1026                }
1027            })
1028            .buffered(CHILD_READ_CONCURRENCY)
1029            .collect()
1030            .await)
1031    })
1032    .await
1033    .context("AT-SPI action reads exceeded their 5-second deadline")?
1034}
1035
1036async fn states_from_proxy(proxy: &AccessibleProxy<'_>) -> Vec<String> {
1037    proxy
1038        .get_state()
1039        .await
1040        .map(state_labels)
1041        .unwrap_or_default()
1042}
1043
1044async fn value_from_proxies(
1045    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
1046) -> Option<AccessibilityValue> {
1047    let value = proxies?.value().await.ok()?;
1048    Some(AccessibilityValue {
1049        current: value.current_value().await.ok()?,
1050        minimum: value.minimum_value().await.ok()?,
1051        maximum: value.maximum_value().await.ok()?,
1052        minimum_increment: value.minimum_increment().await.ok()?,
1053        text: optional_string(value.text().await.ok()),
1054    })
1055}
1056
1057async fn text_from_proxies(
1058    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
1059) -> Option<AccessibilityText> {
1060    let text = proxies?.text().await.ok()?;
1061    let character_count = text.character_count().await.ok()?.max(0);
1062    let caret_offset = text.caret_offset().await.ok();
1063    let capped_count = character_count.min(MAX_TEXT_READBACK_CHARS);
1064    let content = if capped_count > 0 {
1065        optional_string(text.get_text(0, capped_count).await.ok())
1066    } else {
1067        None
1068    };
1069    let selection_count = text
1070        .get_nselections()
1071        .await
1072        .unwrap_or_default()
1073        .clamp(0, MAX_TEXT_SELECTIONS);
1074    let mut selections = Vec::new();
1075    for index in 0..selection_count {
1076        if let Ok((start_offset, end_offset)) = text.get_selection(index).await {
1077            selections.push(AccessibilityTextSelection {
1078                start_offset,
1079                end_offset,
1080            });
1081        }
1082    }
1083
1084    Some(AccessibilityText {
1085        character_count,
1086        caret_offset,
1087        content,
1088        truncated: character_count > MAX_TEXT_READBACK_CHARS,
1089        selections,
1090    })
1091}
1092
1093async fn supports_editable_text(proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>) -> bool {
1094    let Some(proxies) = proxies else {
1095        return false;
1096    };
1097    proxies.editable_text().await.is_ok()
1098}
1099
1100fn state_labels(state_set: StateSet) -> Vec<String> {
1101    state_set.iter().map(|state| state.to_string()).collect()
1102}
1103
1104fn select_action_index(actions: &[atspi::Action], requested_action: Option<&str>) -> Result<i32> {
1105    if actions.is_empty() {
1106        return Err(anyhow!("element exposes no AT-SPI actions"));
1107    }
1108
1109    if let Some(requested_action) = requested_action
1110        .map(str::trim)
1111        .filter(|value| !value.is_empty())
1112    {
1113        let requested_action = requested_action.to_ascii_lowercase();
1114        if let Some((index, _)) = actions.iter().enumerate().find(|(_, action)| {
1115            action.name.to_ascii_lowercase() == requested_action
1116                || action.description.to_ascii_lowercase() == requested_action
1117        }) {
1118            return Ok(index as i32);
1119        }
1120
1121        if let Ok(index) = requested_action.parse::<usize>() {
1122            if index < actions.len() {
1123                return Ok(index as i32);
1124            }
1125        }
1126
1127        return Err(anyhow!(
1128            "requested AT-SPI action was not found; available actions: {}",
1129            actions
1130                .iter()
1131                .map(|action| action.name.as_str())
1132                .collect::<Vec<_>>()
1133                .join(", ")
1134        ));
1135    }
1136
1137    Ok(if actions.len() > 1 { 1 } else { 0 })
1138}
1139
1140fn select_named_action_index(actions: &[atspi::Action], name: &str) -> Result<i32> {
1141    actions
1142        .iter()
1143        .position(|action| action.name.eq_ignore_ascii_case(name))
1144        .map(|index| index as i32)
1145        .ok_or_else(|| {
1146            anyhow!("cached AT-SPI action name is no longer available; refresh get_app_state")
1147        })
1148}
1149
1150fn optional_string(value: Option<String>) -> Option<String> {
1151    value
1152        .map(|value| value.trim().to_string())
1153        .filter(|value| !value.is_empty())
1154}
1155
1156fn object_ref_from_id(object_ref_id: &str) -> Result<ObjectRefOwned> {
1157    let (name, path) = split_object_ref_id(object_ref_id)?;
1158    let name = UniqueName::try_from(name.to_string())
1159        .with_context(|| format!("invalid AT-SPI bus name in object ref {object_ref_id}"))?;
1160    let path = ObjectPath::try_from(path.to_string())
1161        .with_context(|| format!("invalid AT-SPI object path in object ref {object_ref_id}"))?;
1162    Ok(ObjectRef::new_owned(name, path))
1163}
1164
1165fn split_object_ref_id(object_ref_id: &str) -> Result<(&str, &str)> {
1166    let Some(path_start) = object_ref_id.find('/') else {
1167        return Err(anyhow!(
1168            "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
1169        ));
1170    };
1171    let (name, path) = object_ref_id.split_at(path_start);
1172    if name.is_empty() || path.is_empty() {
1173        return Err(anyhow!(
1174            "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
1175        ));
1176    }
1177    Ok((name, path))
1178}
1179
1180fn object_ref_id(object_ref: &ObjectRefOwned) -> String {
1181    format!(
1182        "{}{}",
1183        object_ref.name_as_str().unwrap_or(""),
1184        object_ref.path_as_str()
1185    )
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190    use super::*;
1191    use std::{
1192        io::{BufRead, BufReader},
1193        process::{Child, Command, Stdio},
1194        sync::{
1195            atomic::{AtomicUsize, Ordering},
1196            Arc,
1197        },
1198    };
1199
1200    struct PrivateBus(Child);
1201
1202    impl PrivateBus {
1203        fn start() -> (Self, String) {
1204            let mut bus = Self(
1205                Command::new("dbus-daemon")
1206                    .args(["--session", "--nofork", "--nopidfile", "--print-address=1"])
1207                    .stdout(Stdio::piped())
1208                    .spawn()
1209                    .unwrap(),
1210            );
1211            let mut address = String::new();
1212            BufReader::new(bus.0.stdout.take().unwrap())
1213                .read_line(&mut address)
1214                .unwrap();
1215            (bus, address.trim().to_string())
1216        }
1217    }
1218
1219    impl Drop for PrivateBus {
1220        fn drop(&mut self) {
1221            let _ = self.0.kill();
1222            let _ = self.0.wait();
1223        }
1224    }
1225
1226    struct FakeAccessible {
1227        name: String,
1228        children: Vec<(String, OwnedObjectPath)>,
1229        has_action: bool,
1230    }
1231
1232    #[zbus::interface(name = "org.a11y.atspi.Accessible")]
1233    impl FakeAccessible {
1234        #[zbus(property)]
1235        fn child_count(&self) -> i32 {
1236            self.children.len() as i32
1237        }
1238
1239        #[zbus(property)]
1240        fn name(&self) -> &str {
1241            &self.name
1242        }
1243
1244        fn get_interfaces(&self) -> Vec<&str> {
1245            let mut interfaces = vec!["org.a11y.atspi.Accessible"];
1246            if self.has_action {
1247                interfaces.push("org.a11y.atspi.Action");
1248            }
1249            interfaces
1250        }
1251
1252        fn get_child_at_index(
1253            &self,
1254            index: i32,
1255        ) -> zbus::fdo::Result<((String, OwnedObjectPath),)> {
1256            self.children
1257                .get(index as usize)
1258                .cloned()
1259                .map(|child| (child,))
1260                .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid child index".into()))
1261        }
1262    }
1263
1264    struct FakeWebKitAction {
1265        count: i32,
1266        stall_indexed_reads: bool,
1267        name_reads: Arc<AtomicUsize>,
1268    }
1269
1270    impl Default for FakeWebKitAction {
1271        fn default() -> Self {
1272            Self {
1273                count: 1,
1274                stall_indexed_reads: false,
1275                name_reads: Arc::default(),
1276            }
1277        }
1278    }
1279
1280    #[zbus::interface(name = "org.a11y.atspi.Action")]
1281    impl FakeWebKitAction {
1282        async fn get_actions(&self) -> Vec<atspi::Action> {
1283            std::future::pending().await
1284        }
1285
1286        #[zbus(property, name = "NActions")]
1287        fn nactions(&self) -> i32 {
1288            self.count
1289        }
1290
1291        fn get_name(&self, index: i32) -> &str {
1292            assert!((0..self.count).contains(&index));
1293            "click"
1294        }
1295
1296        async fn get_localized_name(&self, index: i32) -> &str {
1297            assert!((0..self.count).contains(&index));
1298            self.name_reads.fetch_add(1, Ordering::SeqCst);
1299            if self.stall_indexed_reads {
1300                std::future::pending::<()>().await;
1301            }
1302            "Activate"
1303        }
1304
1305        fn get_description(&self, index: i32) -> &str {
1306            assert!((0..self.count).contains(&index));
1307            "Activate the control"
1308        }
1309
1310        fn get_key_binding(&self, index: i32) -> &str {
1311            assert!((0..self.count).contains(&index));
1312            "Return"
1313        }
1314
1315        fn do_action(&self, index: i32) -> bool {
1316            index == 0
1317        }
1318    }
1319
1320    #[tokio::test]
1321    async fn indexed_children_resolve_webkit_names_on_the_accessibility_bus() {
1322        let (_bus, address) = PrivateBus::start();
1323
1324        let webkit_name = "org.webkit.MockWebProcess";
1325        let child_path = "/org/a11y/webkit/accessible/child";
1326        let webkit = zbus::connection::Builder::address(address.trim())
1327            .unwrap()
1328            .name(webkit_name)
1329            .unwrap()
1330            .serve_at(
1331                child_path,
1332                FakeAccessible {
1333                    name: "WebKit page".into(),
1334                    children: Vec::new(),
1335                    has_action: true,
1336                },
1337            )
1338            .unwrap()
1339            .serve_at(child_path, FakeWebKitAction::default())
1340            .unwrap()
1341            .build()
1342            .await
1343            .unwrap();
1344        let owner = webkit.unique_name().unwrap().to_string();
1345        let gtk = zbus::connection::Builder::address(address.trim())
1346            .unwrap()
1347            .serve_at(
1348                "/org/a11y/atspi/accessible/root",
1349                FakeAccessible {
1350                    name: "GTK socket".into(),
1351                    has_action: false,
1352                    children: vec![
1353                        (webkit_name.into(), child_path.try_into().unwrap()),
1354                        (owner.clone(), child_path.try_into().unwrap()),
1355                        (String::new(), "/org/a11y/atspi/null".try_into().unwrap()),
1356                        (
1357                            "org.webkit.UnownedWebProcess".into(),
1358                            child_path.try_into().unwrap(),
1359                        ),
1360                    ],
1361                },
1362            )
1363            .unwrap()
1364            .build()
1365            .await
1366            .unwrap();
1367        let client = zbus::connection::Builder::address(address.trim())
1368            .unwrap()
1369            .build()
1370            .await
1371            .unwrap();
1372        let proxy = AccessibleProxy::builder(&client)
1373            .destination(gtk.unique_name().unwrap().clone())
1374            .unwrap()
1375            .path("/org/a11y/atspi/accessible/root")
1376            .unwrap()
1377            .build()
1378            .await
1379            .unwrap();
1380        let mut remaining = 4;
1381        let reply = proxy
1382            .inner()
1383            .call_method("GetChildAtIndex", &(0_i32,))
1384            .await
1385            .unwrap();
1386        assert_eq!(reply.body().signature().to_string(), "(so)");
1387        let batch = children_up_to(&proxy, 4, &mut remaining).await.unwrap();
1388
1389        assert_eq!(batch.attempted, 4);
1390        assert_eq!(remaining, 0);
1391        assert!(!batch.incomplete);
1392        assert_eq!(batch.items.len(), 3, "only the unowned name should fail");
1393        assert_eq!(batch.items[0].name_as_str(), Some(owner.as_str()));
1394        assert_eq!(batch.items[0].path_as_str(), child_path);
1395        assert_eq!(batch.items[0], batch.items[1]);
1396        assert!(batch.items[2].is_null());
1397
1398        // The canonical reference remains usable by the normal read/action path.
1399        let child = batch.items[0].as_accessible_proxy(&client).await.unwrap();
1400        assert_eq!(child.name().await.unwrap(), "WebKit page");
1401        assert_eq!(
1402            object_ref_from_id(&object_ref_id(&batch.items[0])).unwrap(),
1403            batch.items[0]
1404        );
1405        let action = child.proxies().await.unwrap().action().await.unwrap();
1406        let actions = timeout(Duration::from_secs(1), read_actions(&action))
1407            .await
1408            .expect("indexed action reads must not wait for GetActions")
1409            .unwrap();
1410        assert_eq!(actions.len(), 1);
1411        assert_eq!(actions[0].name, "Activate");
1412        assert_eq!(actions[0].description, "Activate the control");
1413        assert_eq!(actions[0].keybinding, "Return");
1414        assert_eq!(select_named_action_index(&actions, "Activate").unwrap(), 0);
1415        assert!(action.do_action(0).await.unwrap());
1416    }
1417
1418    #[tokio::test]
1419    async fn indexed_actions_bound_untrusted_counts_before_reading_indices() {
1420        let (_bus, address) = PrivateBus::start();
1421        let name_reads = Arc::new(AtomicUsize::new(0));
1422        let counts = [
1423            -1,
1424            MAX_ACTIONS_PER_OBJECT + 1,
1425            i32::MAX,
1426            0,
1427            MAX_ACTIONS_PER_OBJECT,
1428        ];
1429        let mut builder = zbus::connection::Builder::address(address.as_str()).unwrap();
1430        for (index, count) in counts.iter().enumerate() {
1431            builder = builder
1432                .serve_at(
1433                    format!("/actions/{index}"),
1434                    FakeWebKitAction {
1435                        count: *count,
1436                        name_reads: Arc::clone(&name_reads),
1437                        ..Default::default()
1438                    },
1439                )
1440                .unwrap();
1441        }
1442        let peer = builder.build().await.unwrap();
1443        let client = zbus::connection::Builder::address(address.as_str())
1444            .unwrap()
1445            .build()
1446            .await
1447            .unwrap();
1448
1449        for (index, count) in counts.iter().enumerate() {
1450            let action = ActionProxy::builder(&client)
1451                .destination(peer.unique_name().unwrap().clone())
1452                .unwrap()
1453                .path(format!("/actions/{index}"))
1454                .unwrap()
1455                .cache_properties(zbus::proxy::CacheProperties::No)
1456                .build()
1457                .await
1458                .unwrap();
1459            let result = timeout(Duration::from_secs(1), read_actions(&action))
1460                .await
1461                .expect("untrusted counts must not cause unbounded work");
1462            if (0..=MAX_ACTIONS_PER_OBJECT).contains(count) {
1463                assert_eq!(result.unwrap().len(), *count as usize);
1464            } else {
1465                let error = result.unwrap_err().to_string();
1466                assert!(error.contains("action count"), "{error}");
1467                assert_eq!(name_reads.load(Ordering::SeqCst), 0);
1468            }
1469        }
1470        assert_eq!(
1471            name_reads.load(Ordering::SeqCst),
1472            MAX_ACTIONS_PER_OBJECT as usize
1473        );
1474    }
1475
1476    #[tokio::test]
1477    async fn indexed_actions_time_out_the_entire_read() {
1478        let (_bus, address) = PrivateBus::start();
1479        let name_reads = Arc::new(AtomicUsize::new(0));
1480        let peer = zbus::connection::Builder::address(address.as_str())
1481            .unwrap()
1482            .serve_at(
1483                "/actions/stalled",
1484                FakeWebKitAction {
1485                    stall_indexed_reads: true,
1486                    name_reads: Arc::clone(&name_reads),
1487                    ..Default::default()
1488                },
1489            )
1490            .unwrap()
1491            .build()
1492            .await
1493            .unwrap();
1494        let client = zbus::connection::Builder::address(address.as_str())
1495            .unwrap()
1496            .build()
1497            .await
1498            .unwrap();
1499        let action = ActionProxy::builder(&client)
1500            .destination(peer.unique_name().unwrap().clone())
1501            .unwrap()
1502            .path("/actions/stalled")
1503            .unwrap()
1504            .cache_properties(zbus::proxy::CacheProperties::No)
1505            .build()
1506            .await
1507            .unwrap();
1508
1509        let error = timeout(
1510            ACTION_QUERY_TIMEOUT + Duration::from_secs(1),
1511            read_actions(&action),
1512        )
1513        .await
1514        .expect("indexed reads must have an overall deadline")
1515        .unwrap_err();
1516        assert!(error.to_string().contains("5-second deadline"));
1517        assert_eq!(name_reads.load(Ordering::SeqCst), 1);
1518    }
1519
1520    #[test]
1521    fn split_object_ref_id_separates_bus_name_and_path() {
1522        let (name, path) = split_object_ref_id(":1.42/org/a11y/atspi/accessible/7").unwrap();
1523
1524        assert_eq!(name, ":1.42");
1525        assert_eq!(path, "/org/a11y/atspi/accessible/7");
1526    }
1527
1528    #[test]
1529    fn select_action_index_uses_named_action() {
1530        let actions = vec![
1531            atspi::Action {
1532                name: "click".to_string(),
1533                description: "Clicks".to_string(),
1534                keybinding: String::new(),
1535            },
1536            atspi::Action {
1537                name: "show-menu".to_string(),
1538                description: "Shows menu".to_string(),
1539                keybinding: String::new(),
1540            },
1541        ];
1542
1543        assert_eq!(select_action_index(&actions, Some("show-menu")).unwrap(), 1);
1544    }
1545
1546    #[test]
1547    fn cached_action_name_is_revalidated_against_current_actions() {
1548        let mut actions = vec![atspi::Action {
1549            name: "show-menu".into(),
1550            description: "Click".into(),
1551            keybinding: String::new(),
1552        }];
1553        // Neither a stale index nor a matching description may invoke another action.
1554        assert!(select_named_action_index(&actions, "Click").is_err());
1555        actions.push(atspi::Action {
1556            name: "click".into(),
1557            description: String::new(),
1558            keybinding: String::new(),
1559        });
1560        assert_eq!(select_named_action_index(&actions, "Click").unwrap(), 1);
1561    }
1562
1563    #[test]
1564    fn select_action_index_defaults_to_secondary_when_available() {
1565        let actions = vec![
1566            atspi::Action {
1567                name: "click".to_string(),
1568                description: String::new(),
1569                keybinding: String::new(),
1570            },
1571            atspi::Action {
1572                name: "show-menu".to_string(),
1573                description: String::new(),
1574                keybinding: String::new(),
1575            },
1576        ];
1577
1578        assert_eq!(select_action_index(&actions, None).unwrap(), 1);
1579    }
1580
1581    #[test]
1582    fn state_labels_serialize_in_bit_order() {
1583        let labels = state_labels(StateSet::new(atspi::State::Focused | atspi::State::Checked));
1584
1585        assert_eq!(labels, vec!["checked".to_string(), "focused".to_string()]);
1586    }
1587
1588    #[test]
1589    fn default_snapshot_limits_cover_deep_gtk4_trees() {
1590        // Nautilus 50 places file-list cells below depth 20 and can expose
1591        // more than 850 raw nodes. Keep the defaults above that known shape.
1592        assert_eq!(snapshot_limits(None, None), (1_000, 32));
1593    }
1594
1595    #[test]
1596    fn app_name_needle_ignores_blank_values_and_normalizes_the_rest() {
1597        assert_eq!(app_name_needle(None), None);
1598        assert_eq!(app_name_needle(Some("")), None);
1599        assert_eq!(app_name_needle(Some("   ")), None);
1600        assert_eq!(
1601            app_name_needle(Some("Calculator")).as_deref(),
1602            Some("calculator")
1603        );
1604        assert_eq!(
1605            app_name_needle(Some(" :1.64/org/a11y/atspi/accessible/root ")).as_deref(),
1606            Some(":1.64/org/a11y/atspi/accessible/root")
1607        );
1608    }
1609
1610    #[test]
1611    fn requested_snapshot_limits_remain_bounded() {
1612        assert_eq!(snapshot_limits(Some(0), Some(0)), (1, 0));
1613        assert_eq!(snapshot_limits(Some(10_000), Some(128)), (2_000, 64));
1614    }
1615
1616    #[test]
1617    fn app_discovery_budget_is_independent_of_requested_tree_size() {
1618        assert_eq!(snapshot_child_read_budgets(1), (256, 2_048, 1));
1619    }
1620
1621    #[test]
1622    fn traversal_attempts_and_queue_share_one_work_budget() {
1623        let mut traversal = BoundedTraversal::new(4);
1624        traversal.enqueue([1]);
1625
1626        assert_eq!(traversal.pop(), Some(1));
1627        traversal.enqueue(2..=10_000);
1628        assert_eq!(traversal.queue, VecDeque::from([2, 3, 4]));
1629
1630        assert_eq!(traversal.pop(), Some(2));
1631        traversal.enqueue(5..=10_000);
1632        assert_eq!(traversal.queue, VecDeque::from([3, 4]));
1633        assert_eq!(traversal.pop(), Some(3));
1634        assert_eq!(traversal.pop(), Some(4));
1635        assert_eq!(traversal.pop(), None);
1636        assert_eq!(traversal.attempted, 4);
1637        assert!(
1638            traversal.truncated(),
1639            "dropped enqueue items must be reported"
1640        );
1641    }
1642
1643    #[test]
1644    fn traversal_that_drains_within_budget_is_not_truncated() {
1645        let mut traversal = BoundedTraversal::new(3);
1646        traversal.enqueue([1, 2, 3]);
1647        assert_eq!(traversal.pop(), Some(1));
1648        assert_eq!(traversal.pop(), Some(2));
1649        assert_eq!(traversal.pop(), Some(3));
1650        assert_eq!(traversal.pop(), None);
1651        assert!(
1652            !traversal.truncated(),
1653            "exactly max_items real nodes is complete"
1654        );
1655    }
1656
1657    #[test]
1658    fn enqueue_at_zero_capacity_marks_the_traversal_truncated() {
1659        let mut traversal = BoundedTraversal::new(2);
1660        traversal.enqueue([1, 2]);
1661        assert_eq!(traversal.pop(), Some(1));
1662        assert_eq!(traversal.pop(), Some(2));
1663        assert!(!traversal.truncated(), "nothing dropped yet");
1664        // Capacity is zero; the offered item is dropped, which is the only
1665        // path that can mark truncation.
1666        traversal.enqueue([3]);
1667        assert!(traversal.queue.is_empty());
1668        assert_eq!(traversal.pop(), None);
1669        assert!(traversal.truncated());
1670    }
1671
1672    #[test]
1673    fn child_count_is_clamped_before_indexed_reads() {
1674        assert_eq!(bounded_child_count(-1, 4), 0);
1675        assert_eq!(bounded_child_count(3, 4), 3);
1676        assert_eq!(bounded_child_count(i32::MAX, 4), 4);
1677    }
1678
1679    #[tokio::test]
1680    async fn indexed_child_reads_consume_attempts_even_when_one_fails() {
1681        let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1682        let mut remaining_attempts = 3;
1683        let batch = fetch_indexed_up_to(100, 10, &mut remaining_attempts, {
1684            let calls = calls.clone();
1685            move |index| {
1686                let calls = calls.clone();
1687                async move {
1688                    calls.lock().unwrap().push(index);
1689                    if index == 1 {
1690                        Err(())
1691                    } else {
1692                        Ok(index)
1693                    }
1694                }
1695            }
1696        })
1697        .await;
1698
1699        assert_eq!(batch.items, vec![0, 2]);
1700        assert_eq!(batch.attempted, 3);
1701        assert!(
1702            batch.incomplete,
1703            "100 reported children, 3 attempted: budget cut must be reported"
1704        );
1705        assert!(!batch.all_failed());
1706        assert_eq!(*calls.lock().unwrap(), vec![0, 1, 2]);
1707        assert_eq!(remaining_attempts, 0);
1708
1709        let no_children = fetch_indexed_up_to(100, 10, &mut remaining_attempts, |_| async {
1710            Ok::<_, ()>(99)
1711        })
1712        .await;
1713        assert!(no_children.items.is_empty());
1714        assert_eq!(no_children.attempted, 0);
1715        assert!(!no_children.all_failed());
1716
1717        let mut failed_attempts = 2;
1718        let all_failed =
1719            fetch_indexed_up_to(2, 2, &mut failed_attempts, |_| async { Err::<i32, _>(()) }).await;
1720        assert!(all_failed.all_failed());
1721    }
1722}