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    /// True when max_nodes, max_depth, or the child read budget stopped
279    /// traversal with unread elements left. Failed element reads do not count.
280    pub truncated: bool,
281}
282
283pub async fn snapshot_tree(
284    app_name_or_bundle_identifier: Option<&str>,
285    target_pid: Option<u32>,
286    max_nodes: usize,
287    max_depth: u32,
288) -> Result<Vec<AccessibilityNode>> {
289    Ok(snapshot_accessibility_tree(
290        app_name_or_bundle_identifier,
291        target_pid,
292        max_nodes,
293        max_depth,
294    )
295    .await?
296    .nodes)
297}
298
299pub(crate) async fn snapshot_accessibility_tree(
300    app_name_or_bundle_identifier: Option<&str>,
301    target_pid: Option<u32>,
302    max_nodes: usize,
303    max_depth: u32,
304) -> Result<AccessibilitySnapshot> {
305    let (max_nodes, max_depth) = snapshot_limits(Some(max_nodes), Some(max_depth));
306    timeout(
307        SNAPSHOT_TIMEOUT,
308        snapshot_tree_inner(
309            app_name_or_bundle_identifier,
310            target_pid,
311            max_nodes,
312            max_depth,
313        ),
314    )
315    .await
316    .context("AT-SPI snapshot exceeded its 10-second deadline")?
317}
318
319async fn snapshot_tree_inner(
320    app_name_or_bundle_identifier: Option<&str>,
321    target_pid: Option<u32>,
322    max_nodes: usize,
323    max_depth: u32,
324) -> Result<AccessibilitySnapshot> {
325    let conn = connect().await?;
326    // App discovery is bounded independently so a tiny requested tree still
327    // finds a target registered after the first accessibility root.
328    let (mut remaining_registry_reads, mut remaining_filter_reads, mut remaining_traversal_reads) =
329        snapshot_child_read_budgets(max_nodes);
330    let roots =
331        registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
332    let selected_roots = select_roots(
333        &conn,
334        roots,
335        app_name_or_bundle_identifier,
336        target_pid,
337        &mut remaining_filter_reads,
338    )
339    .await;
340    let scoped = selected_roots.scoped;
341    let mut nodes = Vec::new();
342    let mut truncated = false;
343    let mut traversal = BoundedTraversal::new(max_nodes);
344
345    traversal.enqueue(
346        selected_roots
347            .roots
348            .into_iter()
349            .map(|object_ref| (object_ref, 0_u32, None)),
350    );
351
352    while let Some((object_ref, depth, parent_index)) = traversal.pop() {
353        let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
354            continue;
355        };
356        let index = nodes.len() as u32;
357        let remaining = traversal.remaining_capacity();
358        let node = read_node(&proxy, &object_ref, index, parent_index, depth).await;
359        let child_refs = if depth < max_depth && remaining > 0 {
360            match children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await {
361                Ok(batch) => {
362                    // A read-budget cut inside the fetch reports `incomplete`; an
363                    // already-exhausted budget returns the I/O-free empty batch, so
364                    // fall back to the child_count read_node already fetched.
365                    truncated |= batch.incomplete || (batch.attempted == 0 && node.child_count > 0);
366                    batch.items
367                }
368                Err(_) => Vec::new(),
369            }
370        } else {
371            // Depth or node cap reached: any child this node reports is unread.
372            truncated |= node.child_count > 0;
373            Vec::new()
374        };
375
376        nodes.push(node);
377
378        traversal.enqueue(
379            child_refs
380                .into_iter()
381                .map(|child| (child, depth + 1, Some(index))),
382        );
383    }
384    truncated |= traversal.truncated();
385
386    Ok(AccessibilitySnapshot {
387        nodes,
388        scoped,
389        truncated,
390    })
391}
392
393/// Compact description of the AT-SPI element that currently holds keyboard
394/// focus, used as post-input feedback for type_text/press_key.
395#[derive(Debug, Clone, Serialize, JsonSchema)]
396pub struct FocusedElementSummary {
397    pub role: String,
398    pub name: Option<String>,
399    pub editable: bool,
400    pub states: Vec<String>,
401}
402
403const FOCUS_PROBE_MAX_NODES: usize = 400;
404const FOCUS_PROBE_MAX_DEPTH: u32 = 16;
405
406/// Find the element with the `focused` state inside the target app (by pid) or
407/// across all apps. Best-effort and bounded: returns Ok(None) when no focused
408/// element is reachable through AT-SPI (common for apps without accessibility
409/// support, e.g. Electron without --force-renderer-accessibility).
410pub async fn focused_element_summary(
411    target_pid: Option<u32>,
412) -> Result<Option<FocusedElementSummary>> {
413    let conn = connect().await?;
414    let mut remaining_registry_reads = MAX_DISCOVERY_ROOTS;
415    let roots =
416        registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
417    let mut remaining_filter_reads = MAX_DISCOVERY_CHILD_READS;
418    let selected_roots =
419        select_roots(&conn, roots, None, target_pid, &mut remaining_filter_reads).await;
420    let mut traversal = BoundedTraversal::new(FOCUS_PROBE_MAX_NODES);
421    let mut remaining_traversal_reads = FOCUS_PROBE_MAX_NODES;
422
423    traversal.enqueue(
424        selected_roots
425            .roots
426            .into_iter()
427            .map(|object_ref| (object_ref, 0_u32)),
428    );
429
430    while let Some((object_ref, depth)) = traversal.pop() {
431        let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
432            continue;
433        };
434        let Ok(state) = proxy.get_state().await else {
435            continue;
436        };
437        if state.contains(atspi::State::Focused) {
438            let proxies = proxy.proxies().await.ok();
439            return Ok(Some(FocusedElementSummary {
440                role: role_name(&proxy).await,
441                name: optional_string(proxy.name().await.ok()),
442                editable: supports_editable_text(proxies.as_ref()).await,
443                states: state_labels(state),
444            }));
445        }
446        if depth < FOCUS_PROBE_MAX_DEPTH {
447            let remaining = traversal.remaining_capacity();
448            let children = children_up_to(&proxy, remaining, &mut remaining_traversal_reads)
449                .await
450                .map(|batch| batch.items)
451                .unwrap_or_default();
452            traversal.enqueue(children.into_iter().map(|child| (child, depth + 1)));
453        }
454    }
455
456    Ok(None)
457}
458
459pub async fn perform_action(
460    object_ref_id: &str,
461    requested_action: Option<&str>,
462) -> Result<ActionInvocation> {
463    perform_action_inner(object_ref_id, requested_action, false).await
464}
465
466pub(crate) async fn perform_named_action(
467    object_ref_id: &str,
468    name: &str,
469) -> Result<ActionInvocation> {
470    perform_action_inner(object_ref_id, Some(name), true).await
471}
472
473async fn perform_action_inner(
474    object_ref_id: &str,
475    requested_action: Option<&str>,
476    name_only: bool,
477) -> Result<ActionInvocation> {
478    let conn = connect().await?;
479    let object_ref = object_ref_from_id(object_ref_id)?;
480    let proxy = open_accessible(&conn, &object_ref)
481        .await
482        .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
483    let action = proxy
484        .proxies()
485        .await?
486        .action()
487        .await
488        .context("element does not expose the AT-SPI Action interface")?;
489    let actions = action.get_actions().await.unwrap_or_default();
490    let action_index = if name_only {
491        select_named_action_index(&actions, requested_action.unwrap_or_default())?
492    } else {
493        select_action_index(&actions, requested_action)?
494    };
495    let action_name = actions
496        .get(action_index as usize)
497        .map(|action| action.name.clone());
498    let ok = action
499        .do_action(action_index)
500        .await
501        .with_context(|| format!("failed to invoke AT-SPI action {action_index}"))?;
502
503    Ok(ActionInvocation {
504        action_index,
505        action_name,
506        ok,
507    })
508}
509
510pub async fn set_element_value(object_ref_id: &str, value: &str) -> Result<ValueSetInvocation> {
511    let conn = connect().await?;
512    let object_ref = object_ref_from_id(object_ref_id)?;
513    let proxy = open_accessible(&conn, &object_ref)
514        .await
515        .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
516    let proxies = proxy.proxies().await?;
517
518    if let Ok(numeric_value) = value.parse::<f64>() {
519        if let Ok(value_proxy) = proxies.value().await {
520            value_proxy
521                .set_current_value(numeric_value)
522                .await
523                .with_context(|| {
524                    format!("failed to set AT-SPI numeric value to {numeric_value}")
525                })?;
526            return Ok(ValueSetInvocation::Numeric {
527                value: numeric_value,
528            });
529        }
530    }
531
532    if let Ok(editable_text) = proxies.editable_text().await {
533        let ok = editable_text
534            .set_text_contents(value)
535            .await
536            .context("failed to set AT-SPI editable text contents")?;
537        if ok {
538            return Ok(ValueSetInvocation::EditableText);
539        }
540        return Err(anyhow!("AT-SPI EditableText rejected the new contents"));
541    }
542
543    if value.parse::<f64>().is_err() && proxies.value().await.is_ok() {
544        return Err(anyhow!(
545            "element exposes the AT-SPI Value interface, but the requested value is not numeric"
546        ));
547    }
548
549    Err(anyhow!(
550        "element does not expose AT-SPI Value or EditableText interfaces"
551    ))
552}
553
554async fn connect() -> Result<AccessibilityConnection> {
555    hydrate_session_bus_env();
556    AccessibilityConnection::new()
557        .await
558        .context("failed to connect to AT-SPI bus")
559}
560
561/// Open an `AccessibleProxy` for an object on the a11y bus.
562///
563/// We deliberately avoid `AccessibilityConnection::object_as_accessible` (the
564/// `P2P` trait). For apps that advertise a peer-to-peer bus address it routes
565/// reads over that socket, but for apps that don't (notably GTK4 apps such as
566/// Nautilus / Text Editor / baobab, which don't implement the legacy
567/// `GetApplicationBusAddress`) it falls back to a proxy built with only a path
568/// and *no destination*. On the shared a11y bus that proxy can't address the
569/// app and every call fails with `ServiceUnknown`, which surfaces as an empty
570/// tree (`role: "unknown"`, `child_count: 0`). `as_accessible_proxy` always
571/// pins the destination to the object's bus name, so it works for every app
572/// regardless of P2P support. See issue #31.
573async fn open_accessible<'r>(
574    conn: &AccessibilityConnection,
575    object_ref: &'r ObjectRefOwned,
576) -> Result<AccessibleProxy<'r>, atspi::AtspiError> {
577    object_ref.as_accessible_proxy(conn.connection()).await
578}
579
580async fn registry_children(
581    conn: &AccessibilityConnection,
582    limit: usize,
583    remaining_child_reads: &mut usize,
584) -> Result<Vec<ObjectRefOwned>> {
585    let root = conn
586        .root_accessible_on_registry()
587        .await
588        .context("failed to open AT-SPI registry root")?;
589    let batch = children_up_to(&root, limit, remaining_child_reads)
590        .await
591        .context("failed to read AT-SPI registry children")?;
592    if batch.all_failed() {
593        return Err(anyhow!(
594            "AT-SPI registry reported children, but every indexed child read failed"
595        ));
596    }
597    Ok(batch.items)
598}
599
600struct SelectedRoots {
601    roots: Vec<ObjectRefOwned>,
602    scoped: bool,
603}
604
605/// Normalized app-name filter, or `None` when the caller passed nothing usable.
606/// A `None` needle with no pid match means the snapshot covers the whole desktop.
607fn app_name_needle(app_name_or_bundle_identifier: Option<&str>) -> Option<String> {
608    app_name_or_bundle_identifier
609        .map(str::trim)
610        .filter(|value| !value.is_empty())
611        .map(|value| value.to_ascii_lowercase())
612}
613
614async fn select_roots(
615    conn: &AccessibilityConnection,
616    roots: Vec<ObjectRefOwned>,
617    app_name_or_bundle_identifier: Option<&str>,
618    target_pid: Option<u32>,
619    remaining_child_reads: &mut usize,
620) -> SelectedRoots {
621    let needle = app_name_needle(app_name_or_bundle_identifier);
622    let dbus = DBusProxy::new(conn.connection()).await.ok();
623    let mut remaining = roots;
624
625    if let Some(target_pid) = target_pid {
626        let mut pid_and_filter_matches = Vec::new();
627        let mut pid_matches = Vec::new();
628        let mut non_pid_matches = Vec::new();
629
630        for object_ref in remaining {
631            if object_ref_pid(dbus.as_ref(), &object_ref).await == Some(target_pid) {
632                if let Some(needle) = needle.as_deref() {
633                    if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
634                        pid_and_filter_matches.push(object_ref);
635                    } else {
636                        pid_matches.push(object_ref);
637                    }
638                } else {
639                    pid_matches.push(object_ref);
640                }
641            } else {
642                non_pid_matches.push(object_ref);
643            }
644        }
645
646        if !pid_and_filter_matches.is_empty() {
647            return SelectedRoots {
648                roots: pid_and_filter_matches,
649                scoped: true,
650            };
651        }
652        if !pid_matches.is_empty() {
653            return SelectedRoots {
654                roots: pid_matches,
655                scoped: true,
656            };
657        }
658
659        remaining = non_pid_matches;
660    }
661
662    let Some(needle) = needle.as_deref() else {
663        return SelectedRoots {
664            roots: remaining,
665            scoped: false,
666        };
667    };
668
669    let mut selected = Vec::new();
670    for object_ref in remaining {
671        if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
672            selected.push(object_ref);
673        }
674    }
675
676    SelectedRoots {
677        roots: selected,
678        scoped: true,
679    }
680}
681
682async fn root_matches(
683    conn: &AccessibilityConnection,
684    object_ref: &ObjectRefOwned,
685    needle: &str,
686    remaining_child_reads: &mut usize,
687) -> bool {
688    let Ok(proxy) = open_accessible(conn, object_ref).await else {
689        return object_ref_id(object_ref)
690            .to_ascii_lowercase()
691            .contains(needle);
692    };
693
694    if proxy_matches(&proxy, object_ref, needle).await {
695        return true;
696    }
697
698    for child_ref in children_up_to(&proxy, ROOT_MATCH_CHILD_LIMIT, remaining_child_reads)
699        .await
700        .map(|batch| batch.items)
701        .unwrap_or_default()
702    {
703        let Ok(child_proxy) = open_accessible(conn, &child_ref).await else {
704            continue;
705        };
706        if proxy_matches(&child_proxy, &child_ref, needle).await {
707            return true;
708        }
709    }
710
711    false
712}
713
714async fn proxy_matches(
715    proxy: &AccessibleProxy<'_>,
716    object_ref: &ObjectRefOwned,
717    needle: &str,
718) -> bool {
719    let name = proxy.name().await.unwrap_or_default();
720    let role = proxy.get_role_name().await.unwrap_or_default();
721    format!("{} {} {}", object_ref_id(object_ref), name, role)
722        .to_ascii_lowercase()
723        .contains(needle)
724}
725
726async fn read_app_summary(
727    proxy: &AccessibleProxy<'_>,
728    object_ref: &ObjectRefOwned,
729    dbus: Option<&DBusProxy<'_>>,
730) -> AccessibleAppSummary {
731    AccessibleAppSummary {
732        object_ref: object_ref_id(object_ref),
733        name: optional_string(proxy.name().await.ok()),
734        pid: object_ref_pid(dbus, object_ref).await,
735        role: role_name(proxy).await,
736        child_count: proxy.child_count().await.unwrap_or_default(),
737        bounds: bounds(proxy).await,
738    }
739}
740
741async fn read_node(
742    proxy: &AccessibleProxy<'_>,
743    object_ref: &ObjectRefOwned,
744    index: u32,
745    parent_index: Option<u32>,
746    depth: u32,
747) -> AccessibilityNode {
748    let proxies = proxy.proxies().await.ok();
749
750    AccessibilityNode {
751        index,
752        parent_index,
753        depth,
754        object_ref: object_ref_id(object_ref),
755        role: role_name(proxy).await,
756        name: optional_string(proxy.name().await.ok()),
757        description: optional_string(proxy.description().await.ok()),
758        child_count: proxy.child_count().await.unwrap_or_default(),
759        bounds: bounds_from_proxies(proxies.as_ref(), proxy).await,
760        states: states_from_proxy(proxy).await,
761        actions: actions_from_proxies(proxies.as_ref()).await,
762        value: value_from_proxies(proxies.as_ref()).await,
763        text: text_from_proxies(proxies.as_ref()).await,
764        supports_editable_text: supports_editable_text(proxies.as_ref()).await,
765    }
766}
767
768async fn role_name(proxy: &AccessibleProxy<'_>) -> String {
769    if let Ok(role) = proxy.get_role_name().await {
770        if !role.trim().is_empty() {
771            return role;
772        }
773    }
774    proxy
775        .get_role()
776        .await
777        .map(|role| format!("{role:?}"))
778        .unwrap_or_else(|_| "unknown".to_string())
779}
780
781async fn bounds(proxy: &AccessibleProxy<'_>) -> Option<Bounds> {
782    bounds_from_proxies(proxy.proxies().await.ok().as_ref(), proxy).await
783}
784
785async fn object_ref_pid(dbus: Option<&DBusProxy<'_>>, object_ref: &ObjectRefOwned) -> Option<u32> {
786    let dbus = dbus?;
787    let bus_name = BusName::try_from(object_ref.name_as_str()?.to_string()).ok()?;
788    dbus.get_connection_unix_process_id(bus_name).await.ok()
789}
790
791async fn bounds_from_proxies(
792    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
793    proxy: &AccessibleProxy<'_>,
794) -> Option<Bounds> {
795    let owned_proxies;
796    let proxies = if let Some(proxies) = proxies {
797        proxies
798    } else {
799        owned_proxies = proxy.proxies().await.ok()?;
800        &owned_proxies
801    };
802    let component = proxies.component().await.ok()?;
803    let (x, y, width, height) = component.get_extents(CoordType::Screen).await.ok()?;
804    normalize_bounds(Bounds {
805        x,
806        y,
807        width,
808        height,
809    })
810}
811
812fn normalize_bounds(bounds: Bounds) -> Option<Bounds> {
813    if bounds.width <= 0 || bounds.height <= 0 {
814        return None;
815    }
816    if bounds.x <= i32::MIN / 2 || bounds.y <= i32::MIN / 2 {
817        return None;
818    }
819    Some(bounds)
820}
821
822async fn actions_from_proxies(
823    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
824) -> Vec<AccessibilityAction> {
825    let Some(proxies) = proxies else {
826        return Vec::new();
827    };
828    let Ok(action_proxy) = proxies.action().await else {
829        return Vec::new();
830    };
831
832    action_proxy
833        .get_actions()
834        .await
835        .unwrap_or_default()
836        .into_iter()
837        .enumerate()
838        .map(|(index, action)| AccessibilityAction {
839            index: index as i32,
840            name: action.name,
841            description: action.description,
842            keybinding: action.keybinding,
843        })
844        .collect()
845}
846
847async fn states_from_proxy(proxy: &AccessibleProxy<'_>) -> Vec<String> {
848    proxy
849        .get_state()
850        .await
851        .map(state_labels)
852        .unwrap_or_default()
853}
854
855async fn value_from_proxies(
856    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
857) -> Option<AccessibilityValue> {
858    let value = proxies?.value().await.ok()?;
859    Some(AccessibilityValue {
860        current: value.current_value().await.ok()?,
861        minimum: value.minimum_value().await.ok()?,
862        maximum: value.maximum_value().await.ok()?,
863        minimum_increment: value.minimum_increment().await.ok()?,
864        text: optional_string(value.text().await.ok()),
865    })
866}
867
868async fn text_from_proxies(
869    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
870) -> Option<AccessibilityText> {
871    let text = proxies?.text().await.ok()?;
872    let character_count = text.character_count().await.ok()?.max(0);
873    let caret_offset = text.caret_offset().await.ok();
874    let capped_count = character_count.min(MAX_TEXT_READBACK_CHARS);
875    let content = if capped_count > 0 {
876        optional_string(text.get_text(0, capped_count).await.ok())
877    } else {
878        None
879    };
880    let selection_count = text
881        .get_nselections()
882        .await
883        .unwrap_or_default()
884        .clamp(0, MAX_TEXT_SELECTIONS);
885    let mut selections = Vec::new();
886    for index in 0..selection_count {
887        if let Ok((start_offset, end_offset)) = text.get_selection(index).await {
888            selections.push(AccessibilityTextSelection {
889                start_offset,
890                end_offset,
891            });
892        }
893    }
894
895    Some(AccessibilityText {
896        character_count,
897        caret_offset,
898        content,
899        truncated: character_count > MAX_TEXT_READBACK_CHARS,
900        selections,
901    })
902}
903
904async fn supports_editable_text(proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>) -> bool {
905    let Some(proxies) = proxies else {
906        return false;
907    };
908    proxies.editable_text().await.is_ok()
909}
910
911fn state_labels(state_set: StateSet) -> Vec<String> {
912    state_set.iter().map(|state| state.to_string()).collect()
913}
914
915fn select_action_index(actions: &[atspi::Action], requested_action: Option<&str>) -> Result<i32> {
916    if actions.is_empty() {
917        return Err(anyhow!("element exposes no AT-SPI actions"));
918    }
919
920    if let Some(requested_action) = requested_action
921        .map(str::trim)
922        .filter(|value| !value.is_empty())
923    {
924        let requested_action = requested_action.to_ascii_lowercase();
925        if let Some((index, _)) = actions.iter().enumerate().find(|(_, action)| {
926            action.name.to_ascii_lowercase() == requested_action
927                || action.description.to_ascii_lowercase() == requested_action
928        }) {
929            return Ok(index as i32);
930        }
931
932        if let Ok(index) = requested_action.parse::<usize>() {
933            if index < actions.len() {
934                return Ok(index as i32);
935            }
936        }
937
938        return Err(anyhow!(
939            "requested AT-SPI action was not found; available actions: {}",
940            actions
941                .iter()
942                .map(|action| action.name.as_str())
943                .collect::<Vec<_>>()
944                .join(", ")
945        ));
946    }
947
948    Ok(if actions.len() > 1 { 1 } else { 0 })
949}
950
951fn select_named_action_index(actions: &[atspi::Action], name: &str) -> Result<i32> {
952    actions
953        .iter()
954        .position(|action| action.name.eq_ignore_ascii_case(name))
955        .map(|index| index as i32)
956        .ok_or_else(|| {
957            anyhow!("cached AT-SPI action name is no longer available; refresh get_app_state")
958        })
959}
960
961fn optional_string(value: Option<String>) -> Option<String> {
962    value
963        .map(|value| value.trim().to_string())
964        .filter(|value| !value.is_empty())
965}
966
967fn object_ref_from_id(object_ref_id: &str) -> Result<ObjectRefOwned> {
968    let (name, path) = split_object_ref_id(object_ref_id)?;
969    let name = UniqueName::try_from(name.to_string())
970        .with_context(|| format!("invalid AT-SPI bus name in object ref {object_ref_id}"))?;
971    let path = ObjectPath::try_from(path.to_string())
972        .with_context(|| format!("invalid AT-SPI object path in object ref {object_ref_id}"))?;
973    Ok(ObjectRef::new_owned(name, path))
974}
975
976fn split_object_ref_id(object_ref_id: &str) -> Result<(&str, &str)> {
977    let Some(path_start) = object_ref_id.find('/') else {
978        return Err(anyhow!(
979            "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
980        ));
981    };
982    let (name, path) = object_ref_id.split_at(path_start);
983    if name.is_empty() || path.is_empty() {
984        return Err(anyhow!(
985            "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
986        ));
987    }
988    Ok((name, path))
989}
990
991fn object_ref_id(object_ref: &ObjectRefOwned) -> String {
992    format!(
993        "{}{}",
994        object_ref.name_as_str().unwrap_or(""),
995        object_ref.path_as_str()
996    )
997}
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002
1003    #[test]
1004    fn split_object_ref_id_separates_bus_name_and_path() {
1005        let (name, path) = split_object_ref_id(":1.42/org/a11y/atspi/accessible/7").unwrap();
1006
1007        assert_eq!(name, ":1.42");
1008        assert_eq!(path, "/org/a11y/atspi/accessible/7");
1009    }
1010
1011    #[test]
1012    fn select_action_index_uses_named_action() {
1013        let actions = vec![
1014            atspi::Action {
1015                name: "click".to_string(),
1016                description: "Clicks".to_string(),
1017                keybinding: String::new(),
1018            },
1019            atspi::Action {
1020                name: "show-menu".to_string(),
1021                description: "Shows menu".to_string(),
1022                keybinding: String::new(),
1023            },
1024        ];
1025
1026        assert_eq!(select_action_index(&actions, Some("show-menu")).unwrap(), 1);
1027    }
1028
1029    #[test]
1030    fn cached_action_name_is_revalidated_against_current_actions() {
1031        let mut actions = vec![atspi::Action {
1032            name: "show-menu".into(),
1033            description: "Click".into(),
1034            keybinding: String::new(),
1035        }];
1036        // Neither a stale index nor a matching description may invoke another action.
1037        assert!(select_named_action_index(&actions, "Click").is_err());
1038        actions.push(atspi::Action {
1039            name: "click".into(),
1040            description: String::new(),
1041            keybinding: String::new(),
1042        });
1043        assert_eq!(select_named_action_index(&actions, "Click").unwrap(), 1);
1044    }
1045
1046    #[test]
1047    fn select_action_index_defaults_to_secondary_when_available() {
1048        let actions = vec![
1049            atspi::Action {
1050                name: "click".to_string(),
1051                description: String::new(),
1052                keybinding: String::new(),
1053            },
1054            atspi::Action {
1055                name: "show-menu".to_string(),
1056                description: String::new(),
1057                keybinding: String::new(),
1058            },
1059        ];
1060
1061        assert_eq!(select_action_index(&actions, None).unwrap(), 1);
1062    }
1063
1064    #[test]
1065    fn state_labels_serialize_in_bit_order() {
1066        let labels = state_labels(StateSet::new(atspi::State::Focused | atspi::State::Checked));
1067
1068        assert_eq!(labels, vec!["checked".to_string(), "focused".to_string()]);
1069    }
1070
1071    #[test]
1072    fn default_snapshot_limits_cover_deep_gtk4_trees() {
1073        // Nautilus 50 places file-list cells below depth 20 and can expose
1074        // more than 850 raw nodes. Keep the defaults above that known shape.
1075        assert_eq!(snapshot_limits(None, None), (1_000, 32));
1076    }
1077
1078    #[test]
1079    fn app_name_needle_ignores_blank_values_and_normalizes_the_rest() {
1080        assert_eq!(app_name_needle(None), None);
1081        assert_eq!(app_name_needle(Some("")), None);
1082        assert_eq!(app_name_needle(Some("   ")), None);
1083        assert_eq!(
1084            app_name_needle(Some("Calculator")).as_deref(),
1085            Some("calculator")
1086        );
1087        assert_eq!(
1088            app_name_needle(Some(" :1.64/org/a11y/atspi/accessible/root ")).as_deref(),
1089            Some(":1.64/org/a11y/atspi/accessible/root")
1090        );
1091    }
1092
1093    #[test]
1094    fn requested_snapshot_limits_remain_bounded() {
1095        assert_eq!(snapshot_limits(Some(0), Some(0)), (1, 0));
1096        assert_eq!(snapshot_limits(Some(10_000), Some(128)), (2_000, 64));
1097    }
1098
1099    #[test]
1100    fn app_discovery_budget_is_independent_of_requested_tree_size() {
1101        assert_eq!(snapshot_child_read_budgets(1), (256, 2_048, 1));
1102    }
1103
1104    #[test]
1105    fn traversal_attempts_and_queue_share_one_work_budget() {
1106        let mut traversal = BoundedTraversal::new(4);
1107        traversal.enqueue([1]);
1108
1109        assert_eq!(traversal.pop(), Some(1));
1110        traversal.enqueue(2..=10_000);
1111        assert_eq!(traversal.queue, VecDeque::from([2, 3, 4]));
1112
1113        assert_eq!(traversal.pop(), Some(2));
1114        traversal.enqueue(5..=10_000);
1115        assert_eq!(traversal.queue, VecDeque::from([3, 4]));
1116        assert_eq!(traversal.pop(), Some(3));
1117        assert_eq!(traversal.pop(), Some(4));
1118        assert_eq!(traversal.pop(), None);
1119        assert_eq!(traversal.attempted, 4);
1120        assert!(
1121            traversal.truncated(),
1122            "dropped enqueue items must be reported"
1123        );
1124    }
1125
1126    #[test]
1127    fn traversal_that_drains_within_budget_is_not_truncated() {
1128        let mut traversal = BoundedTraversal::new(3);
1129        traversal.enqueue([1, 2, 3]);
1130        assert_eq!(traversal.pop(), Some(1));
1131        assert_eq!(traversal.pop(), Some(2));
1132        assert_eq!(traversal.pop(), Some(3));
1133        assert_eq!(traversal.pop(), None);
1134        assert!(
1135            !traversal.truncated(),
1136            "exactly max_items real nodes is complete"
1137        );
1138    }
1139
1140    #[test]
1141    fn enqueue_at_zero_capacity_marks_the_traversal_truncated() {
1142        let mut traversal = BoundedTraversal::new(2);
1143        traversal.enqueue([1, 2]);
1144        assert_eq!(traversal.pop(), Some(1));
1145        assert_eq!(traversal.pop(), Some(2));
1146        assert!(!traversal.truncated(), "nothing dropped yet");
1147        // Capacity is zero; the offered item is dropped, which is the only
1148        // path that can mark truncation.
1149        traversal.enqueue([3]);
1150        assert!(traversal.queue.is_empty());
1151        assert_eq!(traversal.pop(), None);
1152        assert!(traversal.truncated());
1153    }
1154
1155    #[test]
1156    fn child_count_is_clamped_before_indexed_reads() {
1157        assert_eq!(bounded_child_count(-1, 4), 0);
1158        assert_eq!(bounded_child_count(3, 4), 3);
1159        assert_eq!(bounded_child_count(i32::MAX, 4), 4);
1160    }
1161
1162    #[tokio::test]
1163    async fn indexed_child_reads_consume_attempts_even_when_one_fails() {
1164        let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1165        let mut remaining_attempts = 3;
1166        let batch = fetch_indexed_up_to(100, 10, &mut remaining_attempts, {
1167            let calls = calls.clone();
1168            move |index| {
1169                let calls = calls.clone();
1170                async move {
1171                    calls.lock().unwrap().push(index);
1172                    if index == 1 {
1173                        Err(())
1174                    } else {
1175                        Ok(index)
1176                    }
1177                }
1178            }
1179        })
1180        .await;
1181
1182        assert_eq!(batch.items, vec![0, 2]);
1183        assert_eq!(batch.attempted, 3);
1184        assert!(
1185            batch.incomplete,
1186            "100 reported children, 3 attempted: budget cut must be reported"
1187        );
1188        assert!(!batch.all_failed());
1189        assert_eq!(*calls.lock().unwrap(), vec![0, 1, 2]);
1190        assert_eq!(remaining_attempts, 0);
1191
1192        let no_children = fetch_indexed_up_to(100, 10, &mut remaining_attempts, |_| async {
1193            Ok::<_, ()>(99)
1194        })
1195        .await;
1196        assert!(no_children.items.is_empty());
1197        assert_eq!(no_children.attempted, 0);
1198        assert!(!no_children.all_failed());
1199
1200        let mut failed_attempts = 2;
1201        let all_failed =
1202            fetch_indexed_up_to(2, 2, &mut failed_attempts, |_| async { Err::<i32, _>(()) }).await;
1203        assert!(all_failed.all_failed());
1204    }
1205}