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}
140
141impl<T> BoundedTraversal<T> {
142    fn new(max_items: usize) -> Self {
143        Self {
144            queue: VecDeque::new(),
145            attempted: 0,
146            max_items,
147        }
148    }
149
150    fn enqueue(&mut self, items: impl IntoIterator<Item = T>) {
151        self.queue
152            .extend(items.into_iter().take(self.remaining_capacity()));
153    }
154
155    fn pop(&mut self) -> Option<T> {
156        if self.attempted >= self.max_items {
157            return None;
158        }
159        let item = self.queue.pop_front()?;
160        self.attempted += 1;
161        Some(item)
162    }
163
164    fn remaining_capacity(&self) -> usize {
165        self.max_items
166            .saturating_sub(self.attempted.saturating_add(self.queue.len()))
167    }
168}
169
170fn bounded_child_count(reported: i32, limit: usize) -> usize {
171    usize::try_from(reported).unwrap_or_default().min(limit)
172}
173
174struct IndexedReadBatch<T> {
175    items: Vec<T>,
176    attempted: usize,
177}
178
179impl<T> IndexedReadBatch<T> {
180    fn all_failed(&self) -> bool {
181        self.attempted > 0 && self.items.is_empty()
182    }
183}
184
185async fn fetch_indexed_up_to<T, E, F, Fut>(
186    reported: i32,
187    limit: usize,
188    remaining_attempts: &mut usize,
189    fetch: F,
190) -> IndexedReadBatch<T>
191where
192    F: Fn(i32) -> Fut,
193    Fut: Future<Output = std::result::Result<T, E>>,
194{
195    let attempt_count = bounded_child_count(reported, limit).min(*remaining_attempts);
196    *remaining_attempts = (*remaining_attempts).saturating_sub(attempt_count);
197    let end_index = i32::try_from(attempt_count).unwrap_or(i32::MAX);
198
199    let items = stream::iter(0..end_index)
200        .map(fetch)
201        .buffered(CHILD_READ_CONCURRENCY)
202        .filter_map(|result| async move { result.ok() })
203        .collect()
204        .await;
205
206    IndexedReadBatch {
207        items,
208        attempted: attempt_count,
209    }
210}
211
212async fn children_up_to(
213    proxy: &AccessibleProxy<'_>,
214    limit: usize,
215    remaining_attempts: &mut usize,
216) -> zbus::Result<IndexedReadBatch<ObjectRefOwned>> {
217    if limit == 0 || *remaining_attempts == 0 {
218        return Ok(IndexedReadBatch {
219            items: Vec::new(),
220            attempted: 0,
221        });
222    }
223
224    let child_count = proxy.child_count().await?;
225    Ok(
226        fetch_indexed_up_to(child_count, limit, remaining_attempts, |index| {
227            proxy.get_child_at_index(index)
228        })
229        .await,
230    )
231}
232
233pub async fn list_accessible_apps(limit: usize) -> Result<Vec<AccessibleAppSummary>> {
234    let conn = connect().await?;
235    let mut remaining_child_reads = limit;
236    let roots = registry_children(&conn, limit, &mut remaining_child_reads).await?;
237    let dbus = DBusProxy::new(conn.connection()).await.ok();
238    let mut apps = Vec::new();
239
240    for object_ref in roots.into_iter().take(limit) {
241        if let Ok(proxy) = open_accessible(&conn, &object_ref).await {
242            apps.push(read_app_summary(&proxy, &object_ref, dbus.as_ref()).await);
243        }
244    }
245
246    Ok(apps)
247}
248
249pub async fn snapshot_tree(
250    app_name_or_bundle_identifier: Option<&str>,
251    target_pid: Option<u32>,
252    max_nodes: usize,
253    max_depth: u32,
254) -> Result<Vec<AccessibilityNode>> {
255    let (max_nodes, max_depth) = snapshot_limits(Some(max_nodes), Some(max_depth));
256    timeout(
257        SNAPSHOT_TIMEOUT,
258        snapshot_tree_inner(
259            app_name_or_bundle_identifier,
260            target_pid,
261            max_nodes,
262            max_depth,
263        ),
264    )
265    .await
266    .context("AT-SPI snapshot exceeded its 10-second deadline")?
267}
268
269async fn snapshot_tree_inner(
270    app_name_or_bundle_identifier: Option<&str>,
271    target_pid: Option<u32>,
272    max_nodes: usize,
273    max_depth: u32,
274) -> Result<Vec<AccessibilityNode>> {
275    let conn = connect().await?;
276    // App discovery is bounded independently so a tiny requested tree still
277    // finds a target registered after the first accessibility root.
278    let (mut remaining_registry_reads, mut remaining_filter_reads, mut remaining_traversal_reads) =
279        snapshot_child_read_budgets(max_nodes);
280    let roots =
281        registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
282    let selected_roots = select_roots(
283        &conn,
284        roots,
285        app_name_or_bundle_identifier,
286        target_pid,
287        &mut remaining_filter_reads,
288    )
289    .await;
290    let mut nodes = Vec::new();
291    let mut traversal = BoundedTraversal::new(max_nodes);
292
293    traversal.enqueue(
294        selected_roots
295            .into_iter()
296            .map(|object_ref| (object_ref, 0_u32, None)),
297    );
298
299    while let Some((object_ref, depth, parent_index)) = traversal.pop() {
300        let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
301            continue;
302        };
303        let index = nodes.len() as u32;
304        let remaining = traversal.remaining_capacity();
305        let child_refs = if depth < max_depth && remaining > 0 {
306            children_up_to(&proxy, remaining, &mut remaining_traversal_reads)
307                .await
308                .map(|batch| batch.items)
309                .unwrap_or_default()
310        } else {
311            Vec::new()
312        };
313
314        nodes.push(read_node(&proxy, &object_ref, index, parent_index, depth).await);
315
316        traversal.enqueue(
317            child_refs
318                .into_iter()
319                .map(|child| (child, depth + 1, Some(index))),
320        );
321    }
322
323    Ok(nodes)
324}
325
326/// Compact description of the AT-SPI element that currently holds keyboard
327/// focus, used as post-input feedback for type_text/press_key.
328#[derive(Debug, Clone, Serialize, JsonSchema)]
329pub struct FocusedElementSummary {
330    pub role: String,
331    pub name: Option<String>,
332    pub editable: bool,
333    pub states: Vec<String>,
334}
335
336const FOCUS_PROBE_MAX_NODES: usize = 400;
337const FOCUS_PROBE_MAX_DEPTH: u32 = 16;
338
339/// Find the element with the `focused` state inside the target app (by pid) or
340/// across all apps. Best-effort and bounded: returns Ok(None) when no focused
341/// element is reachable through AT-SPI (common for apps without accessibility
342/// support, e.g. Electron without --force-renderer-accessibility).
343pub async fn focused_element_summary(
344    target_pid: Option<u32>,
345) -> Result<Option<FocusedElementSummary>> {
346    let conn = connect().await?;
347    let mut remaining_registry_reads = MAX_DISCOVERY_ROOTS;
348    let roots =
349        registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
350    let mut remaining_filter_reads = MAX_DISCOVERY_CHILD_READS;
351    let selected_roots =
352        select_roots(&conn, roots, None, target_pid, &mut remaining_filter_reads).await;
353    let mut traversal = BoundedTraversal::new(FOCUS_PROBE_MAX_NODES);
354    let mut remaining_traversal_reads = FOCUS_PROBE_MAX_NODES;
355
356    traversal.enqueue(
357        selected_roots
358            .into_iter()
359            .map(|object_ref| (object_ref, 0_u32)),
360    );
361
362    while let Some((object_ref, depth)) = traversal.pop() {
363        let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
364            continue;
365        };
366        let Ok(state) = proxy.get_state().await else {
367            continue;
368        };
369        if state.contains(atspi::State::Focused) {
370            let proxies = proxy.proxies().await.ok();
371            return Ok(Some(FocusedElementSummary {
372                role: role_name(&proxy).await,
373                name: optional_string(proxy.name().await.ok()),
374                editable: supports_editable_text(proxies.as_ref()).await,
375                states: state_labels(state),
376            }));
377        }
378        if depth < FOCUS_PROBE_MAX_DEPTH {
379            let remaining = traversal.remaining_capacity();
380            let children = children_up_to(&proxy, remaining, &mut remaining_traversal_reads)
381                .await
382                .map(|batch| batch.items)
383                .unwrap_or_default();
384            traversal.enqueue(children.into_iter().map(|child| (child, depth + 1)));
385        }
386    }
387
388    Ok(None)
389}
390
391pub async fn perform_action(
392    object_ref_id: &str,
393    requested_action: Option<&str>,
394) -> Result<ActionInvocation> {
395    perform_action_inner(object_ref_id, requested_action, false).await
396}
397
398pub(crate) async fn perform_named_action(
399    object_ref_id: &str,
400    name: &str,
401) -> Result<ActionInvocation> {
402    perform_action_inner(object_ref_id, Some(name), true).await
403}
404
405async fn perform_action_inner(
406    object_ref_id: &str,
407    requested_action: Option<&str>,
408    name_only: bool,
409) -> Result<ActionInvocation> {
410    let conn = connect().await?;
411    let object_ref = object_ref_from_id(object_ref_id)?;
412    let proxy = open_accessible(&conn, &object_ref)
413        .await
414        .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
415    let action = proxy
416        .proxies()
417        .await?
418        .action()
419        .await
420        .context("element does not expose the AT-SPI Action interface")?;
421    let actions = action.get_actions().await.unwrap_or_default();
422    let action_index = if name_only {
423        select_named_action_index(&actions, requested_action.unwrap_or_default())?
424    } else {
425        select_action_index(&actions, requested_action)?
426    };
427    let action_name = actions
428        .get(action_index as usize)
429        .map(|action| action.name.clone());
430    let ok = action
431        .do_action(action_index)
432        .await
433        .with_context(|| format!("failed to invoke AT-SPI action {action_index}"))?;
434
435    Ok(ActionInvocation {
436        action_index,
437        action_name,
438        ok,
439    })
440}
441
442pub async fn set_element_value(object_ref_id: &str, value: &str) -> Result<ValueSetInvocation> {
443    let conn = connect().await?;
444    let object_ref = object_ref_from_id(object_ref_id)?;
445    let proxy = open_accessible(&conn, &object_ref)
446        .await
447        .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
448    let proxies = proxy.proxies().await?;
449
450    if let Ok(numeric_value) = value.parse::<f64>() {
451        if let Ok(value_proxy) = proxies.value().await {
452            value_proxy
453                .set_current_value(numeric_value)
454                .await
455                .with_context(|| {
456                    format!("failed to set AT-SPI numeric value to {numeric_value}")
457                })?;
458            return Ok(ValueSetInvocation::Numeric {
459                value: numeric_value,
460            });
461        }
462    }
463
464    if let Ok(editable_text) = proxies.editable_text().await {
465        let ok = editable_text
466            .set_text_contents(value)
467            .await
468            .context("failed to set AT-SPI editable text contents")?;
469        if ok {
470            return Ok(ValueSetInvocation::EditableText);
471        }
472        return Err(anyhow!("AT-SPI EditableText rejected the new contents"));
473    }
474
475    if value.parse::<f64>().is_err() && proxies.value().await.is_ok() {
476        return Err(anyhow!(
477            "element exposes the AT-SPI Value interface, but the requested value is not numeric"
478        ));
479    }
480
481    Err(anyhow!(
482        "element does not expose AT-SPI Value or EditableText interfaces"
483    ))
484}
485
486async fn connect() -> Result<AccessibilityConnection> {
487    hydrate_session_bus_env();
488    AccessibilityConnection::new()
489        .await
490        .context("failed to connect to AT-SPI bus")
491}
492
493/// Open an `AccessibleProxy` for an object on the a11y bus.
494///
495/// We deliberately avoid `AccessibilityConnection::object_as_accessible` (the
496/// `P2P` trait). For apps that advertise a peer-to-peer bus address it routes
497/// reads over that socket, but for apps that don't (notably GTK4 apps such as
498/// Nautilus / Text Editor / baobab, which don't implement the legacy
499/// `GetApplicationBusAddress`) it falls back to a proxy built with only a path
500/// and *no destination*. On the shared a11y bus that proxy can't address the
501/// app and every call fails with `ServiceUnknown`, which surfaces as an empty
502/// tree (`role: "unknown"`, `child_count: 0`). `as_accessible_proxy` always
503/// pins the destination to the object's bus name, so it works for every app
504/// regardless of P2P support. See issue #31.
505async fn open_accessible<'r>(
506    conn: &AccessibilityConnection,
507    object_ref: &'r ObjectRefOwned,
508) -> Result<AccessibleProxy<'r>, atspi::AtspiError> {
509    object_ref.as_accessible_proxy(conn.connection()).await
510}
511
512async fn registry_children(
513    conn: &AccessibilityConnection,
514    limit: usize,
515    remaining_child_reads: &mut usize,
516) -> Result<Vec<ObjectRefOwned>> {
517    let root = conn
518        .root_accessible_on_registry()
519        .await
520        .context("failed to open AT-SPI registry root")?;
521    let batch = children_up_to(&root, limit, remaining_child_reads)
522        .await
523        .context("failed to read AT-SPI registry children")?;
524    if batch.all_failed() {
525        return Err(anyhow!(
526            "AT-SPI registry reported children, but every indexed child read failed"
527        ));
528    }
529    Ok(batch.items)
530}
531
532async fn select_roots(
533    conn: &AccessibilityConnection,
534    roots: Vec<ObjectRefOwned>,
535    app_name_or_bundle_identifier: Option<&str>,
536    target_pid: Option<u32>,
537    remaining_child_reads: &mut usize,
538) -> Vec<ObjectRefOwned> {
539    let needle = app_name_or_bundle_identifier
540        .map(str::trim)
541        .filter(|value| !value.is_empty())
542        .map(|value| value.to_ascii_lowercase());
543    let dbus = DBusProxy::new(conn.connection()).await.ok();
544    let mut remaining = roots;
545
546    if let Some(target_pid) = target_pid {
547        let mut pid_and_filter_matches = Vec::new();
548        let mut pid_matches = Vec::new();
549        let mut non_pid_matches = Vec::new();
550
551        for object_ref in remaining {
552            if object_ref_pid(dbus.as_ref(), &object_ref).await == Some(target_pid) {
553                if let Some(needle) = needle.as_deref() {
554                    if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
555                        pid_and_filter_matches.push(object_ref);
556                    } else {
557                        pid_matches.push(object_ref);
558                    }
559                } else {
560                    pid_matches.push(object_ref);
561                }
562            } else {
563                non_pid_matches.push(object_ref);
564            }
565        }
566
567        if !pid_and_filter_matches.is_empty() {
568            return pid_and_filter_matches;
569        }
570        if !pid_matches.is_empty() {
571            return pid_matches;
572        }
573
574        remaining = non_pid_matches;
575    }
576
577    let Some(needle) = needle.as_deref() else {
578        return remaining;
579    };
580
581    let mut selected = Vec::new();
582    for object_ref in remaining {
583        if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
584            selected.push(object_ref);
585        }
586    }
587
588    selected
589}
590
591async fn root_matches(
592    conn: &AccessibilityConnection,
593    object_ref: &ObjectRefOwned,
594    needle: &str,
595    remaining_child_reads: &mut usize,
596) -> bool {
597    let Ok(proxy) = open_accessible(conn, object_ref).await else {
598        return object_ref_id(object_ref)
599            .to_ascii_lowercase()
600            .contains(needle);
601    };
602
603    if proxy_matches(&proxy, object_ref, needle).await {
604        return true;
605    }
606
607    for child_ref in children_up_to(&proxy, ROOT_MATCH_CHILD_LIMIT, remaining_child_reads)
608        .await
609        .map(|batch| batch.items)
610        .unwrap_or_default()
611    {
612        let Ok(child_proxy) = open_accessible(conn, &child_ref).await else {
613            continue;
614        };
615        if proxy_matches(&child_proxy, &child_ref, needle).await {
616            return true;
617        }
618    }
619
620    false
621}
622
623async fn proxy_matches(
624    proxy: &AccessibleProxy<'_>,
625    object_ref: &ObjectRefOwned,
626    needle: &str,
627) -> bool {
628    let name = proxy.name().await.unwrap_or_default();
629    let role = proxy.get_role_name().await.unwrap_or_default();
630    format!("{} {} {}", object_ref_id(object_ref), name, role)
631        .to_ascii_lowercase()
632        .contains(needle)
633}
634
635async fn read_app_summary(
636    proxy: &AccessibleProxy<'_>,
637    object_ref: &ObjectRefOwned,
638    dbus: Option<&DBusProxy<'_>>,
639) -> AccessibleAppSummary {
640    AccessibleAppSummary {
641        object_ref: object_ref_id(object_ref),
642        name: optional_string(proxy.name().await.ok()),
643        pid: object_ref_pid(dbus, object_ref).await,
644        role: role_name(proxy).await,
645        child_count: proxy.child_count().await.unwrap_or_default(),
646        bounds: bounds(proxy).await,
647    }
648}
649
650async fn read_node(
651    proxy: &AccessibleProxy<'_>,
652    object_ref: &ObjectRefOwned,
653    index: u32,
654    parent_index: Option<u32>,
655    depth: u32,
656) -> AccessibilityNode {
657    let proxies = proxy.proxies().await.ok();
658
659    AccessibilityNode {
660        index,
661        parent_index,
662        depth,
663        object_ref: object_ref_id(object_ref),
664        role: role_name(proxy).await,
665        name: optional_string(proxy.name().await.ok()),
666        description: optional_string(proxy.description().await.ok()),
667        child_count: proxy.child_count().await.unwrap_or_default(),
668        bounds: bounds_from_proxies(proxies.as_ref(), proxy).await,
669        states: states_from_proxy(proxy).await,
670        actions: actions_from_proxies(proxies.as_ref()).await,
671        value: value_from_proxies(proxies.as_ref()).await,
672        text: text_from_proxies(proxies.as_ref()).await,
673        supports_editable_text: supports_editable_text(proxies.as_ref()).await,
674    }
675}
676
677async fn role_name(proxy: &AccessibleProxy<'_>) -> String {
678    if let Ok(role) = proxy.get_role_name().await {
679        if !role.trim().is_empty() {
680            return role;
681        }
682    }
683    proxy
684        .get_role()
685        .await
686        .map(|role| format!("{role:?}"))
687        .unwrap_or_else(|_| "unknown".to_string())
688}
689
690async fn bounds(proxy: &AccessibleProxy<'_>) -> Option<Bounds> {
691    bounds_from_proxies(proxy.proxies().await.ok().as_ref(), proxy).await
692}
693
694async fn object_ref_pid(dbus: Option<&DBusProxy<'_>>, object_ref: &ObjectRefOwned) -> Option<u32> {
695    let dbus = dbus?;
696    let bus_name = BusName::try_from(object_ref.name_as_str()?.to_string()).ok()?;
697    dbus.get_connection_unix_process_id(bus_name).await.ok()
698}
699
700async fn bounds_from_proxies(
701    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
702    proxy: &AccessibleProxy<'_>,
703) -> Option<Bounds> {
704    let owned_proxies;
705    let proxies = if let Some(proxies) = proxies {
706        proxies
707    } else {
708        owned_proxies = proxy.proxies().await.ok()?;
709        &owned_proxies
710    };
711    let component = proxies.component().await.ok()?;
712    let (x, y, width, height) = component.get_extents(CoordType::Screen).await.ok()?;
713    normalize_bounds(Bounds {
714        x,
715        y,
716        width,
717        height,
718    })
719}
720
721fn normalize_bounds(bounds: Bounds) -> Option<Bounds> {
722    if bounds.width <= 0 || bounds.height <= 0 {
723        return None;
724    }
725    if bounds.x <= i32::MIN / 2 || bounds.y <= i32::MIN / 2 {
726        return None;
727    }
728    Some(bounds)
729}
730
731async fn actions_from_proxies(
732    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
733) -> Vec<AccessibilityAction> {
734    let Some(proxies) = proxies else {
735        return Vec::new();
736    };
737    let Ok(action_proxy) = proxies.action().await else {
738        return Vec::new();
739    };
740
741    action_proxy
742        .get_actions()
743        .await
744        .unwrap_or_default()
745        .into_iter()
746        .enumerate()
747        .map(|(index, action)| AccessibilityAction {
748            index: index as i32,
749            name: action.name,
750            description: action.description,
751            keybinding: action.keybinding,
752        })
753        .collect()
754}
755
756async fn states_from_proxy(proxy: &AccessibleProxy<'_>) -> Vec<String> {
757    proxy
758        .get_state()
759        .await
760        .map(state_labels)
761        .unwrap_or_default()
762}
763
764async fn value_from_proxies(
765    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
766) -> Option<AccessibilityValue> {
767    let value = proxies?.value().await.ok()?;
768    Some(AccessibilityValue {
769        current: value.current_value().await.ok()?,
770        minimum: value.minimum_value().await.ok()?,
771        maximum: value.maximum_value().await.ok()?,
772        minimum_increment: value.minimum_increment().await.ok()?,
773        text: optional_string(value.text().await.ok()),
774    })
775}
776
777async fn text_from_proxies(
778    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
779) -> Option<AccessibilityText> {
780    let text = proxies?.text().await.ok()?;
781    let character_count = text.character_count().await.ok()?.max(0);
782    let caret_offset = text.caret_offset().await.ok();
783    let capped_count = character_count.min(MAX_TEXT_READBACK_CHARS);
784    let content = if capped_count > 0 {
785        optional_string(text.get_text(0, capped_count).await.ok())
786    } else {
787        None
788    };
789    let selection_count = text
790        .get_nselections()
791        .await
792        .unwrap_or_default()
793        .clamp(0, MAX_TEXT_SELECTIONS);
794    let mut selections = Vec::new();
795    for index in 0..selection_count {
796        if let Ok((start_offset, end_offset)) = text.get_selection(index).await {
797            selections.push(AccessibilityTextSelection {
798                start_offset,
799                end_offset,
800            });
801        }
802    }
803
804    Some(AccessibilityText {
805        character_count,
806        caret_offset,
807        content,
808        truncated: character_count > MAX_TEXT_READBACK_CHARS,
809        selections,
810    })
811}
812
813async fn supports_editable_text(proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>) -> bool {
814    let Some(proxies) = proxies else {
815        return false;
816    };
817    proxies.editable_text().await.is_ok()
818}
819
820fn state_labels(state_set: StateSet) -> Vec<String> {
821    state_set.iter().map(|state| state.to_string()).collect()
822}
823
824fn select_action_index(actions: &[atspi::Action], requested_action: Option<&str>) -> Result<i32> {
825    if actions.is_empty() {
826        return Err(anyhow!("element exposes no AT-SPI actions"));
827    }
828
829    if let Some(requested_action) = requested_action
830        .map(str::trim)
831        .filter(|value| !value.is_empty())
832    {
833        let requested_action = requested_action.to_ascii_lowercase();
834        if let Some((index, _)) = actions.iter().enumerate().find(|(_, action)| {
835            action.name.to_ascii_lowercase() == requested_action
836                || action.description.to_ascii_lowercase() == requested_action
837        }) {
838            return Ok(index as i32);
839        }
840
841        if let Ok(index) = requested_action.parse::<usize>() {
842            if index < actions.len() {
843                return Ok(index as i32);
844            }
845        }
846
847        return Err(anyhow!(
848            "requested AT-SPI action was not found; available actions: {}",
849            actions
850                .iter()
851                .map(|action| action.name.as_str())
852                .collect::<Vec<_>>()
853                .join(", ")
854        ));
855    }
856
857    Ok(if actions.len() > 1 { 1 } else { 0 })
858}
859
860fn select_named_action_index(actions: &[atspi::Action], name: &str) -> Result<i32> {
861    actions
862        .iter()
863        .position(|action| action.name.eq_ignore_ascii_case(name))
864        .map(|index| index as i32)
865        .ok_or_else(|| {
866            anyhow!("cached AT-SPI action name is no longer available; refresh get_app_state")
867        })
868}
869
870fn optional_string(value: Option<String>) -> Option<String> {
871    value
872        .map(|value| value.trim().to_string())
873        .filter(|value| !value.is_empty())
874}
875
876fn object_ref_from_id(object_ref_id: &str) -> Result<ObjectRefOwned> {
877    let (name, path) = split_object_ref_id(object_ref_id)?;
878    let name = UniqueName::try_from(name.to_string())
879        .with_context(|| format!("invalid AT-SPI bus name in object ref {object_ref_id}"))?;
880    let path = ObjectPath::try_from(path.to_string())
881        .with_context(|| format!("invalid AT-SPI object path in object ref {object_ref_id}"))?;
882    Ok(ObjectRef::new_owned(name, path))
883}
884
885fn split_object_ref_id(object_ref_id: &str) -> Result<(&str, &str)> {
886    let Some(path_start) = object_ref_id.find('/') else {
887        return Err(anyhow!(
888            "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
889        ));
890    };
891    let (name, path) = object_ref_id.split_at(path_start);
892    if name.is_empty() || path.is_empty() {
893        return Err(anyhow!(
894            "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
895        ));
896    }
897    Ok((name, path))
898}
899
900fn object_ref_id(object_ref: &ObjectRefOwned) -> String {
901    format!(
902        "{}{}",
903        object_ref.name_as_str().unwrap_or(""),
904        object_ref.path_as_str()
905    )
906}
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911
912    #[test]
913    fn split_object_ref_id_separates_bus_name_and_path() {
914        let (name, path) = split_object_ref_id(":1.42/org/a11y/atspi/accessible/7").unwrap();
915
916        assert_eq!(name, ":1.42");
917        assert_eq!(path, "/org/a11y/atspi/accessible/7");
918    }
919
920    #[test]
921    fn select_action_index_uses_named_action() {
922        let actions = vec![
923            atspi::Action {
924                name: "click".to_string(),
925                description: "Clicks".to_string(),
926                keybinding: String::new(),
927            },
928            atspi::Action {
929                name: "show-menu".to_string(),
930                description: "Shows menu".to_string(),
931                keybinding: String::new(),
932            },
933        ];
934
935        assert_eq!(select_action_index(&actions, Some("show-menu")).unwrap(), 1);
936    }
937
938    #[test]
939    fn cached_action_name_is_revalidated_against_current_actions() {
940        let mut actions = vec![atspi::Action {
941            name: "show-menu".into(),
942            description: "Click".into(),
943            keybinding: String::new(),
944        }];
945        // Neither a stale index nor a matching description may invoke another action.
946        assert!(select_named_action_index(&actions, "Click").is_err());
947        actions.push(atspi::Action {
948            name: "click".into(),
949            description: String::new(),
950            keybinding: String::new(),
951        });
952        assert_eq!(select_named_action_index(&actions, "Click").unwrap(), 1);
953    }
954
955    #[test]
956    fn select_action_index_defaults_to_secondary_when_available() {
957        let actions = vec![
958            atspi::Action {
959                name: "click".to_string(),
960                description: String::new(),
961                keybinding: String::new(),
962            },
963            atspi::Action {
964                name: "show-menu".to_string(),
965                description: String::new(),
966                keybinding: String::new(),
967            },
968        ];
969
970        assert_eq!(select_action_index(&actions, None).unwrap(), 1);
971    }
972
973    #[test]
974    fn state_labels_serialize_in_bit_order() {
975        let labels = state_labels(StateSet::new(atspi::State::Focused | atspi::State::Checked));
976
977        assert_eq!(labels, vec!["checked".to_string(), "focused".to_string()]);
978    }
979
980    #[test]
981    fn default_snapshot_limits_cover_deep_gtk4_trees() {
982        // Nautilus 50 places file-list cells below depth 20 and can expose
983        // more than 850 raw nodes. Keep the defaults above that known shape.
984        assert_eq!(snapshot_limits(None, None), (1_000, 32));
985    }
986
987    #[test]
988    fn requested_snapshot_limits_remain_bounded() {
989        assert_eq!(snapshot_limits(Some(0), Some(0)), (1, 0));
990        assert_eq!(snapshot_limits(Some(10_000), Some(128)), (2_000, 64));
991    }
992
993    #[test]
994    fn app_discovery_budget_is_independent_of_requested_tree_size() {
995        assert_eq!(snapshot_child_read_budgets(1), (256, 2_048, 1));
996    }
997
998    #[test]
999    fn traversal_attempts_and_queue_share_one_work_budget() {
1000        let mut traversal = BoundedTraversal::new(4);
1001        traversal.enqueue([1]);
1002
1003        assert_eq!(traversal.pop(), Some(1));
1004        traversal.enqueue(2..=10_000);
1005        assert_eq!(traversal.queue, VecDeque::from([2, 3, 4]));
1006
1007        assert_eq!(traversal.pop(), Some(2));
1008        traversal.enqueue(5..=10_000);
1009        assert_eq!(traversal.queue, VecDeque::from([3, 4]));
1010        assert_eq!(traversal.pop(), Some(3));
1011        assert_eq!(traversal.pop(), Some(4));
1012        assert_eq!(traversal.pop(), None);
1013        assert_eq!(traversal.attempted, 4);
1014    }
1015
1016    #[test]
1017    fn child_count_is_clamped_before_indexed_reads() {
1018        assert_eq!(bounded_child_count(-1, 4), 0);
1019        assert_eq!(bounded_child_count(3, 4), 3);
1020        assert_eq!(bounded_child_count(i32::MAX, 4), 4);
1021    }
1022
1023    #[tokio::test]
1024    async fn indexed_child_reads_consume_attempts_even_when_one_fails() {
1025        let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1026        let mut remaining_attempts = 3;
1027        let batch = fetch_indexed_up_to(100, 10, &mut remaining_attempts, {
1028            let calls = calls.clone();
1029            move |index| {
1030                let calls = calls.clone();
1031                async move {
1032                    calls.lock().unwrap().push(index);
1033                    if index == 1 {
1034                        Err(())
1035                    } else {
1036                        Ok(index)
1037                    }
1038                }
1039            }
1040        })
1041        .await;
1042
1043        assert_eq!(batch.items, vec![0, 2]);
1044        assert_eq!(batch.attempted, 3);
1045        assert!(!batch.all_failed());
1046        assert_eq!(*calls.lock().unwrap(), vec![0, 1, 2]);
1047        assert_eq!(remaining_attempts, 0);
1048
1049        let no_children = fetch_indexed_up_to(100, 10, &mut remaining_attempts, |_| async {
1050            Ok::<_, ()>(99)
1051        })
1052        .await;
1053        assert!(no_children.items.is_empty());
1054        assert_eq!(no_children.attempted, 0);
1055        assert!(!no_children.all_failed());
1056
1057        let mut failed_attempts = 2;
1058        let all_failed =
1059            fetch_indexed_up_to(2, 2, &mut failed_attempts, |_| async { Err::<i32, _>(()) }).await;
1060        assert!(all_failed.all_failed());
1061    }
1062}