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        proxy_ext::ProxyExt,
7    },
8    CoordType, ObjectRef, ObjectRefOwned, StateSet,
9};
10// Direct dependency (p2p feature off) — see Cargo.toml for why we bypass
11// atspi's "connection" re-export.
12use atspi_connection::AccessibilityConnection;
13use futures_util::{stream, StreamExt};
14use schemars::JsonSchema;
15use serde::Serialize;
16use std::{collections::VecDeque, future::Future, time::Duration};
17use tokio::time::timeout;
18use zbus::{
19    fdo::DBusProxy,
20    names::{BusName, UniqueName},
21    zvariant::ObjectPath,
22};
23
24#[derive(Debug, Clone, Serialize, JsonSchema)]
25pub struct AccessibleAppSummary {
26    pub object_ref: String,
27    pub name: Option<String>,
28    pub pid: Option<u32>,
29    pub role: String,
30    pub child_count: i32,
31    pub bounds: Option<Bounds>,
32}
33
34#[derive(Debug, Clone, Serialize, JsonSchema)]
35pub struct AccessibilityNode {
36    pub index: u32,
37    pub parent_index: Option<u32>,
38    pub depth: u32,
39    pub object_ref: String,
40    pub role: String,
41    pub name: Option<String>,
42    pub description: Option<String>,
43    pub child_count: i32,
44    pub bounds: Option<Bounds>,
45    pub states: Vec<String>,
46    pub actions: Vec<AccessibilityAction>,
47    pub value: Option<AccessibilityValue>,
48    pub text: Option<AccessibilityText>,
49    pub supports_editable_text: bool,
50}
51
52#[derive(Debug, Clone, Serialize, JsonSchema)]
53pub struct Bounds {
54    pub x: i32,
55    pub y: i32,
56    pub width: i32,
57    pub height: i32,
58}
59
60#[derive(Debug, Clone, Serialize, JsonSchema)]
61pub struct AccessibilityAction {
62    pub index: i32,
63    pub name: String,
64    pub description: String,
65    pub keybinding: String,
66}
67
68#[derive(Debug, Clone, Serialize, JsonSchema)]
69pub struct AccessibilityValue {
70    pub current: f64,
71    pub minimum: f64,
72    pub maximum: f64,
73    pub minimum_increment: f64,
74    pub text: Option<String>,
75}
76
77#[derive(Debug, Clone, Serialize, JsonSchema)]
78pub struct AccessibilityText {
79    pub character_count: i32,
80    pub caret_offset: Option<i32>,
81    pub content: Option<String>,
82    pub truncated: bool,
83    pub selections: Vec<AccessibilityTextSelection>,
84}
85
86#[derive(Debug, Clone, Serialize, JsonSchema)]
87pub struct AccessibilityTextSelection {
88    pub start_offset: i32,
89    pub end_offset: i32,
90}
91
92#[derive(Debug, Clone)]
93pub struct ActionInvocation {
94    pub action_index: i32,
95    pub action_name: Option<String>,
96    pub ok: bool,
97}
98
99#[derive(Debug, Clone)]
100pub enum ValueSetInvocation {
101    Numeric { value: f64 },
102    EditableText,
103}
104
105const MAX_TEXT_READBACK_CHARS: i32 = 4096;
106const MAX_TEXT_SELECTIONS: i32 = 8;
107const DEFAULT_SNAPSHOT_MAX_NODES: usize = 1_000;
108const HARD_SNAPSHOT_MAX_NODES: usize = 2_000;
109const DEFAULT_SNAPSHOT_MAX_DEPTH: u32 = 32;
110const HARD_SNAPSHOT_MAX_DEPTH: u32 = 64;
111const CHILD_READ_CONCURRENCY: usize = 16;
112const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
113const MAX_DISCOVERY_ROOTS: usize = 256;
114const ROOT_MATCH_CHILD_LIMIT: usize = 8;
115const MAX_DISCOVERY_CHILD_READS: usize = MAX_DISCOVERY_ROOTS * ROOT_MATCH_CHILD_LIMIT;
116
117fn snapshot_child_read_budgets(max_nodes: usize) -> (usize, usize, usize) {
118    (MAX_DISCOVERY_ROOTS, MAX_DISCOVERY_CHILD_READS, max_nodes)
119}
120
121pub(crate) fn snapshot_limits(
122    requested_max_nodes: Option<usize>,
123    requested_max_depth: Option<u32>,
124) -> (usize, u32) {
125    (
126        requested_max_nodes
127            .unwrap_or(DEFAULT_SNAPSHOT_MAX_NODES)
128            .clamp(1, HARD_SNAPSHOT_MAX_NODES),
129        requested_max_depth
130            .unwrap_or(DEFAULT_SNAPSHOT_MAX_DEPTH)
131            .min(HARD_SNAPSHOT_MAX_DEPTH),
132    )
133}
134
135struct BoundedTraversal<T> {
136    queue: VecDeque<T>,
137    attempted: usize,
138    max_items: usize,
139    /// Set once an enqueue had to drop offered items for lack of capacity.
140    dropped: bool,
141}
142
143impl<T> BoundedTraversal<T> {
144    fn new(max_items: usize) -> Self {
145        Self {
146            queue: VecDeque::new(),
147            attempted: 0,
148            max_items,
149            dropped: false,
150        }
151    }
152
153    fn enqueue(&mut self, items: impl IntoIterator<Item = T>) {
154        let capacity = self.remaining_capacity();
155        let mut items = items.into_iter();
156        self.queue.extend(items.by_ref().take(capacity));
157        if items.next().is_some() {
158            self.dropped = true;
159        }
160    }
161
162    fn pop(&mut self) -> Option<T> {
163        if self.attempted >= self.max_items {
164            return None;
165        }
166        let item = self.queue.pop_front()?;
167        self.attempted += 1;
168        Some(item)
169    }
170
171    fn remaining_capacity(&self) -> usize {
172        self.max_items
173            .saturating_sub(self.attempted.saturating_add(self.queue.len()))
174    }
175
176    /// True when the budget stopped work that was actually offered. `enqueue`
177    /// is the only gate: it never exceeds `remaining_capacity`, so
178    /// `attempted + queue.len() <= max_items` always holds and the queue can
179    /// never be non-empty at the attempt cap. Dropped items are the whole story.
180    fn truncated(&self) -> bool {
181        self.dropped
182    }
183}
184
185fn bounded_child_count(reported: i32, limit: usize) -> usize {
186    usize::try_from(reported).unwrap_or_default().min(limit)
187}
188
189struct IndexedReadBatch<T> {
190    items: Vec<T>,
191    attempted: usize,
192    /// True when fewer children were attempted than the parent reported, because
193    /// the caller's limit or the shared read budget ran out. Failed reads do not
194    /// count; they were attempted.
195    incomplete: bool,
196}
197
198impl<T> IndexedReadBatch<T> {
199    fn all_failed(&self) -> bool {
200        self.attempted > 0 && self.items.is_empty()
201    }
202}
203
204async fn fetch_indexed_up_to<T, E, F, Fut>(
205    reported: i32,
206    limit: usize,
207    remaining_attempts: &mut usize,
208    fetch: F,
209) -> IndexedReadBatch<T>
210where
211    F: Fn(i32) -> Fut,
212    Fut: Future<Output = std::result::Result<T, E>>,
213{
214    let attempt_count = bounded_child_count(reported, limit).min(*remaining_attempts);
215    *remaining_attempts = (*remaining_attempts).saturating_sub(attempt_count);
216    let end_index = i32::try_from(attempt_count).unwrap_or(i32::MAX);
217
218    let items = stream::iter(0..end_index)
219        .map(fetch)
220        .buffered(CHILD_READ_CONCURRENCY)
221        .filter_map(|result| async move { result.ok() })
222        .collect()
223        .await;
224
225    IndexedReadBatch {
226        items,
227        attempted: attempt_count,
228        incomplete: attempt_count < usize::try_from(reported).unwrap_or_default(),
229    }
230}
231
232async fn children_up_to(
233    proxy: &AccessibleProxy<'_>,
234    limit: usize,
235    remaining_attempts: &mut usize,
236) -> zbus::Result<IndexedReadBatch<ObjectRefOwned>> {
237    if limit == 0 || *remaining_attempts == 0 {
238        // Budget already spent: stay I/O-free. `incomplete` is false here because
239        // nothing was asked of the parent; callers that already hold the node's
240        // child_count (snapshot_tree_inner) derive truncation from that instead.
241        return Ok(IndexedReadBatch {
242            items: Vec::new(),
243            attempted: 0,
244            incomplete: false,
245        });
246    }
247
248    let child_count = proxy.child_count().await?;
249    Ok(
250        fetch_indexed_up_to(child_count, limit, remaining_attempts, |index| {
251            proxy.get_child_at_index(index)
252        })
253        .await,
254    )
255}
256
257pub async fn list_accessible_apps(limit: usize) -> Result<Vec<AccessibleAppSummary>> {
258    let conn = connect().await?;
259    let mut remaining_child_reads = limit;
260    let roots = registry_children(&conn, limit, &mut remaining_child_reads).await?;
261    let dbus = DBusProxy::new(conn.connection()).await.ok();
262    let mut apps = Vec::new();
263
264    for object_ref in roots.into_iter().take(limit) {
265        if let Ok(proxy) = open_accessible(&conn, &object_ref).await {
266            apps.push(read_app_summary(&proxy, &object_ref, dbus.as_ref()).await);
267        }
268    }
269
270    Ok(apps)
271}
272
273#[derive(Debug, Clone)]
274pub(crate) struct AccessibilitySnapshot {
275    pub nodes: Vec<AccessibilityNode>,
276    /// True when registry roots were filtered to an app name and/or pid.
277    pub scoped: bool,
278    /// The pid every root belongs to, when roots were matched by pid. None
279    /// when they were matched by app name alone or not filtered at all: those
280    /// roots can belong to any app, so no single pid describes the tree.
281    pub root_pid: Option<u32>,
282    /// True when max_nodes, max_depth, or the child read budget stopped
283    /// traversal with unread elements left. Failed element reads do not count.
284    pub truncated: bool,
285}
286
287pub async fn snapshot_tree(
288    app_name_or_bundle_identifier: Option<&str>,
289    target_pid: Option<u32>,
290    max_nodes: usize,
291    max_depth: u32,
292) -> Result<Vec<AccessibilityNode>> {
293    Ok(snapshot_accessibility_tree(
294        app_name_or_bundle_identifier,
295        target_pid,
296        max_nodes,
297        max_depth,
298    )
299    .await?
300    .nodes)
301}
302
303pub(crate) async fn snapshot_accessibility_tree(
304    app_name_or_bundle_identifier: Option<&str>,
305    target_pid: Option<u32>,
306    max_nodes: usize,
307    max_depth: u32,
308) -> Result<AccessibilitySnapshot> {
309    let (max_nodes, max_depth) = snapshot_limits(Some(max_nodes), Some(max_depth));
310    timeout(
311        SNAPSHOT_TIMEOUT,
312        snapshot_tree_inner(
313            app_name_or_bundle_identifier,
314            target_pid,
315            max_nodes,
316            max_depth,
317        ),
318    )
319    .await
320    .context("AT-SPI snapshot exceeded its 10-second deadline")?
321}
322
323async fn snapshot_tree_inner(
324    app_name_or_bundle_identifier: Option<&str>,
325    target_pid: Option<u32>,
326    max_nodes: usize,
327    max_depth: u32,
328) -> Result<AccessibilitySnapshot> {
329    let conn = connect().await?;
330    // App discovery is bounded independently so a tiny requested tree still
331    // finds a target registered after the first accessibility root.
332    let (mut remaining_registry_reads, mut remaining_filter_reads, mut remaining_traversal_reads) =
333        snapshot_child_read_budgets(max_nodes);
334    let roots =
335        registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
336    let selected_roots = select_roots(
337        &conn,
338        roots,
339        app_name_or_bundle_identifier,
340        target_pid,
341        &mut remaining_filter_reads,
342    )
343    .await;
344    let scoped = selected_roots.scoped;
345    let root_pid = selected_roots.pid;
346    let mut nodes = Vec::new();
347    let mut truncated = false;
348    let mut traversal = BoundedTraversal::new(max_nodes);
349
350    traversal.enqueue(
351        selected_roots
352            .roots
353            .into_iter()
354            .map(|object_ref| (object_ref, 0_u32, None)),
355    );
356
357    while let Some((object_ref, depth, parent_index)) = traversal.pop() {
358        let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
359            continue;
360        };
361        let index = nodes.len() as u32;
362        let remaining = traversal.remaining_capacity();
363        let node = read_node(&proxy, &object_ref, index, parent_index, depth).await;
364        let child_refs = if depth < max_depth && remaining > 0 {
365            match children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await {
366                Ok(batch) => {
367                    // A read-budget cut inside the fetch reports `incomplete`; an
368                    // already-exhausted budget returns the I/O-free empty batch, so
369                    // fall back to the child_count read_node already fetched.
370                    truncated |= batch.incomplete || (batch.attempted == 0 && node.child_count > 0);
371                    batch.items
372                }
373                Err(_) => Vec::new(),
374            }
375        } else {
376            // Depth or node cap reached: any child this node reports is unread.
377            truncated |= node.child_count > 0;
378            Vec::new()
379        };
380
381        nodes.push(node);
382
383        traversal.enqueue(
384            child_refs
385                .into_iter()
386                .map(|child| (child, depth + 1, Some(index))),
387        );
388    }
389    truncated |= traversal.truncated();
390
391    Ok(AccessibilitySnapshot {
392        nodes,
393        scoped,
394        root_pid,
395        truncated,
396    })
397}
398
399/// Compact description of the AT-SPI element that currently holds keyboard
400/// focus, used as post-input feedback for type_text/press_key.
401#[derive(Debug, Clone, Serialize, JsonSchema)]
402pub struct FocusedElementSummary {
403    pub role: String,
404    pub name: Option<String>,
405    pub editable: bool,
406    pub states: Vec<String>,
407    /// AT-SPI `Role::Terminal`, read from the role enum rather than the
408    /// localized role name. Internal routing only; not part of the output.
409    #[serde(skip)]
410    #[schemars(skip)]
411    pub is_terminal: bool,
412}
413
414const FOCUS_PROBE_MAX_NODES: usize = 400;
415const FOCUS_PROBE_MAX_DEPTH: u32 = 16;
416
417/// Find the element with the `focused` state inside the target app (by pid) or
418/// across all apps. Best-effort and bounded: returns Ok(None) when no focused
419/// element is reachable through AT-SPI (common for apps without accessibility
420/// support, e.g. Electron without --force-renderer-accessibility).
421/// Outcome of the bounded focused-element search.
422#[derive(Debug, Clone)]
423pub(crate) enum FocusProbe {
424    Found(FocusedElementSummary),
425    /// The search covered the target's whole tree and nothing holds focus.
426    NoneFocused,
427    /// A complete scoped search saw only window/menu chrome, no content.
428    ShellOnly,
429    /// A node, depth, or read limit stopped the search before it finished, so
430    /// the focused element may exist past the limit.
431    Incomplete,
432    /// A pid was given but no AT-SPI app belongs to it (xterm, or Electron
433    /// without --force-renderer-accessibility), so focus cannot be read.
434    NoAccessibleApp,
435}
436
437/// Focused element in the app owning `target_pid`, or across all apps when no
438/// pid is given. With a pid, only that app's tree is searched: falling back to
439/// every other app would report some unrelated widget as the focus.
440pub(crate) async fn probe_focused_element(target_pid: Option<u32>) -> Result<FocusProbe> {
441    focused_element_probe(target_pid, target_pid.is_some()).await
442}
443
444/// The focused element in the app that owns `target_pid`, for callers that
445/// only need an answer when one exists. Every non-`Found` probe result maps to
446/// `None`; see [`probe_focused_element`] for the distinctions.
447pub(crate) async fn focused_element_summary_in_app(
448    target_pid: u32,
449) -> Result<Option<FocusedElementSummary>> {
450    Ok(match focused_element_probe(Some(target_pid), true).await? {
451        FocusProbe::Found(summary) => Some(summary),
452        _ => None,
453    })
454}
455
456async fn focused_element_probe(
457    target_pid: Option<u32>,
458    require_scoped: bool,
459) -> Result<FocusProbe> {
460    let conn = connect().await?;
461    let mut remaining_registry_reads = MAX_DISCOVERY_ROOTS;
462    let roots =
463        registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
464    let mut remaining_filter_reads = MAX_DISCOVERY_CHILD_READS;
465    let selected_roots =
466        select_roots(&conn, roots, None, target_pid, &mut remaining_filter_reads).await;
467    if require_scoped && !selected_roots.scoped {
468        return Ok(FocusProbe::NoAccessibleApp);
469    }
470    let mut traversal = BoundedTraversal::new(FOCUS_PROBE_MAX_NODES);
471    let mut remaining_traversal_reads = FOCUS_PROBE_MAX_NODES;
472    let mut incomplete = false;
473    let mut shell_only = true;
474    let mut observed_nodes = 0;
475
476    traversal.enqueue(
477        selected_roots
478            .roots
479            .into_iter()
480            .map(|object_ref| (object_ref, 0_u32, false)),
481    );
482
483    while let Some((object_ref, depth, in_menu)) = traversal.pop() {
484        let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
485            incomplete = true;
486            continue;
487        };
488        let Ok(state) = proxy.get_state().await else {
489            incomplete = true;
490            continue;
491        };
492        observed_nodes += 1;
493        let mut child_menu = false;
494        if shell_only {
495            match accessibility_chrome_context(&role_name(&proxy).await, in_menu) {
496                Some(menu) => child_menu = menu,
497                None => shell_only = false,
498            }
499        }
500        if state.contains(atspi::State::Focused) {
501            let proxies = proxy.proxies().await.ok();
502            let is_terminal = matches!(proxy.get_role().await, Ok(atspi::Role::Terminal));
503            return Ok(FocusProbe::Found(FocusedElementSummary {
504                role: role_name(&proxy).await,
505                name: optional_string(proxy.name().await.ok()),
506                editable: supports_editable_text(proxies.as_ref()).await,
507                states: state_labels(state),
508                is_terminal,
509            }));
510        }
511        let remaining = traversal.remaining_capacity();
512        if depth < FOCUS_PROBE_MAX_DEPTH && remaining > 0 {
513            if let Ok(batch) =
514                children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await
515            {
516                incomplete |= batch.incomplete;
517                // An exhausted shared read budget takes children_up_to's
518                // I/O-free path (attempted 0, incomplete false), so the node's
519                // own child count has to say whether children went unread.
520                // With budget left, attempted 0 just means a leaf: no read.
521                if batch.attempted == 0 && remaining_traversal_reads == 0 && !incomplete {
522                    incomplete = proxy.child_count().await.map_or(true, |count| count > 0);
523                }
524                traversal.enqueue(
525                    batch
526                        .items
527                        .into_iter()
528                        .map(|child| (child, depth + 1, child_menu)),
529                );
530            } else {
531                incomplete = true;
532            }
533        } else if !incomplete {
534            // Depth or node cap: any child this node reports goes unread. One
535            // child-count read settles it, and only until the first cut is seen.
536            incomplete = proxy.child_count().await.map_or(true, |count| count > 0);
537        }
538    }
539    if incomplete || traversal.truncated() {
540        return Ok(FocusProbe::Incomplete);
541    }
542
543    Ok(if require_scoped && shell_only && observed_nodes > 0 {
544        FocusProbe::ShellOnly
545    } else {
546        FocusProbe::NoneFocused
547    })
548}
549
550/// Window and native menu chrome can exist without Electron renderer content.
551/// This is evidence about tree coverage, not positive Electron detection.
552pub(crate) fn accessibility_chrome_context(role: &str, in_menu: bool) -> Option<bool> {
553    match role {
554        "application" | "frame" | "window" | "panel" | "filler" | "separator" => Some(in_menu),
555        "menu bar" | "menu" | "menu item" | "check menu item" | "radio menu item"
556        | "tearoff menu item" => Some(true),
557        // Electron's native menu buttons are exposed as buttons, not menu items.
558        "button" | "push button" | "label" | "static" | "image" | "icon" if in_menu => Some(true),
559        _ => None,
560    }
561}
562
563pub async fn perform_action(
564    object_ref_id: &str,
565    requested_action: Option<&str>,
566) -> Result<ActionInvocation> {
567    perform_action_inner(object_ref_id, requested_action, false).await
568}
569
570pub(crate) async fn perform_named_action(
571    object_ref_id: &str,
572    name: &str,
573) -> Result<ActionInvocation> {
574    perform_action_inner(object_ref_id, Some(name), true).await
575}
576
577async fn perform_action_inner(
578    object_ref_id: &str,
579    requested_action: Option<&str>,
580    name_only: bool,
581) -> Result<ActionInvocation> {
582    let conn = connect().await?;
583    let object_ref = object_ref_from_id(object_ref_id)?;
584    let proxy = open_accessible(&conn, &object_ref)
585        .await
586        .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
587    let action = proxy
588        .proxies()
589        .await?
590        .action()
591        .await
592        .context("element does not expose the AT-SPI Action interface")?;
593    let actions = action.get_actions().await.unwrap_or_default();
594    let action_index = if name_only {
595        select_named_action_index(&actions, requested_action.unwrap_or_default())?
596    } else {
597        select_action_index(&actions, requested_action)?
598    };
599    let action_name = actions
600        .get(action_index as usize)
601        .map(|action| action.name.clone());
602    let ok = action
603        .do_action(action_index)
604        .await
605        .with_context(|| format!("failed to invoke AT-SPI action {action_index}"))?;
606
607    Ok(ActionInvocation {
608        action_index,
609        action_name,
610        ok,
611    })
612}
613
614pub async fn set_element_value(object_ref_id: &str, value: &str) -> Result<ValueSetInvocation> {
615    let conn = connect().await?;
616    let object_ref = object_ref_from_id(object_ref_id)?;
617    let proxy = open_accessible(&conn, &object_ref)
618        .await
619        .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
620    let proxies = proxy.proxies().await?;
621
622    if let Ok(numeric_value) = value.parse::<f64>() {
623        if let Ok(value_proxy) = proxies.value().await {
624            value_proxy
625                .set_current_value(numeric_value)
626                .await
627                .with_context(|| {
628                    format!("failed to set AT-SPI numeric value to {numeric_value}")
629                })?;
630            return Ok(ValueSetInvocation::Numeric {
631                value: numeric_value,
632            });
633        }
634    }
635
636    if let Ok(editable_text) = proxies.editable_text().await {
637        let ok = editable_text
638            .set_text_contents(value)
639            .await
640            .context("failed to set AT-SPI editable text contents")?;
641        if ok {
642            return Ok(ValueSetInvocation::EditableText);
643        }
644        return Err(anyhow!("AT-SPI EditableText rejected the new contents"));
645    }
646
647    if value.parse::<f64>().is_err() && proxies.value().await.is_ok() {
648        return Err(anyhow!(
649            "element exposes the AT-SPI Value interface, but the requested value is not numeric"
650        ));
651    }
652
653    Err(anyhow!(
654        "element does not expose AT-SPI Value or EditableText interfaces"
655    ))
656}
657
658async fn connect() -> Result<AccessibilityConnection> {
659    hydrate_session_bus_env();
660    AccessibilityConnection::new()
661        .await
662        .context("failed to connect to AT-SPI bus")
663}
664
665/// Open an `AccessibleProxy` for an object on the a11y bus.
666///
667/// We deliberately avoid `AccessibilityConnection::object_as_accessible` (the
668/// `P2P` trait). For apps that advertise a peer-to-peer bus address it routes
669/// reads over that socket, but for apps that don't (notably GTK4 apps such as
670/// Nautilus / Text Editor / baobab, which don't implement the legacy
671/// `GetApplicationBusAddress`) it falls back to a proxy built with only a path
672/// and *no destination*. On the shared a11y bus that proxy can't address the
673/// app and every call fails with `ServiceUnknown`, which surfaces as an empty
674/// tree (`role: "unknown"`, `child_count: 0`). `as_accessible_proxy` always
675/// pins the destination to the object's bus name, so it works for every app
676/// regardless of P2P support. See issue #31.
677async fn open_accessible<'r>(
678    conn: &AccessibilityConnection,
679    object_ref: &'r ObjectRefOwned,
680) -> Result<AccessibleProxy<'r>, atspi::AtspiError> {
681    object_ref.as_accessible_proxy(conn.connection()).await
682}
683
684async fn registry_children(
685    conn: &AccessibilityConnection,
686    limit: usize,
687    remaining_child_reads: &mut usize,
688) -> Result<Vec<ObjectRefOwned>> {
689    let root = conn
690        .root_accessible_on_registry()
691        .await
692        .context("failed to open AT-SPI registry root")?;
693    let batch = children_up_to(&root, limit, remaining_child_reads)
694        .await
695        .context("failed to read AT-SPI registry children")?;
696    if batch.all_failed() {
697        return Err(anyhow!(
698            "AT-SPI registry reported children, but every indexed child read failed"
699        ));
700    }
701    Ok(batch.items)
702}
703
704struct SelectedRoots {
705    roots: Vec<ObjectRefOwned>,
706    scoped: bool,
707    /// Set only when the roots were chosen because they belong to this pid.
708    pid: Option<u32>,
709}
710
711/// Normalized app-name filter, or `None` when the caller passed nothing usable.
712/// A `None` needle with no pid match means the snapshot covers the whole desktop.
713fn app_name_needle(app_name_or_bundle_identifier: Option<&str>) -> Option<String> {
714    app_name_or_bundle_identifier
715        .map(str::trim)
716        .filter(|value| !value.is_empty())
717        .map(|value| value.to_ascii_lowercase())
718}
719
720async fn select_roots(
721    conn: &AccessibilityConnection,
722    roots: Vec<ObjectRefOwned>,
723    app_name_or_bundle_identifier: Option<&str>,
724    target_pid: Option<u32>,
725    remaining_child_reads: &mut usize,
726) -> SelectedRoots {
727    let needle = app_name_needle(app_name_or_bundle_identifier);
728    let dbus = DBusProxy::new(conn.connection()).await.ok();
729    let mut remaining = roots;
730
731    if let Some(target_pid) = target_pid {
732        let mut pid_and_filter_matches = Vec::new();
733        let mut pid_matches = Vec::new();
734        let mut non_pid_matches = Vec::new();
735
736        for object_ref in remaining {
737            if object_ref_pid(dbus.as_ref(), &object_ref).await == Some(target_pid) {
738                if let Some(needle) = needle.as_deref() {
739                    if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
740                        pid_and_filter_matches.push(object_ref);
741                    } else {
742                        pid_matches.push(object_ref);
743                    }
744                } else {
745                    pid_matches.push(object_ref);
746                }
747            } else {
748                non_pid_matches.push(object_ref);
749            }
750        }
751
752        if !pid_and_filter_matches.is_empty() {
753            return SelectedRoots {
754                roots: pid_and_filter_matches,
755                scoped: true,
756                pid: Some(target_pid),
757            };
758        }
759        if !pid_matches.is_empty() {
760            return SelectedRoots {
761                roots: pid_matches,
762                scoped: true,
763                pid: Some(target_pid),
764            };
765        }
766
767        remaining = non_pid_matches;
768    }
769
770    let Some(needle) = needle.as_deref() else {
771        return SelectedRoots {
772            roots: remaining,
773            scoped: false,
774            pid: None,
775        };
776    };
777
778    let mut selected = Vec::new();
779    for object_ref in remaining {
780        if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
781            selected.push(object_ref);
782        }
783    }
784
785    SelectedRoots {
786        roots: selected,
787        scoped: true,
788        pid: None,
789    }
790}
791
792async fn root_matches(
793    conn: &AccessibilityConnection,
794    object_ref: &ObjectRefOwned,
795    needle: &str,
796    remaining_child_reads: &mut usize,
797) -> bool {
798    let Ok(proxy) = open_accessible(conn, object_ref).await else {
799        return object_ref_id(object_ref)
800            .to_ascii_lowercase()
801            .contains(needle);
802    };
803
804    if proxy_matches(&proxy, object_ref, needle).await {
805        return true;
806    }
807
808    for child_ref in children_up_to(&proxy, ROOT_MATCH_CHILD_LIMIT, remaining_child_reads)
809        .await
810        .map(|batch| batch.items)
811        .unwrap_or_default()
812    {
813        let Ok(child_proxy) = open_accessible(conn, &child_ref).await else {
814            continue;
815        };
816        if proxy_matches(&child_proxy, &child_ref, needle).await {
817            return true;
818        }
819    }
820
821    false
822}
823
824async fn proxy_matches(
825    proxy: &AccessibleProxy<'_>,
826    object_ref: &ObjectRefOwned,
827    needle: &str,
828) -> bool {
829    let name = proxy.name().await.unwrap_or_default();
830    let role = proxy.get_role_name().await.unwrap_or_default();
831    format!("{} {} {}", object_ref_id(object_ref), name, role)
832        .to_ascii_lowercase()
833        .contains(needle)
834}
835
836async fn read_app_summary(
837    proxy: &AccessibleProxy<'_>,
838    object_ref: &ObjectRefOwned,
839    dbus: Option<&DBusProxy<'_>>,
840) -> AccessibleAppSummary {
841    AccessibleAppSummary {
842        object_ref: object_ref_id(object_ref),
843        name: optional_string(proxy.name().await.ok()),
844        pid: object_ref_pid(dbus, object_ref).await,
845        role: role_name(proxy).await,
846        child_count: proxy.child_count().await.unwrap_or_default(),
847        bounds: bounds(proxy).await,
848    }
849}
850
851async fn read_node(
852    proxy: &AccessibleProxy<'_>,
853    object_ref: &ObjectRefOwned,
854    index: u32,
855    parent_index: Option<u32>,
856    depth: u32,
857) -> AccessibilityNode {
858    let proxies = proxy.proxies().await.ok();
859
860    AccessibilityNode {
861        index,
862        parent_index,
863        depth,
864        object_ref: object_ref_id(object_ref),
865        role: role_name(proxy).await,
866        name: optional_string(proxy.name().await.ok()),
867        description: optional_string(proxy.description().await.ok()),
868        child_count: proxy.child_count().await.unwrap_or_default(),
869        bounds: bounds_from_proxies(proxies.as_ref(), proxy).await,
870        states: states_from_proxy(proxy).await,
871        actions: actions_from_proxies(proxies.as_ref()).await,
872        value: value_from_proxies(proxies.as_ref()).await,
873        text: text_from_proxies(proxies.as_ref()).await,
874        supports_editable_text: supports_editable_text(proxies.as_ref()).await,
875    }
876}
877
878/// Role reported when neither AT-SPI role read succeeds.
879pub(crate) const UNKNOWN_ROLE: &str = "unknown";
880
881async fn role_name(proxy: &AccessibleProxy<'_>) -> String {
882    if let Ok(role) = proxy.get_role_name().await {
883        if !role.trim().is_empty() {
884            return role;
885        }
886    }
887    proxy
888        .get_role()
889        .await
890        .map(|role| format!("{role:?}"))
891        .unwrap_or_else(|_| UNKNOWN_ROLE.to_string())
892}
893
894async fn bounds(proxy: &AccessibleProxy<'_>) -> Option<Bounds> {
895    bounds_from_proxies(proxy.proxies().await.ok().as_ref(), proxy).await
896}
897
898/// Pid of the process that owns an AT-SPI object ref (`:bus/path`), from the
899/// accessibility bus. `None` when the owner is gone or the bus cannot say.
900pub(crate) async fn object_ref_owner_pid(object_ref_id: &str) -> Result<Option<u32>> {
901    let object_ref = object_ref_from_id(object_ref_id)?;
902    let conn = connect().await?;
903    let dbus = DBusProxy::new(conn.connection()).await.ok();
904    Ok(object_ref_pid(dbus.as_ref(), &object_ref).await)
905}
906
907async fn object_ref_pid(dbus: Option<&DBusProxy<'_>>, object_ref: &ObjectRefOwned) -> Option<u32> {
908    let dbus = dbus?;
909    let bus_name = BusName::try_from(object_ref.name_as_str()?.to_string()).ok()?;
910    dbus.get_connection_unix_process_id(bus_name).await.ok()
911}
912
913async fn bounds_from_proxies(
914    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
915    proxy: &AccessibleProxy<'_>,
916) -> Option<Bounds> {
917    let owned_proxies;
918    let proxies = if let Some(proxies) = proxies {
919        proxies
920    } else {
921        owned_proxies = proxy.proxies().await.ok()?;
922        &owned_proxies
923    };
924    let component = proxies.component().await.ok()?;
925    let (x, y, width, height) = component.get_extents(CoordType::Screen).await.ok()?;
926    normalize_bounds(Bounds {
927        x,
928        y,
929        width,
930        height,
931    })
932}
933
934fn normalize_bounds(bounds: Bounds) -> Option<Bounds> {
935    if bounds.width <= 0 || bounds.height <= 0 {
936        return None;
937    }
938    if bounds.x <= i32::MIN / 2 || bounds.y <= i32::MIN / 2 {
939        return None;
940    }
941    Some(bounds)
942}
943
944async fn actions_from_proxies(
945    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
946) -> Vec<AccessibilityAction> {
947    let Some(proxies) = proxies else {
948        return Vec::new();
949    };
950    let Ok(action_proxy) = proxies.action().await else {
951        return Vec::new();
952    };
953
954    action_proxy
955        .get_actions()
956        .await
957        .unwrap_or_default()
958        .into_iter()
959        .enumerate()
960        .map(|(index, action)| AccessibilityAction {
961            index: index as i32,
962            name: action.name,
963            description: action.description,
964            keybinding: action.keybinding,
965        })
966        .collect()
967}
968
969async fn states_from_proxy(proxy: &AccessibleProxy<'_>) -> Vec<String> {
970    proxy
971        .get_state()
972        .await
973        .map(state_labels)
974        .unwrap_or_default()
975}
976
977async fn value_from_proxies(
978    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
979) -> Option<AccessibilityValue> {
980    let value = proxies?.value().await.ok()?;
981    Some(AccessibilityValue {
982        current: value.current_value().await.ok()?,
983        minimum: value.minimum_value().await.ok()?,
984        maximum: value.maximum_value().await.ok()?,
985        minimum_increment: value.minimum_increment().await.ok()?,
986        text: optional_string(value.text().await.ok()),
987    })
988}
989
990async fn text_from_proxies(
991    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
992) -> Option<AccessibilityText> {
993    let text = proxies?.text().await.ok()?;
994    let character_count = text.character_count().await.ok()?.max(0);
995    let caret_offset = text.caret_offset().await.ok();
996    let capped_count = character_count.min(MAX_TEXT_READBACK_CHARS);
997    let content = if capped_count > 0 {
998        optional_string(text.get_text(0, capped_count).await.ok())
999    } else {
1000        None
1001    };
1002    let selection_count = text
1003        .get_nselections()
1004        .await
1005        .unwrap_or_default()
1006        .clamp(0, MAX_TEXT_SELECTIONS);
1007    let mut selections = Vec::new();
1008    for index in 0..selection_count {
1009        if let Ok((start_offset, end_offset)) = text.get_selection(index).await {
1010            selections.push(AccessibilityTextSelection {
1011                start_offset,
1012                end_offset,
1013            });
1014        }
1015    }
1016
1017    Some(AccessibilityText {
1018        character_count,
1019        caret_offset,
1020        content,
1021        truncated: character_count > MAX_TEXT_READBACK_CHARS,
1022        selections,
1023    })
1024}
1025
1026async fn supports_editable_text(proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>) -> bool {
1027    let Some(proxies) = proxies else {
1028        return false;
1029    };
1030    proxies.editable_text().await.is_ok()
1031}
1032
1033fn state_labels(state_set: StateSet) -> Vec<String> {
1034    state_set.iter().map(|state| state.to_string()).collect()
1035}
1036
1037fn select_action_index(actions: &[atspi::Action], requested_action: Option<&str>) -> Result<i32> {
1038    if actions.is_empty() {
1039        return Err(anyhow!("element exposes no AT-SPI actions"));
1040    }
1041
1042    if let Some(requested_action) = requested_action
1043        .map(str::trim)
1044        .filter(|value| !value.is_empty())
1045    {
1046        let requested_action = requested_action.to_ascii_lowercase();
1047        if let Some((index, _)) = actions.iter().enumerate().find(|(_, action)| {
1048            action.name.to_ascii_lowercase() == requested_action
1049                || action.description.to_ascii_lowercase() == requested_action
1050        }) {
1051            return Ok(index as i32);
1052        }
1053
1054        if let Ok(index) = requested_action.parse::<usize>() {
1055            if index < actions.len() {
1056                return Ok(index as i32);
1057            }
1058        }
1059
1060        return Err(anyhow!(
1061            "requested AT-SPI action was not found; available actions: {}",
1062            actions
1063                .iter()
1064                .map(|action| action.name.as_str())
1065                .collect::<Vec<_>>()
1066                .join(", ")
1067        ));
1068    }
1069
1070    Ok(if actions.len() > 1 { 1 } else { 0 })
1071}
1072
1073fn select_named_action_index(actions: &[atspi::Action], name: &str) -> Result<i32> {
1074    actions
1075        .iter()
1076        .position(|action| action.name.eq_ignore_ascii_case(name))
1077        .map(|index| index as i32)
1078        .ok_or_else(|| {
1079            anyhow!("cached AT-SPI action name is no longer available; refresh get_app_state")
1080        })
1081}
1082
1083fn optional_string(value: Option<String>) -> Option<String> {
1084    value
1085        .map(|value| value.trim().to_string())
1086        .filter(|value| !value.is_empty())
1087}
1088
1089fn object_ref_from_id(object_ref_id: &str) -> Result<ObjectRefOwned> {
1090    let (name, path) = split_object_ref_id(object_ref_id)?;
1091    let name = UniqueName::try_from(name.to_string())
1092        .with_context(|| format!("invalid AT-SPI bus name in object ref {object_ref_id}"))?;
1093    let path = ObjectPath::try_from(path.to_string())
1094        .with_context(|| format!("invalid AT-SPI object path in object ref {object_ref_id}"))?;
1095    Ok(ObjectRef::new_owned(name, path))
1096}
1097
1098fn split_object_ref_id(object_ref_id: &str) -> Result<(&str, &str)> {
1099    let Some(path_start) = object_ref_id.find('/') else {
1100        return Err(anyhow!(
1101            "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
1102        ));
1103    };
1104    let (name, path) = object_ref_id.split_at(path_start);
1105    if name.is_empty() || path.is_empty() {
1106        return Err(anyhow!(
1107            "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
1108        ));
1109    }
1110    Ok((name, path))
1111}
1112
1113fn object_ref_id(object_ref: &ObjectRefOwned) -> String {
1114    format!(
1115        "{}{}",
1116        object_ref.name_as_str().unwrap_or(""),
1117        object_ref.path_as_str()
1118    )
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123    use super::*;
1124
1125    #[test]
1126    fn split_object_ref_id_separates_bus_name_and_path() {
1127        let (name, path) = split_object_ref_id(":1.42/org/a11y/atspi/accessible/7").unwrap();
1128
1129        assert_eq!(name, ":1.42");
1130        assert_eq!(path, "/org/a11y/atspi/accessible/7");
1131    }
1132
1133    #[test]
1134    fn select_action_index_uses_named_action() {
1135        let actions = vec![
1136            atspi::Action {
1137                name: "click".to_string(),
1138                description: "Clicks".to_string(),
1139                keybinding: String::new(),
1140            },
1141            atspi::Action {
1142                name: "show-menu".to_string(),
1143                description: "Shows menu".to_string(),
1144                keybinding: String::new(),
1145            },
1146        ];
1147
1148        assert_eq!(select_action_index(&actions, Some("show-menu")).unwrap(), 1);
1149    }
1150
1151    #[test]
1152    fn cached_action_name_is_revalidated_against_current_actions() {
1153        let mut actions = vec![atspi::Action {
1154            name: "show-menu".into(),
1155            description: "Click".into(),
1156            keybinding: String::new(),
1157        }];
1158        // Neither a stale index nor a matching description may invoke another action.
1159        assert!(select_named_action_index(&actions, "Click").is_err());
1160        actions.push(atspi::Action {
1161            name: "click".into(),
1162            description: String::new(),
1163            keybinding: String::new(),
1164        });
1165        assert_eq!(select_named_action_index(&actions, "Click").unwrap(), 1);
1166    }
1167
1168    #[test]
1169    fn select_action_index_defaults_to_secondary_when_available() {
1170        let actions = vec![
1171            atspi::Action {
1172                name: "click".to_string(),
1173                description: String::new(),
1174                keybinding: String::new(),
1175            },
1176            atspi::Action {
1177                name: "show-menu".to_string(),
1178                description: String::new(),
1179                keybinding: String::new(),
1180            },
1181        ];
1182
1183        assert_eq!(select_action_index(&actions, None).unwrap(), 1);
1184    }
1185
1186    #[test]
1187    fn state_labels_serialize_in_bit_order() {
1188        let labels = state_labels(StateSet::new(atspi::State::Focused | atspi::State::Checked));
1189
1190        assert_eq!(labels, vec!["checked".to_string(), "focused".to_string()]);
1191    }
1192
1193    #[test]
1194    fn default_snapshot_limits_cover_deep_gtk4_trees() {
1195        // Nautilus 50 places file-list cells below depth 20 and can expose
1196        // more than 850 raw nodes. Keep the defaults above that known shape.
1197        assert_eq!(snapshot_limits(None, None), (1_000, 32));
1198    }
1199
1200    #[test]
1201    fn app_name_needle_ignores_blank_values_and_normalizes_the_rest() {
1202        assert_eq!(app_name_needle(None), None);
1203        assert_eq!(app_name_needle(Some("")), None);
1204        assert_eq!(app_name_needle(Some("   ")), None);
1205        assert_eq!(
1206            app_name_needle(Some("Calculator")).as_deref(),
1207            Some("calculator")
1208        );
1209        assert_eq!(
1210            app_name_needle(Some(" :1.64/org/a11y/atspi/accessible/root ")).as_deref(),
1211            Some(":1.64/org/a11y/atspi/accessible/root")
1212        );
1213    }
1214
1215    #[test]
1216    fn requested_snapshot_limits_remain_bounded() {
1217        assert_eq!(snapshot_limits(Some(0), Some(0)), (1, 0));
1218        assert_eq!(snapshot_limits(Some(10_000), Some(128)), (2_000, 64));
1219    }
1220
1221    #[test]
1222    fn app_discovery_budget_is_independent_of_requested_tree_size() {
1223        assert_eq!(snapshot_child_read_budgets(1), (256, 2_048, 1));
1224    }
1225
1226    #[test]
1227    fn traversal_attempts_and_queue_share_one_work_budget() {
1228        let mut traversal = BoundedTraversal::new(4);
1229        traversal.enqueue([1]);
1230
1231        assert_eq!(traversal.pop(), Some(1));
1232        traversal.enqueue(2..=10_000);
1233        assert_eq!(traversal.queue, VecDeque::from([2, 3, 4]));
1234
1235        assert_eq!(traversal.pop(), Some(2));
1236        traversal.enqueue(5..=10_000);
1237        assert_eq!(traversal.queue, VecDeque::from([3, 4]));
1238        assert_eq!(traversal.pop(), Some(3));
1239        assert_eq!(traversal.pop(), Some(4));
1240        assert_eq!(traversal.pop(), None);
1241        assert_eq!(traversal.attempted, 4);
1242        assert!(
1243            traversal.truncated(),
1244            "dropped enqueue items must be reported"
1245        );
1246    }
1247
1248    #[test]
1249    fn traversal_that_drains_within_budget_is_not_truncated() {
1250        let mut traversal = BoundedTraversal::new(3);
1251        traversal.enqueue([1, 2, 3]);
1252        assert_eq!(traversal.pop(), Some(1));
1253        assert_eq!(traversal.pop(), Some(2));
1254        assert_eq!(traversal.pop(), Some(3));
1255        assert_eq!(traversal.pop(), None);
1256        assert!(
1257            !traversal.truncated(),
1258            "exactly max_items real nodes is complete"
1259        );
1260    }
1261
1262    #[test]
1263    fn enqueue_at_zero_capacity_marks_the_traversal_truncated() {
1264        let mut traversal = BoundedTraversal::new(2);
1265        traversal.enqueue([1, 2]);
1266        assert_eq!(traversal.pop(), Some(1));
1267        assert_eq!(traversal.pop(), Some(2));
1268        assert!(!traversal.truncated(), "nothing dropped yet");
1269        // Capacity is zero; the offered item is dropped, which is the only
1270        // path that can mark truncation.
1271        traversal.enqueue([3]);
1272        assert!(traversal.queue.is_empty());
1273        assert_eq!(traversal.pop(), None);
1274        assert!(traversal.truncated());
1275    }
1276
1277    #[test]
1278    fn child_count_is_clamped_before_indexed_reads() {
1279        assert_eq!(bounded_child_count(-1, 4), 0);
1280        assert_eq!(bounded_child_count(3, 4), 3);
1281        assert_eq!(bounded_child_count(i32::MAX, 4), 4);
1282    }
1283
1284    #[tokio::test]
1285    async fn indexed_child_reads_consume_attempts_even_when_one_fails() {
1286        let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1287        let mut remaining_attempts = 3;
1288        let batch = fetch_indexed_up_to(100, 10, &mut remaining_attempts, {
1289            let calls = calls.clone();
1290            move |index| {
1291                let calls = calls.clone();
1292                async move {
1293                    calls.lock().unwrap().push(index);
1294                    if index == 1 {
1295                        Err(())
1296                    } else {
1297                        Ok(index)
1298                    }
1299                }
1300            }
1301        })
1302        .await;
1303
1304        assert_eq!(batch.items, vec![0, 2]);
1305        assert_eq!(batch.attempted, 3);
1306        assert!(
1307            batch.incomplete,
1308            "100 reported children, 3 attempted: budget cut must be reported"
1309        );
1310        assert!(!batch.all_failed());
1311        assert_eq!(*calls.lock().unwrap(), vec![0, 1, 2]);
1312        assert_eq!(remaining_attempts, 0);
1313
1314        let no_children = fetch_indexed_up_to(100, 10, &mut remaining_attempts, |_| async {
1315            Ok::<_, ()>(99)
1316        })
1317        .await;
1318        assert!(no_children.items.is_empty());
1319        assert_eq!(no_children.attempted, 0);
1320        assert!(!no_children.all_failed());
1321
1322        let mut failed_attempts = 2;
1323        let all_failed =
1324            fetch_indexed_up_to(2, 2, &mut failed_attempts, |_| async { Err::<i32, _>(()) }).await;
1325        assert!(all_failed.all_failed());
1326    }
1327}