Skip to main content

computer_use_linux/
atspi_tree.rs

1use crate::diagnostics::hydrate_session_bus_env;
2use anyhow::{anyhow, Context, Result};
3use atspi::{
4    proxy::{
5        accessible::{AccessibleProxy, ObjectRefExt},
6        action::ActionProxy,
7        editable_text::EditableTextProxy,
8        proxy_ext::{Proxies, ProxyExt},
9        text::TextProxy,
10    },
11    CoordType, ObjectRef, ObjectRefOwned, StateSet,
12};
13// Direct dependency (p2p feature off) — see Cargo.toml for why we bypass
14// atspi's "connection" re-export.
15use atspi_connection::AccessibilityConnection;
16use futures_util::{stream, StreamExt};
17use schemars::JsonSchema;
18use serde::Serialize;
19use std::{collections::VecDeque, future::Future, time::Duration};
20use tokio::time::{sleep_until, timeout, timeout_at, Instant};
21use zbus::{
22    fdo::DBusProxy,
23    names::{BusName, UniqueName},
24    zvariant::{ObjectPath, OwnedObjectPath},
25};
26
27#[derive(Debug, Clone, Serialize, JsonSchema)]
28pub struct AccessibleAppSummary {
29    pub object_ref: String,
30    pub name: Option<String>,
31    pub pid: Option<u32>,
32    pub role: String,
33    pub child_count: i32,
34    pub bounds: Option<Bounds>,
35}
36
37#[derive(Debug, Clone, Serialize, JsonSchema)]
38pub struct AccessibilityNode {
39    pub index: u32,
40    pub parent_index: Option<u32>,
41    pub depth: u32,
42    pub object_ref: String,
43    pub role: String,
44    pub name: Option<String>,
45    pub description: Option<String>,
46    pub child_count: i32,
47    pub bounds: Option<Bounds>,
48    pub states: Vec<String>,
49    pub actions: Vec<AccessibilityAction>,
50    pub value: Option<AccessibilityValue>,
51    pub text: Option<AccessibilityText>,
52    pub supports_editable_text: bool,
53}
54
55#[derive(Debug, Clone, Serialize, JsonSchema)]
56pub struct Bounds {
57    pub x: i32,
58    pub y: i32,
59    pub width: i32,
60    pub height: i32,
61}
62
63#[derive(Debug, Clone, Serialize, JsonSchema)]
64pub struct AccessibilityAction {
65    pub index: i32,
66    pub name: String,
67    pub description: String,
68    pub keybinding: String,
69}
70
71#[derive(Debug, Clone, Serialize, JsonSchema)]
72pub struct AccessibilityValue {
73    pub current: f64,
74    pub minimum: f64,
75    pub maximum: f64,
76    pub minimum_increment: f64,
77    pub text: Option<String>,
78}
79
80#[derive(Debug, Clone, Serialize, JsonSchema)]
81pub struct AccessibilityText {
82    pub character_count: i32,
83    pub caret_offset: Option<i32>,
84    pub content: Option<String>,
85    pub truncated: bool,
86    pub selections: Vec<AccessibilityTextSelection>,
87}
88
89#[derive(Debug, Clone, Serialize, JsonSchema)]
90pub struct AccessibilityTextSelection {
91    pub start_offset: i32,
92    pub end_offset: i32,
93}
94
95#[derive(Debug, Clone)]
96pub struct ActionInvocation {
97    pub action_index: i32,
98    pub action_name: Option<String>,
99    pub ok: bool,
100}
101
102#[derive(Debug, Clone)]
103pub enum ValueSetInvocation {
104    Numeric { value: f64 },
105    EditableText { outcome: EditableTextSetOutcome },
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum EditableTextSetOutcome {
110    Verified,
111    MismatchAfterDeadline,
112    ReadbackFailed,
113    ReadbackTimedOut,
114    PasswordUnverifiable,
115}
116
117const MAX_TEXT_READBACK_CHARS: i32 = 4096;
118const EDITABLE_TEXT_READ_CHUNK_UNITS: usize = 4096;
119const EDITABLE_TEXT_VERIFY_TIMEOUT: Duration = Duration::from_secs(1);
120const EDITABLE_TEXT_VERIFY_INTERVAL: Duration = Duration::from_millis(50);
121const MAX_TEXT_SELECTIONS: i32 = 8;
122const DEFAULT_SNAPSHOT_MAX_NODES: usize = 1_000;
123// Larger trees are opt-in; the default and the 10-second deadline stay unchanged.
124const HARD_SNAPSHOT_MAX_NODES: usize = 10_000;
125const DEFAULT_SNAPSHOT_MAX_DEPTH: u32 = 32;
126const HARD_SNAPSHOT_MAX_DEPTH: u32 = 64;
127const CHILD_READ_CONCURRENCY: usize = 16;
128const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
129/// Time past `SNAPSHOT_TIMEOUT` for the node being read to finish; a node still
130/// reading after it is dropped and the tree is returned truncated.
131const SNAPSHOT_HARD_GRACE: Duration = Duration::from_secs(2);
132const MAX_ACTIONS_PER_OBJECT: i32 = 64;
133const ACTION_QUERY_TIMEOUT: Duration = Duration::from_secs(5);
134const MAX_DISCOVERY_ROOTS: usize = 256;
135const ROOT_MATCH_CHILD_LIMIT: usize = 8;
136const MAX_DISCOVERY_CHILD_READS: usize = MAX_DISCOVERY_ROOTS * ROOT_MATCH_CHILD_LIMIT;
137
138fn snapshot_child_read_budgets(max_nodes: usize) -> (usize, usize, usize) {
139    (MAX_DISCOVERY_ROOTS, MAX_DISCOVERY_CHILD_READS, max_nodes)
140}
141
142pub(crate) fn snapshot_limits(
143    requested_max_nodes: Option<usize>,
144    requested_max_depth: Option<u32>,
145) -> (usize, u32) {
146    (
147        requested_max_nodes
148            .unwrap_or(DEFAULT_SNAPSHOT_MAX_NODES)
149            .clamp(1, HARD_SNAPSHOT_MAX_NODES),
150        requested_max_depth
151            .unwrap_or(DEFAULT_SNAPSHOT_MAX_DEPTH)
152            .min(HARD_SNAPSHOT_MAX_DEPTH),
153    )
154}
155
156struct BoundedTraversal<T> {
157    queue: VecDeque<T>,
158    attempted: usize,
159    max_items: usize,
160    /// Set once an enqueue had to drop offered items for lack of capacity.
161    dropped: bool,
162}
163
164impl<T> BoundedTraversal<T> {
165    fn new(max_items: usize) -> Self {
166        Self {
167            queue: VecDeque::new(),
168            attempted: 0,
169            max_items,
170            dropped: false,
171        }
172    }
173
174    fn enqueue(&mut self, items: impl IntoIterator<Item = T>) {
175        let capacity = self.remaining_capacity();
176        let mut items = items.into_iter();
177        self.queue.extend(items.by_ref().take(capacity));
178        if items.next().is_some() {
179            self.dropped = true;
180        }
181    }
182
183    fn pop(&mut self) -> Option<T> {
184        if self.attempted >= self.max_items {
185            return None;
186        }
187        let item = self.queue.pop_front()?;
188        self.attempted += 1;
189        Some(item)
190    }
191
192    fn remaining_capacity(&self) -> usize {
193        self.max_items
194            .saturating_sub(self.attempted.saturating_add(self.queue.len()))
195    }
196
197    /// Drop pending and earlier dropped work, keeping the spent budget, so the
198    /// rest of the budget goes to the subtree enqueued next.
199    fn narrow(&mut self) {
200        self.queue.clear();
201        self.dropped = false;
202    }
203
204    /// True when the budget stopped work that was actually offered. `enqueue`
205    /// is the only gate: it never exceeds `remaining_capacity`, so
206    /// `attempted + queue.len() <= max_items` always holds and the queue can
207    /// never be non-empty at the attempt cap. Dropped items are the whole story.
208    fn truncated(&self) -> bool {
209        self.dropped
210    }
211}
212
213fn bounded_child_count(reported: i32, limit: usize) -> usize {
214    usize::try_from(reported).unwrap_or_default().min(limit)
215}
216
217struct IndexedReadBatch<T> {
218    items: Vec<T>,
219    attempted: usize,
220    /// True when fewer children were attempted than the parent reported, because
221    /// the caller's limit or the shared read budget ran out. Failed reads do not
222    /// count; they were attempted.
223    incomplete: bool,
224}
225
226impl<T> IndexedReadBatch<T> {
227    fn all_failed(&self) -> bool {
228        self.attempted > 0 && self.items.is_empty()
229    }
230}
231
232async fn fetch_indexed_up_to<T, E, F, Fut>(
233    reported: i32,
234    limit: usize,
235    remaining_attempts: &mut usize,
236    fetch: F,
237) -> IndexedReadBatch<T>
238where
239    F: Fn(i32) -> Fut,
240    Fut: Future<Output = std::result::Result<T, E>>,
241{
242    let attempt_count = bounded_child_count(reported, limit).min(*remaining_attempts);
243    *remaining_attempts = (*remaining_attempts).saturating_sub(attempt_count);
244    let end_index = i32::try_from(attempt_count).unwrap_or(i32::MAX);
245
246    let items = stream::iter(0..end_index)
247        .map(fetch)
248        .buffered(CHILD_READ_CONCURRENCY)
249        .filter_map(|result| async move { result.ok() })
250        .collect()
251        .await;
252
253    IndexedReadBatch {
254        items,
255        attempted: attempt_count,
256        incomplete: attempt_count < usize::try_from(reported).unwrap_or_default(),
257    }
258}
259
260async fn children_up_to(
261    proxy: &AccessibleProxy<'_>,
262    limit: usize,
263    remaining_attempts: &mut usize,
264) -> zbus::Result<IndexedReadBatch<ObjectRefOwned>> {
265    if limit == 0 || *remaining_attempts == 0 {
266        // Budget already spent: stay I/O-free. `incomplete` is false here because
267        // nothing was asked of the parent; callers that already hold the node's
268        // child_count (snapshot_tree_inner) derive truncation from that instead.
269        return Ok(IndexedReadBatch {
270            items: Vec::new(),
271            attempted: 0,
272            incomplete: false,
273        });
274    }
275
276    let child_count = proxy.child_count().await?;
277    Ok(
278        fetch_indexed_up_to(child_count, limit, remaining_attempts, |index| {
279            child_at_index(proxy, index)
280        })
281        .await,
282    )
283}
284
285async fn child_at_index(proxy: &AccessibleProxy<'_>, index: i32) -> zbus::Result<ObjectRefOwned> {
286    // WebKit sockets return a well-known bus name. atspi's ObjectRef decoder
287    // only accepts unique names, so resolve the wire reference before using it.
288    let (name, path): (String, OwnedObjectPath) =
289        proxy.inner().call("GetChildAtIndex", &(index,)).await?;
290    if path.as_str() == "/org/a11y/atspi/null" {
291        return Ok(ObjectRef::Null.into());
292    }
293    match BusName::try_from(name)? {
294        BusName::Unique(name) => Ok(ObjectRef::new_owned(name, path)),
295        name => {
296            let owner = DBusProxy::new(proxy.inner().connection())
297                .await?
298                .get_name_owner(name)
299                .await?;
300            Ok(ObjectRef::new_owned(owner, path))
301        }
302    }
303}
304
305pub async fn list_accessible_apps(limit: usize) -> Result<Vec<AccessibleAppSummary>> {
306    let conn = connect().await?;
307    let mut remaining_child_reads = limit;
308    let roots = registry_children(&conn, limit, &mut remaining_child_reads).await?;
309    let dbus = DBusProxy::new(conn.connection()).await.ok();
310    let mut apps = Vec::new();
311
312    for object_ref in roots.into_iter().take(limit) {
313        if let Ok(proxy) = open_accessible(&conn, &object_ref).await {
314            apps.push(read_app_summary(&proxy, &object_ref, dbus.as_ref()).await);
315        }
316    }
317
318    Ok(apps)
319}
320
321#[derive(Debug, Clone)]
322pub(crate) struct AccessibilitySnapshot {
323    pub nodes: Vec<AccessibilityNode>,
324    /// True when registry roots were filtered to an app name and/or pid.
325    pub scoped: bool,
326    /// The pid every root belongs to, when roots were matched by pid. None
327    /// when they were matched by app name alone or not filtered at all: those
328    /// roots can belong to any app, so no single pid describes the tree.
329    pub root_pid: Option<u32>,
330    /// True when selected roots satisfy the supplied app/name filter, or no
331    /// filter was supplied. Normal snapshots retain their PID-only fallback;
332    /// action recovery requires this evidence before invoking a fresh node.
333    pub app_filter_matched: bool,
334    /// True when max_nodes, max_depth, the child read budget, or the deadline stopped
335    /// traversal with unread elements left. Failed element reads do not count.
336    pub truncated: bool,
337    /// Name of the AT-SPI window the tree was narrowed to, when an app root
338    /// exposed several windows and exactly one matched the window title.
339    pub window_scope: Option<String>,
340}
341
342pub async fn snapshot_tree(
343    app_name_or_bundle_identifier: Option<&str>,
344    target_pid: Option<u32>,
345    max_nodes: usize,
346    max_depth: u32,
347) -> Result<Vec<AccessibilityNode>> {
348    Ok(snapshot_accessibility_tree(
349        app_name_or_bundle_identifier,
350        target_pid,
351        None,
352        None,
353        max_nodes,
354        max_depth,
355    )
356    .await?
357    .nodes)
358}
359
360/// Read the accessibility tree of the matching app roots. With
361/// `window_title`, an app root that exposes several windows (Chromium puts
362/// every browser window under one application) is read only through the
363/// window whose name matches it; its other children stay.
364/// `pid_fallback_name` finds the roots by name when `target_pid` owns none;
365/// pass it only for a pid read from a window the compositor listed.
366pub(crate) async fn snapshot_accessibility_tree(
367    app_name_or_bundle_identifier: Option<&str>,
368    target_pid: Option<u32>,
369    window_title: Option<&str>,
370    pid_fallback_name: Option<&str>,
371    max_nodes: usize,
372    max_depth: u32,
373) -> Result<AccessibilitySnapshot> {
374    let (max_nodes, max_depth) = snapshot_limits(Some(max_nodes), Some(max_depth));
375    // Traversal stops at the deadline and returns what it read, marked truncated.
376    // The outer timeout only bounds app discovery, which runs before any node
377    // is read; the traversal itself ends by `stop_reading` plus the grace.
378    let stop_reading = Instant::now() + SNAPSHOT_TIMEOUT;
379    timeout(
380        SNAPSHOT_TIMEOUT + SNAPSHOT_HARD_GRACE + SNAPSHOT_HARD_GRACE,
381        snapshot_tree_inner(
382            app_name_or_bundle_identifier,
383            target_pid,
384            window_title
385                .map(str::trim)
386                .filter(|title| !title.is_empty()),
387            pid_fallback_name,
388            max_nodes,
389            max_depth,
390            stop_reading,
391        ),
392    )
393    .await
394    .context("AT-SPI snapshot could not find its target before the deadline")?
395}
396
397async fn snapshot_tree_inner(
398    app_name_or_bundle_identifier: Option<&str>,
399    target_pid: Option<u32>,
400    window_title: Option<&str>,
401    pid_fallback_name: Option<&str>,
402    max_nodes: usize,
403    max_depth: u32,
404    stop_reading: Instant,
405) -> Result<AccessibilitySnapshot> {
406    let conn = connect().await?;
407    // App discovery is bounded independently so a tiny requested tree still
408    // finds a target registered after the first accessibility root.
409    let (mut remaining_registry_reads, mut remaining_filter_reads, mut remaining_traversal_reads) =
410        snapshot_child_read_budgets(max_nodes);
411    let roots =
412        registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
413    let selected_roots = select_roots(
414        &conn,
415        roots,
416        app_name_or_bundle_identifier,
417        target_pid,
418        pid_fallback_name,
419        &mut remaining_filter_reads,
420    )
421    .await;
422    let scoped = selected_roots.scoped;
423    let root_pid = selected_roots.pid;
424    let app_filter_matched = selected_roots.app_filter_matched;
425    let mut nodes = Vec::new();
426    let mut truncated = false;
427    let mut window_scope = None;
428    let mut traversal = BoundedTraversal::new(max_nodes);
429
430    traversal.enqueue(
431        selected_roots
432            .roots
433            .into_iter()
434            .map(|object_ref| (object_ref, 0_u32, None)),
435    );
436
437    // Each node's reads may run past `stop_reading` by the grace, never more: a
438    // slow action query (up to ACTION_QUERY_TIMEOUT) near the deadline cuts the
439    // tree there instead of losing it to the outer timeout.
440    let node_cutoff = stop_reading + SNAPSHOT_HARD_GRACE;
441    while let Some((object_ref, depth, parent_index)) = traversal.pop() {
442        if Instant::now() >= stop_reading {
443            truncated = true;
444            break;
445        }
446        let index = nodes.len() as u32;
447        let remaining = traversal.remaining_capacity();
448        let read = timeout_at(node_cutoff, async {
449            let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
450                return None;
451            };
452            let node = read_node(&proxy, &object_ref, index, parent_index, depth).await;
453            let mut cut = false;
454            let mut scope = None;
455            let child_refs = if depth < max_depth && remaining > 0 {
456                match children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await {
457                    Ok(batch) => {
458                        // A read-budget cut inside the fetch reports `incomplete`; an
459                        // already-exhausted budget returns the I/O-free empty batch, so
460                        // fall back to the child_count read_node already fetched.
461                        cut = batch.incomplete || (batch.attempted == 0 && node.child_count > 0);
462                        match window_title.filter(|_| depth == 0) {
463                            Some(title) => {
464                                let (children, matched) =
465                                    scope_to_window(&conn, batch.items, title).await;
466                                scope = matched;
467                                children
468                            }
469                            None => batch.items,
470                        }
471                    }
472                    Err(_) => Vec::new(),
473                }
474            } else {
475                // Depth or node cap reached: any child this node reports is unread.
476                cut = node.child_count > 0;
477                Vec::new()
478            };
479            Some((node, child_refs, cut, scope))
480        })
481        .await;
482        let Ok(read) = read else {
483            truncated = true;
484            break;
485        };
486        let Some((node, child_refs, cut, scope)) = read else {
487            continue;
488        };
489        truncated |= cut;
490        if scope.is_some() {
491            window_scope = scope;
492        }
493        nodes.push(node);
494
495        traversal.enqueue(
496            child_refs
497                .into_iter()
498                .map(|child| (child, depth + 1, Some(index))),
499        );
500    }
501    truncated |= traversal.truncated();
502
503    Ok(AccessibilitySnapshot {
504        nodes,
505        scoped,
506        root_pid,
507        app_filter_matched,
508        truncated,
509        window_scope,
510    })
511}
512
513/// Children of an app root, narrowed to the window named `title` when the root
514/// holds several windows. Returns the children to read and the matched window
515/// name. Only the other named frames are dropped: popups, menus, dialogs and
516/// unnamed windows are kept, since Chromium puts a window's popups and dialogs
517/// beside it under the app root.
518async fn scope_to_window(
519    conn: &AccessibilityConnection,
520    children: Vec<ObjectRefOwned>,
521    title: &str,
522) -> (Vec<ObjectRefOwned>, Option<String>) {
523    // An app root has a handful of windows; read them in order.
524    let mut labels = Vec::with_capacity(children.len());
525    for child in &children {
526        labels.push(match open_accessible(conn, child).await {
527            Ok(proxy) => (
528                role_name(&proxy).await,
529                optional_string(proxy.name().await.ok()),
530            ),
531            Err(_) => (String::new(), None),
532        });
533    }
534    let Some(chosen) = matching_window(&labels, title) else {
535        return (children, None);
536    };
537    let name = labels[chosen].1.clone();
538    let keep = kept_beside_window(&labels, chosen);
539    let kept = children
540        .into_iter()
541        .zip(keep)
542        .filter_map(|(child, keep)| keep.then_some(child))
543        .collect();
544    (kept, name)
545}
546
547/// Which app root children (`(role, name)`) stay when `chosen` is the targeted
548/// window: it, and every child that is not another named frame.
549fn kept_beside_window(children: &[(String, Option<String>)], chosen: usize) -> Vec<bool> {
550    children
551        .iter()
552        .enumerate()
553        .map(|(index, (role, name))| {
554            index == chosen
555                || role != "frame"
556                || name.as_deref().is_none_or(|name| name.trim().is_empty())
557        })
558        .collect()
559}
560
561pub(crate) fn is_window_role(role: &str) -> bool {
562    matches!(role, "frame" | "window" | "dialog" | "file chooser")
563}
564
565/// Index of the one window among an app root's children (`(role, name)`)
566/// that `title` names, when the root has at least two windows. An exact name
567/// wins, then a case-insensitive one, then the one name that contains
568/// `title`, ignoring case. A tie at the first rule that matches returns
569/// `None`, which keeps every window. With no match at all, the longest name
570/// that `title` starts with, followed by a decoration (` (...)` or
571/// ` - ...`), wins: a compositor title such as Chromium's
572/// "PlanetScale - Chromium (Profile 1)" decorates the AT-SPI frame name
573/// "PlanetScale - Chromium".
574fn matching_window(children: &[(String, Option<String>)], title: &str) -> Option<usize> {
575    let windows: Vec<(usize, &str)> = children
576        .iter()
577        .enumerate()
578        .filter(|(_, (role, _))| is_window_role(role))
579        .map(|(index, (_, name))| (index, name.as_deref().unwrap_or_default().trim()))
580        .collect();
581    if windows.len() < 2 {
582        return None;
583    }
584    let title = title.trim();
585    let lowered = title.to_lowercase();
586    let rules: [&dyn Fn(&str) -> bool; 3] = [
587        &|name| name == title,
588        &|name| name.to_lowercase() == lowered,
589        &|name| name.to_lowercase().contains(&lowered),
590    ];
591    for rule in rules {
592        let mut matches = windows.iter().filter(|(_, name)| rule(name));
593        if let Some((index, _)) = matches.next() {
594            return matches.next().is_none().then_some(*index);
595        }
596    }
597    let decorated = |name: &str| {
598        let name = name.to_lowercase();
599        !name.is_empty()
600            && lowered.strip_prefix(&name).is_some_and(|rest| {
601                rest.starts_with(" (") || rest.starts_with(" - ") || rest.starts_with(" \u{2014} ")
602            })
603    };
604    let decorated_matches = windows.iter().filter(|(_, name)| decorated(name));
605    let longest = decorated_matches
606        .clone()
607        .map(|(_, name)| name.len())
608        .max()?;
609    let mut longest_matches = decorated_matches.filter(|(_, name)| name.len() == longest);
610    let (index, _) = longest_matches.next()?;
611    longest_matches.next().is_none().then_some(*index)
612}
613
614/// Compact description of the AT-SPI element that currently holds keyboard
615/// focus, used as post-input feedback for type_text/press_key.
616#[derive(Debug, Clone, Serialize, JsonSchema)]
617pub struct FocusedElementSummary {
618    pub role: String,
619    pub name: Option<String>,
620    pub editable: bool,
621    pub states: Vec<String>,
622    /// AT-SPI `Role::Terminal`, read from the role enum rather than the
623    /// localized role name. Internal routing only; not part of the output.
624    #[serde(skip)]
625    #[schemars(skip)]
626    pub is_terminal: bool,
627}
628
629impl FocusedElementSummary {
630    /// A field that takes typed text: editable, or a text-entry role.
631    pub(crate) fn accepts_text(&self) -> bool {
632        self.editable
633            || self.states.iter().any(|state| state == "editable")
634            || matches!(
635                self.role.as_str(),
636                "text" | "entry" | "password text" | "combo box"
637            )
638    }
639}
640
641const FOCUS_PROBE_MAX_NODES: usize = 400;
642const FOCUS_PROBE_MAX_DEPTH: u32 = 16;
643
644/// Find the element with the `focused` state inside the target app (by pid) or
645/// across all apps. Best-effort and bounded: returns Ok(None) when no focused
646/// element is reachable through AT-SPI (common for apps without accessibility
647/// support, e.g. Electron without --force-renderer-accessibility).
648/// Outcome of the bounded focused-element search.
649#[derive(Debug, Clone)]
650pub(crate) enum FocusProbe {
651    Found(FocusedElementSummary),
652    /// The search covered the target's whole tree and nothing holds focus.
653    NoneFocused,
654    /// A complete scoped search saw only window/menu chrome, no content.
655    ShellOnly,
656    /// A node, depth, or read limit stopped the search before it finished, so
657    /// the focused element may exist past the limit.
658    Incomplete,
659    /// A pid was given but no AT-SPI app belongs to it (xterm, or Electron
660    /// without --force-renderer-accessibility), so focus cannot be read.
661    NoAccessibleApp,
662}
663
664/// Focused element in the app owning `target_pid`, or across all apps when no
665/// pid is given. With a pid, only that app's tree is searched: falling back to
666/// every other app would report some unrelated widget as the focus.
667pub(crate) async fn probe_focused_element(target_pid: Option<u32>) -> Result<FocusProbe> {
668    focused_element_probe(target_pid, target_pid.is_some()).await
669}
670
671/// The focused element in the app that owns `target_pid`, for callers that
672/// only need an answer when one exists. Every non-`Found` probe result maps to
673/// `None`; see [`probe_focused_element`] for the distinctions.
674pub(crate) async fn focused_element_summary_in_app(
675    target_pid: u32,
676) -> Result<Option<FocusedElementSummary>> {
677    Ok(match focused_element_probe(Some(target_pid), true).await? {
678        FocusProbe::Found(summary) => Some(summary),
679        _ => None,
680    })
681}
682
683async fn focused_element_probe(
684    target_pid: Option<u32>,
685    require_scoped: bool,
686) -> Result<FocusProbe> {
687    let conn = connect().await?;
688    let mut remaining_registry_reads = MAX_DISCOVERY_ROOTS;
689    let roots =
690        registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
691    let mut remaining_filter_reads = MAX_DISCOVERY_CHILD_READS;
692    let selected_roots = select_roots(
693        &conn,
694        roots,
695        None,
696        target_pid,
697        None,
698        &mut remaining_filter_reads,
699    )
700    .await;
701    if require_scoped && selected_roots.pid != target_pid {
702        return Ok(FocusProbe::NoAccessibleApp);
703    }
704    let mut traversal = BoundedTraversal::new(FOCUS_PROBE_MAX_NODES);
705    let mut remaining_traversal_reads = FOCUS_PROBE_MAX_NODES;
706    let mut incomplete = false;
707    let mut shell_only = true;
708    let mut observed_nodes = 0;
709    let mut focused_container = None;
710    let mut depth_base = 0_u32;
711
712    traversal.enqueue(
713        selected_roots
714            .roots
715            .into_iter()
716            .map(|object_ref| (object_ref, 0_u32, false)),
717    );
718
719    while let Some((object_ref, depth, in_menu)) = traversal.pop() {
720        let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
721            incomplete = true;
722            continue;
723        };
724        let Ok(state) = proxy.get_state().await else {
725            incomplete = true;
726            continue;
727        };
728        observed_nodes += 1;
729        let mut child_menu = false;
730        if shell_only {
731            match accessibility_chrome_context(&role_name(&proxy).await, in_menu) {
732                Some(menu) => child_menu = menu,
733                None => shell_only = false,
734            }
735        }
736        if state.contains(atspi::State::Focused) {
737            let proxies = proxy.proxies().await.ok();
738            let is_terminal = matches!(proxy.get_role().await, Ok(atspi::Role::Terminal));
739            let summary = FocusedElementSummary {
740                role: role_name(&proxy).await,
741                name: optional_string(proxy.name().await.ok()),
742                editable: supports_editable_text(proxies.as_ref()).await,
743                states: state_labels(state),
744                is_terminal,
745            };
746            // Chromium can mark both a document and its contenteditable entry
747            // focused. Do not stop at the ancestor before visiting the editor:
748            // the focus is inside it, so the rest of the budget searches only
749            // its subtree, and nodes left unread elsewhere no longer matter.
750            if focus_is_container(&summary) {
751                focused_container = Some(summary);
752                // Only within one app: across apps a stale focused document in
753                // another app must not hide the real focus.
754                if require_scoped {
755                    traversal.narrow();
756                    // The discarded queue had spent its child reads; the subtree
757                    // gets the rest of the node budget, and its depth counts from
758                    // the container.
759                    remaining_traversal_reads = traversal.remaining_capacity();
760                    depth_base = depth;
761                    incomplete = false;
762                }
763            } else {
764                return Ok(FocusProbe::Found(summary));
765            }
766        }
767        let remaining = traversal.remaining_capacity();
768        if depth - depth_base < FOCUS_PROBE_MAX_DEPTH && remaining > 0 {
769            if let Ok(batch) =
770                children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await
771            {
772                incomplete |= batch.incomplete;
773                // An exhausted shared read budget takes children_up_to's
774                // I/O-free path (attempted 0, incomplete false), so the node's
775                // own child count has to say whether children went unread.
776                // With budget left, attempted 0 just means a leaf: no read.
777                if batch.attempted == 0 && remaining_traversal_reads == 0 && !incomplete {
778                    incomplete = proxy.child_count().await.map_or(true, |count| count > 0);
779                }
780                traversal.enqueue(
781                    batch
782                        .items
783                        .into_iter()
784                        .map(|child| (child, depth + 1, child_menu)),
785                );
786            } else {
787                incomplete = true;
788            }
789        } else if !incomplete {
790            // Depth or node cap: any child this node reports goes unread. One
791            // child-count read settles it, and only until the first cut is seen.
792            incomplete = proxy.child_count().await.map_or(true, |count| count > 0);
793        }
794    }
795    Ok(finish_focus_probe(
796        incomplete || traversal.truncated(),
797        require_scoped && shell_only && observed_nodes > 0,
798        focused_container,
799    ))
800}
801
802pub(crate) fn focus_is_container(element: &FocusedElementSummary) -> bool {
803    !element.editable
804        && !element.states.iter().any(|state| state == "editable")
805        && !element.is_terminal
806        && matches!(
807            element.role.as_str(),
808            "application"
809                | "frame"
810                | "window"
811                | "panel"
812                | "filler"
813                | "section"
814                | "document web"
815                | "document frame"
816                | "document text"
817        )
818}
819
820fn finish_focus_probe(
821    incomplete: bool,
822    shell_only: bool,
823    focused_container: Option<FocusedElementSummary>,
824) -> FocusProbe {
825    // A focused container is not a definitive recipient if descendants were
826    // unread. Preserve uncertainty rather than reporting a non-editable field.
827    if incomplete {
828        FocusProbe::Incomplete
829    } else if let Some(container) = focused_container {
830        FocusProbe::Found(container)
831    } else if shell_only {
832        FocusProbe::ShellOnly
833    } else {
834        FocusProbe::NoneFocused
835    }
836}
837
838/// Window and native menu chrome can exist without Electron renderer content.
839/// This is evidence about tree coverage, not positive Electron detection.
840pub(crate) fn accessibility_chrome_context(role: &str, in_menu: bool) -> Option<bool> {
841    match role {
842        "application" | "frame" | "window" | "panel" | "filler" | "separator" => Some(in_menu),
843        "menu bar" | "menu" | "menu item" | "check menu item" | "radio menu item"
844        | "tearoff menu item" => Some(true),
845        // Electron's native menu buttons are exposed as buttons, not menu items.
846        "button" | "push button" | "label" | "static" | "image" | "icon" if in_menu => Some(true),
847        _ => None,
848    }
849}
850
851pub async fn perform_action(
852    object_ref_id: &str,
853    requested_action: Option<&str>,
854) -> Result<ActionInvocation> {
855    perform_action_inner(object_ref_id, requested_action, false).await
856}
857
858pub(crate) async fn perform_named_action(
859    object_ref_id: &str,
860    name: &str,
861) -> Result<ActionInvocation> {
862    perform_action_inner(object_ref_id, Some(name), true).await
863}
864
865async fn perform_action_inner(
866    object_ref_id: &str,
867    requested_action: Option<&str>,
868    name_only: bool,
869) -> Result<ActionInvocation> {
870    let object_ref = object_ref_from_id(object_ref_id)?;
871    let (action, action_index, action_name) = timeout(ACTION_QUERY_TIMEOUT, async {
872        let conn = connect().await?;
873        let proxy = open_accessible(&conn, &object_ref)
874            .await
875            .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
876        let action = proxy
877            .proxies()
878            .await?
879            .action()
880            .await
881            .context("element does not expose the AT-SPI Action interface")?;
882        let actions = read_actions(&action).await?;
883        let action_index = if name_only {
884            select_named_action_index(&actions, requested_action.unwrap_or_default())?
885        } else {
886            select_action_index(&actions, requested_action)?
887        };
888        let action_name = actions
889            .get(action_index as usize)
890            .map(|action| action.name.clone());
891        Ok::<_, anyhow::Error>((action, action_index, action_name))
892    })
893    .await
894    .context("AT-SPI action revalidation exceeded its 5-second deadline")??;
895    // Only time out the reads: once DoAction is sent, it may already have acted.
896    let ok = action
897        .do_action(action_index)
898        .await
899        .with_context(|| format!("failed to invoke AT-SPI action {action_index}"))?;
900
901    Ok(ActionInvocation {
902        action_index,
903        action_name,
904        ok,
905    })
906}
907
908pub async fn set_element_value(object_ref_id: &str, value: &str) -> Result<ValueSetInvocation> {
909    let conn = connect().await?;
910    let object_ref = object_ref_from_id(object_ref_id)?;
911    let proxy = open_accessible(&conn, &object_ref)
912        .await
913        .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
914    let proxies = proxy.proxies().await?;
915    set_element_value_with_proxies(&proxy, &proxies, value).await
916}
917
918async fn set_element_value_with_proxies(
919    proxy: &AccessibleProxy<'_>,
920    proxies: &Proxies<'_>,
921    value: &str,
922) -> Result<ValueSetInvocation> {
923    if let Ok(numeric_value) = value.parse::<f64>() {
924        if let Ok(value_proxy) = proxies.value().await {
925            value_proxy
926                .set_current_value(numeric_value)
927                .await
928                .with_context(|| {
929                    format!("failed to set AT-SPI numeric value to {numeric_value}")
930                })?;
931            return Ok(ValueSetInvocation::Numeric {
932                value: numeric_value,
933            });
934        }
935    }
936
937    if let Ok(editable_text) = proxies.editable_text().await {
938        return set_editable_text_contents(proxy, proxies, &editable_text, value).await;
939    }
940
941    if value.parse::<f64>().is_err() && proxies.value().await.is_ok() {
942        return Err(anyhow!(
943            "element exposes the AT-SPI Value interface, but the requested value is not numeric"
944        ));
945    }
946
947    Err(anyhow!(
948        "element does not expose AT-SPI Value or EditableText interfaces"
949    ))
950}
951
952async fn set_editable_text_contents(
953    proxy: &AccessibleProxy<'_>,
954    proxies: &Proxies<'_>,
955    editable_text: &EditableTextProxy<'_>,
956    value: &str,
957) -> Result<ValueSetInvocation> {
958    let ok = editable_text
959        .set_text_contents(value)
960        .await
961        .context("failed to set AT-SPI editable text contents")?;
962    if !ok {
963        return Err(anyhow!("AT-SPI EditableText rejected the new contents"));
964    }
965
966    let outcome = verify_editable_text_contents(proxy, proxies, value).await;
967    Ok(ValueSetInvocation::EditableText { outcome })
968}
969
970#[derive(Debug, Clone, Copy, PartialEq, Eq)]
971enum EditableTextReadbackError {
972    Failed,
973    TimedOut,
974}
975
976async fn readback_within<T, E>(
977    deadline: Instant,
978    read: impl Future<Output = std::result::Result<T, E>>,
979) -> std::result::Result<T, EditableTextReadbackError> {
980    timeout_at(deadline, read)
981        .await
982        .map_err(|_| EditableTextReadbackError::TimedOut)?
983        .map_err(|_| EditableTextReadbackError::Failed)
984}
985
986async fn text_readback_matches(
987    text: &TextProxy<'_>,
988    requested: &str,
989    deadline: Instant,
990) -> std::result::Result<bool, EditableTextReadbackError> {
991    let character_count = readback_within(deadline, text.character_count()).await?;
992    if character_count < 0 {
993        return Err(EditableTextReadbackError::Failed);
994    }
995    if character_count as usize == requested.chars().count() {
996        return unicode_scalar_text_readback_matches(text, requested, deadline).await;
997    }
998
999    let requested_utf16 = requested.encode_utf16().collect::<Vec<_>>();
1000    if character_count as usize != requested_utf16.len() {
1001        return Ok(false);
1002    }
1003    utf16_text_readback_matches(text, &requested_utf16, deadline).await
1004}
1005
1006async fn unicode_scalar_text_readback_matches(
1007    text: &TextProxy<'_>,
1008    requested: &str,
1009    deadline: Instant,
1010) -> std::result::Result<bool, EditableTextReadbackError> {
1011    let requested_chars = requested.chars().collect::<Vec<_>>();
1012    let mut offset = 0;
1013    while offset < requested_chars.len() {
1014        let end = offset
1015            .saturating_add(EDITABLE_TEXT_READ_CHUNK_UNITS)
1016            .min(requested_chars.len());
1017        let chunk = readback_within(deadline, text.get_text(offset as i32, end as i32)).await?;
1018        if !chunk
1019            .chars()
1020            .eq(requested_chars[offset..end].iter().copied())
1021        {
1022            return Ok(false);
1023        }
1024        offset = end;
1025    }
1026
1027    Ok(true)
1028}
1029
1030async fn utf16_text_readback_matches(
1031    text: &TextProxy<'_>,
1032    requested: &[u16],
1033    deadline: Instant,
1034) -> std::result::Result<bool, EditableTextReadbackError> {
1035    let mut offset = 0;
1036    while offset < requested.len() {
1037        let mut end = offset
1038            .saturating_add(EDITABLE_TEXT_READ_CHUNK_UNITS)
1039            .min(requested.len());
1040        if end < requested.len() && (0xD800..=0xDBFF).contains(&requested[end - 1]) {
1041            end -= 1;
1042        }
1043        let chunk = readback_within(deadline, text.get_text(offset as i32, end as i32)).await?;
1044        if !chunk
1045            .encode_utf16()
1046            .eq(requested[offset..end].iter().copied())
1047        {
1048            return Ok(false);
1049        }
1050        offset = end;
1051    }
1052
1053    Ok(true)
1054}
1055
1056async fn verify_editable_text_contents(
1057    proxy: &AccessibleProxy<'_>,
1058    proxies: &Proxies<'_>,
1059    requested: &str,
1060) -> EditableTextSetOutcome {
1061    let deadline = Instant::now() + EDITABLE_TEXT_VERIFY_TIMEOUT;
1062    match readback_within(deadline, proxy.get_role()).await {
1063        Ok(atspi::Role::PasswordText) => return EditableTextSetOutcome::PasswordUnverifiable,
1064        Ok(_) => {}
1065        Err(EditableTextReadbackError::Failed) => {
1066            return EditableTextSetOutcome::ReadbackFailed;
1067        }
1068        Err(EditableTextReadbackError::TimedOut) => {
1069            return EditableTextSetOutcome::ReadbackTimedOut;
1070        }
1071    }
1072    let text = match readback_within(deadline, proxies.text()).await {
1073        Ok(text) => text,
1074        Err(EditableTextReadbackError::Failed) => {
1075            return EditableTextSetOutcome::ReadbackFailed;
1076        }
1077        Err(EditableTextReadbackError::TimedOut) => {
1078            return EditableTextSetOutcome::ReadbackTimedOut;
1079        }
1080    };
1081
1082    loop {
1083        match text_readback_matches(&text, requested, deadline).await {
1084            Ok(true) => return EditableTextSetOutcome::Verified,
1085            Ok(false) => {}
1086            Err(EditableTextReadbackError::Failed) => {
1087                return EditableTextSetOutcome::ReadbackFailed;
1088            }
1089            Err(EditableTextReadbackError::TimedOut) => {
1090                return EditableTextSetOutcome::ReadbackTimedOut;
1091            }
1092        }
1093
1094        let now = Instant::now();
1095        if now >= deadline {
1096            return EditableTextSetOutcome::MismatchAfterDeadline;
1097        }
1098        sleep_until((now + EDITABLE_TEXT_VERIFY_INTERVAL).min(deadline)).await;
1099        if Instant::now() >= deadline {
1100            return EditableTextSetOutcome::MismatchAfterDeadline;
1101        }
1102    }
1103}
1104
1105async fn connect() -> Result<AccessibilityConnection> {
1106    hydrate_session_bus_env();
1107    AccessibilityConnection::new()
1108        .await
1109        .context("failed to connect to AT-SPI bus")
1110}
1111
1112/// Open an `AccessibleProxy` for an object on the a11y bus.
1113///
1114/// We deliberately avoid `AccessibilityConnection::object_as_accessible` (the
1115/// `P2P` trait). For apps that advertise a peer-to-peer bus address it routes
1116/// reads over that socket, but for apps that don't (notably GTK4 apps such as
1117/// Nautilus / Text Editor / baobab, which don't implement the legacy
1118/// `GetApplicationBusAddress`) it falls back to a proxy built with only a path
1119/// and *no destination*. On the shared a11y bus that proxy can't address the
1120/// app and every call fails with `ServiceUnknown`, which surfaces as an empty
1121/// tree (`role: "unknown"`, `child_count: 0`). `as_accessible_proxy` always
1122/// pins the destination to the object's bus name, so it works for every app
1123/// regardless of P2P support. See issue #31.
1124async fn open_accessible<'r>(
1125    conn: &AccessibilityConnection,
1126    object_ref: &'r ObjectRefOwned,
1127) -> Result<AccessibleProxy<'r>, atspi::AtspiError> {
1128    object_ref.as_accessible_proxy(conn.connection()).await
1129}
1130
1131async fn registry_children(
1132    conn: &AccessibilityConnection,
1133    limit: usize,
1134    remaining_child_reads: &mut usize,
1135) -> Result<Vec<ObjectRefOwned>> {
1136    let root = conn
1137        .root_accessible_on_registry()
1138        .await
1139        .context("failed to open AT-SPI registry root")?;
1140    let batch = children_up_to(&root, limit, remaining_child_reads)
1141        .await
1142        .context("failed to read AT-SPI registry children")?;
1143    if batch.all_failed() {
1144        return Err(anyhow!(
1145            "AT-SPI registry reported children, but every indexed child read failed"
1146        ));
1147    }
1148    Ok(batch.items)
1149}
1150
1151struct SelectedRoots {
1152    roots: Vec<ObjectRefOwned>,
1153    scoped: bool,
1154    /// Set only when the roots were chosen because they belong to this pid.
1155    pid: Option<u32>,
1156    app_filter_matched: bool,
1157}
1158
1159/// Normalized app-name filter, or `None` when the caller passed nothing usable.
1160/// A `None` needle without a PID target means the snapshot covers the whole desktop.
1161fn app_name_needle(app_name_or_bundle_identifier: Option<&str>) -> Option<String> {
1162    app_name_or_bundle_identifier
1163        .map(str::trim)
1164        .filter(|value| !value.is_empty())
1165        .map(|value| value.to_ascii_lowercase())
1166}
1167
1168async fn select_roots(
1169    conn: &AccessibilityConnection,
1170    roots: Vec<ObjectRefOwned>,
1171    app_name_or_bundle_identifier: Option<&str>,
1172    target_pid: Option<u32>,
1173    pid_fallback_name: Option<&str>,
1174    remaining_child_reads: &mut usize,
1175) -> SelectedRoots {
1176    let needle = app_name_needle(app_name_or_bundle_identifier);
1177    let dbus = DBusProxy::new(conn.connection()).await.ok();
1178    let mut remaining = roots;
1179
1180    if let Some(target_pid) = target_pid {
1181        let mut pid_and_filter_matches = Vec::new();
1182        let mut pid_matches = Vec::new();
1183        let mut non_pid_matches = Vec::new();
1184
1185        for object_ref in remaining {
1186            if object_ref_pid(dbus.as_ref(), &object_ref).await == Some(target_pid) {
1187                if let Some(needle) = needle.as_deref() {
1188                    if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
1189                        pid_and_filter_matches.push(object_ref);
1190                    } else {
1191                        pid_matches.push(object_ref);
1192                    }
1193                } else {
1194                    pid_matches.push(object_ref);
1195                }
1196            } else {
1197                non_pid_matches.push(object_ref);
1198            }
1199        }
1200
1201        if !pid_and_filter_matches.is_empty() {
1202            return SelectedRoots {
1203                roots: pid_and_filter_matches,
1204                scoped: true,
1205                pid: Some(target_pid),
1206                app_filter_matched: true,
1207            };
1208        }
1209        if !pid_matches.is_empty() {
1210            return SelectedRoots {
1211                roots: pid_matches,
1212                scoped: true,
1213                pid: Some(target_pid),
1214                app_filter_matched: needle.is_none(),
1215            };
1216        }
1217
1218        if needle.is_none() {
1219            // A listed window's pid can own no root: a Flatpak app registers
1220            // through xdg-dbus-proxy. Its name then finds the root, and the
1221            // tree records no pid, so element calls check each node's owner.
1222            if let Some(fallback) = app_name_needle(pid_fallback_name) {
1223                let mut selected = Vec::new();
1224                for object_ref in non_pid_matches {
1225                    if root_matches(conn, &object_ref, &fallback, remaining_child_reads).await {
1226                        selected.push(object_ref);
1227                    }
1228                }
1229                if !selected.is_empty() {
1230                    return SelectedRoots {
1231                        roots: selected,
1232                        scoped: true,
1233                        pid: None,
1234                        app_filter_matched: true,
1235                    };
1236                }
1237            }
1238            // A PID-only target that disappeared must not widen to the desktop.
1239            return SelectedRoots {
1240                roots: Vec::new(),
1241                scoped: true,
1242                pid: None,
1243                app_filter_matched: false,
1244            };
1245        }
1246
1247        remaining = non_pid_matches;
1248    }
1249
1250    let Some(needle) = needle.as_deref() else {
1251        return SelectedRoots {
1252            roots: remaining,
1253            scoped: false,
1254            pid: None,
1255            app_filter_matched: true,
1256        };
1257    };
1258
1259    let mut selected = Vec::new();
1260    for object_ref in remaining {
1261        if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
1262            selected.push(object_ref);
1263        }
1264    }
1265
1266    let app_filter_matched = !selected.is_empty();
1267    SelectedRoots {
1268        roots: selected,
1269        scoped: true,
1270        pid: None,
1271        app_filter_matched,
1272    }
1273}
1274
1275async fn root_matches(
1276    conn: &AccessibilityConnection,
1277    object_ref: &ObjectRefOwned,
1278    needle: &str,
1279    remaining_child_reads: &mut usize,
1280) -> bool {
1281    let Ok(proxy) = open_accessible(conn, object_ref).await else {
1282        return object_ref_id(object_ref)
1283            .to_ascii_lowercase()
1284            .contains(needle);
1285    };
1286
1287    if proxy_matches(&proxy, object_ref, needle).await {
1288        return true;
1289    }
1290
1291    for child_ref in children_up_to(&proxy, ROOT_MATCH_CHILD_LIMIT, remaining_child_reads)
1292        .await
1293        .map(|batch| batch.items)
1294        .unwrap_or_default()
1295    {
1296        let Ok(child_proxy) = open_accessible(conn, &child_ref).await else {
1297            continue;
1298        };
1299        if proxy_matches(&child_proxy, &child_ref, needle).await {
1300            return true;
1301        }
1302    }
1303
1304    false
1305}
1306
1307async fn proxy_matches(
1308    proxy: &AccessibleProxy<'_>,
1309    object_ref: &ObjectRefOwned,
1310    needle: &str,
1311) -> bool {
1312    let name = proxy.name().await.unwrap_or_default();
1313    let role = proxy.get_role_name().await.unwrap_or_default();
1314    format!("{} {} {}", object_ref_id(object_ref), name, role)
1315        .to_ascii_lowercase()
1316        .contains(needle)
1317}
1318
1319async fn read_app_summary(
1320    proxy: &AccessibleProxy<'_>,
1321    object_ref: &ObjectRefOwned,
1322    dbus: Option<&DBusProxy<'_>>,
1323) -> AccessibleAppSummary {
1324    AccessibleAppSummary {
1325        object_ref: object_ref_id(object_ref),
1326        name: optional_string(proxy.name().await.ok()),
1327        pid: object_ref_pid(dbus, object_ref).await,
1328        role: role_name(proxy).await,
1329        child_count: proxy.child_count().await.unwrap_or_default(),
1330        bounds: bounds(proxy).await,
1331    }
1332}
1333
1334async fn read_node(
1335    proxy: &AccessibleProxy<'_>,
1336    object_ref: &ObjectRefOwned,
1337    index: u32,
1338    parent_index: Option<u32>,
1339    depth: u32,
1340) -> AccessibilityNode {
1341    let proxies = proxy.proxies().await.ok();
1342
1343    AccessibilityNode {
1344        index,
1345        parent_index,
1346        depth,
1347        object_ref: object_ref_id(object_ref),
1348        role: role_name(proxy).await,
1349        name: optional_string(proxy.name().await.ok()),
1350        description: optional_string(proxy.description().await.ok()),
1351        child_count: proxy.child_count().await.unwrap_or_default(),
1352        bounds: bounds_from_proxies(proxies.as_ref(), proxy).await,
1353        states: states_from_proxy(proxy).await,
1354        actions: actions_from_proxies(proxies.as_ref()).await,
1355        value: value_from_proxies(proxies.as_ref()).await,
1356        text: text_from_proxies(proxies.as_ref()).await,
1357        supports_editable_text: supports_editable_text(proxies.as_ref()).await,
1358    }
1359}
1360
1361/// Role reported when neither AT-SPI role read succeeds.
1362pub(crate) const UNKNOWN_ROLE: &str = "unknown";
1363
1364async fn role_name(proxy: &AccessibleProxy<'_>) -> String {
1365    if let Ok(role) = proxy.get_role_name().await {
1366        if !role.trim().is_empty() {
1367            return role;
1368        }
1369    }
1370    proxy
1371        .get_role()
1372        .await
1373        .map(|role| format!("{role:?}"))
1374        .unwrap_or_else(|_| UNKNOWN_ROLE.to_string())
1375}
1376
1377async fn bounds(proxy: &AccessibleProxy<'_>) -> Option<Bounds> {
1378    bounds_from_proxies(proxy.proxies().await.ok().as_ref(), proxy).await
1379}
1380
1381/// Pid of the process that owns an AT-SPI object ref (`:bus/path`), from the
1382/// accessibility bus. `None` when the owner is gone or the bus cannot say.
1383pub(crate) async fn object_ref_owner_pid(object_ref_id: &str) -> Result<Option<u32>> {
1384    let object_ref = object_ref_from_id(object_ref_id)?;
1385    let conn = connect().await?;
1386    let dbus = DBusProxy::new(conn.connection()).await.ok();
1387    Ok(object_ref_pid(dbus.as_ref(), &object_ref).await)
1388}
1389
1390/// PID of the application root, which may differ from an embedded element's process.
1391pub(crate) async fn object_ref_application_pid(object_ref_id: &str) -> Result<Option<u32>> {
1392    let object_ref = object_ref_from_id(object_ref_id)?;
1393    let conn = connect().await?;
1394    let application = timeout(ACTION_QUERY_TIMEOUT, async {
1395        let proxy = open_accessible(&conn, &object_ref).await.ok()?;
1396        proxy.get_application().await.ok()
1397    })
1398    .await
1399    .ok()
1400    .flatten()
1401    .filter(|application| !application.is_null());
1402    let dbus = DBusProxy::new(conn.connection()).await.ok();
1403    Ok(object_ref_pid(dbus.as_ref(), application.as_ref().unwrap_or(&object_ref)).await)
1404}
1405
1406async fn object_ref_pid(dbus: Option<&DBusProxy<'_>>, object_ref: &ObjectRefOwned) -> Option<u32> {
1407    let dbus = dbus?;
1408    let bus_name = BusName::try_from(object_ref.name_as_str()?.to_string()).ok()?;
1409    dbus.get_connection_unix_process_id(bus_name).await.ok()
1410}
1411
1412async fn bounds_from_proxies(
1413    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
1414    proxy: &AccessibleProxy<'_>,
1415) -> Option<Bounds> {
1416    let owned_proxies;
1417    let proxies = if let Some(proxies) = proxies {
1418        proxies
1419    } else {
1420        owned_proxies = proxy.proxies().await.ok()?;
1421        &owned_proxies
1422    };
1423    let component = proxies.component().await.ok()?;
1424    let (x, y, width, height) = component.get_extents(CoordType::Screen).await.ok()?;
1425    normalize_bounds(Bounds {
1426        x,
1427        y,
1428        width,
1429        height,
1430    })
1431}
1432
1433fn normalize_bounds(bounds: Bounds) -> Option<Bounds> {
1434    if bounds.width <= 0 || bounds.height <= 0 {
1435        return None;
1436    }
1437    if bounds.x <= i32::MIN / 2 || bounds.y <= i32::MIN / 2 {
1438        return None;
1439    }
1440    Some(bounds)
1441}
1442
1443async fn actions_from_proxies(
1444    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
1445) -> Vec<AccessibilityAction> {
1446    let Some(proxies) = proxies else {
1447        return Vec::new();
1448    };
1449    let Ok(action_proxy) = proxies.action().await else {
1450        return Vec::new();
1451    };
1452
1453    read_actions(&action_proxy)
1454        .await
1455        .unwrap_or_default()
1456        .into_iter()
1457        .enumerate()
1458        .map(|(index, action)| AccessibilityAction {
1459            index: index as i32,
1460            name: action.name,
1461            description: action.description,
1462            keybinding: action.keybinding,
1463        })
1464        .collect()
1465}
1466
1467async fn read_actions(proxy: &ActionProxy<'_>) -> Result<Vec<atspi::Action>> {
1468    // WebKit can advertise GetActions without replying to it. The indexed
1469    // methods work, and must also be used when revalidating a cached action.
1470    // Keep the wire spelling: the current atspi binding asks for "Nactions".
1471    timeout(ACTION_QUERY_TIMEOUT, async {
1472        let count = proxy
1473            .inner()
1474            .get_property::<i32>("NActions")
1475            .await
1476            .context("failed to read AT-SPI action count")?;
1477        if count > MAX_ACTIONS_PER_OBJECT {
1478            return Err(anyhow!(
1479                "AT-SPI action count {count} exceeds the {MAX_ACTIONS_PER_OBJECT}-action read budget"
1480            ));
1481        }
1482        if count < 0 {
1483            return Err(anyhow!("invalid negative AT-SPI action count {count}"));
1484        }
1485        Ok(stream::iter(0..count)
1486            .map(|index| async move {
1487                let (name, description, keybinding) = tokio::join!(
1488                    proxy.get_name(index),
1489                    proxy.get_description(index),
1490                    proxy.get_key_binding(index),
1491                );
1492                atspi::Action {
1493                    name: name.unwrap_or_default(),
1494                    description: description.unwrap_or_default(),
1495                    keybinding: keybinding.unwrap_or_default(),
1496                }
1497            })
1498            .buffered(CHILD_READ_CONCURRENCY)
1499            .collect()
1500            .await)
1501    })
1502    .await
1503    .context("AT-SPI action reads exceeded their 5-second deadline")?
1504}
1505
1506async fn states_from_proxy(proxy: &AccessibleProxy<'_>) -> Vec<String> {
1507    proxy
1508        .get_state()
1509        .await
1510        .map(state_labels)
1511        .unwrap_or_default()
1512}
1513
1514async fn value_from_proxies(
1515    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
1516) -> Option<AccessibilityValue> {
1517    let value = proxies?.value().await.ok()?;
1518    Some(AccessibilityValue {
1519        current: value.current_value().await.ok()?,
1520        minimum: value.minimum_value().await.ok()?,
1521        maximum: value.maximum_value().await.ok()?,
1522        minimum_increment: value.minimum_increment().await.ok()?,
1523        text: optional_string(value.text().await.ok()),
1524    })
1525}
1526
1527async fn text_from_proxies(
1528    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
1529) -> Option<AccessibilityText> {
1530    let text = proxies?.text().await.ok()?;
1531    let character_count = text.character_count().await.ok()?.max(0);
1532    let caret_offset = text.caret_offset().await.ok();
1533    let capped_count = character_count.min(MAX_TEXT_READBACK_CHARS);
1534    let content = if capped_count > 0 {
1535        optional_string(text.get_text(0, capped_count).await.ok())
1536    } else {
1537        None
1538    };
1539    let selection_count = text
1540        .get_nselections()
1541        .await
1542        .unwrap_or_default()
1543        .clamp(0, MAX_TEXT_SELECTIONS);
1544    let mut selections = Vec::new();
1545    for index in 0..selection_count {
1546        if let Ok((start_offset, end_offset)) = text.get_selection(index).await {
1547            selections.push(AccessibilityTextSelection {
1548                start_offset,
1549                end_offset,
1550            });
1551        }
1552    }
1553
1554    Some(AccessibilityText {
1555        character_count,
1556        caret_offset,
1557        content,
1558        truncated: character_count > MAX_TEXT_READBACK_CHARS,
1559        selections,
1560    })
1561}
1562
1563async fn supports_editable_text(proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>) -> bool {
1564    let Some(proxies) = proxies else {
1565        return false;
1566    };
1567    proxies.editable_text().await.is_ok()
1568}
1569
1570fn state_labels(state_set: StateSet) -> Vec<String> {
1571    state_set.iter().map(|state| state.to_string()).collect()
1572}
1573
1574fn select_action_index(actions: &[atspi::Action], requested_action: Option<&str>) -> Result<i32> {
1575    if actions.is_empty() {
1576        return Err(anyhow!("element exposes no AT-SPI actions"));
1577    }
1578
1579    if let Some(requested_action) = requested_action
1580        .map(str::trim)
1581        .filter(|value| !value.is_empty())
1582    {
1583        let requested_action = requested_action.to_ascii_lowercase();
1584        if let Some((index, _)) = actions.iter().enumerate().find(|(_, action)| {
1585            action.name.to_ascii_lowercase() == requested_action
1586                || action.description.to_ascii_lowercase() == requested_action
1587        }) {
1588            return Ok(index as i32);
1589        }
1590
1591        if let Ok(index) = requested_action.parse::<usize>() {
1592            if index < actions.len() {
1593                return Ok(index as i32);
1594            }
1595        }
1596
1597        return Err(anyhow!(
1598            "requested AT-SPI action was not found; available actions: {}",
1599            actions
1600                .iter()
1601                .map(|action| action.name.as_str())
1602                .collect::<Vec<_>>()
1603                .join(", ")
1604        ));
1605    }
1606
1607    Ok(if actions.len() > 1 { 1 } else { 0 })
1608}
1609
1610fn select_named_action_index(actions: &[atspi::Action], name: &str) -> Result<i32> {
1611    actions
1612        .iter()
1613        .position(|action| action.name.eq_ignore_ascii_case(name))
1614        .map(|index| index as i32)
1615        .ok_or_else(|| {
1616            anyhow!("cached AT-SPI action name is no longer available; refresh get_app_state")
1617        })
1618}
1619
1620fn optional_string(value: Option<String>) -> Option<String> {
1621    value
1622        .map(|value| value.trim().to_string())
1623        .filter(|value| !value.is_empty())
1624}
1625
1626fn object_ref_from_id(object_ref_id: &str) -> Result<ObjectRefOwned> {
1627    let (name, path) = split_object_ref_id(object_ref_id)?;
1628    let name = UniqueName::try_from(name.to_string())
1629        .with_context(|| format!("invalid AT-SPI bus name in object ref {object_ref_id}"))?;
1630    let path = ObjectPath::try_from(path.to_string())
1631        .with_context(|| format!("invalid AT-SPI object path in object ref {object_ref_id}"))?;
1632    Ok(ObjectRef::new_owned(name, path))
1633}
1634
1635fn split_object_ref_id(object_ref_id: &str) -> Result<(&str, &str)> {
1636    let Some(path_start) = object_ref_id.find('/') else {
1637        return Err(anyhow!(
1638            "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
1639        ));
1640    };
1641    let (name, path) = object_ref_id.split_at(path_start);
1642    if name.is_empty() || path.is_empty() {
1643        return Err(anyhow!(
1644            "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
1645        ));
1646    }
1647    Ok((name, path))
1648}
1649
1650fn object_ref_id(object_ref: &ObjectRefOwned) -> String {
1651    format!(
1652        "{}{}",
1653        object_ref.name_as_str().unwrap_or(""),
1654        object_ref.path_as_str()
1655    )
1656}
1657
1658#[cfg(test)]
1659mod tests {
1660    use super::*;
1661    use crate::test_sockets::PrivateBus;
1662    use std::sync::{
1663        atomic::{AtomicUsize, Ordering},
1664        Arc, Mutex,
1665    };
1666
1667    const EDITABLE_FIXTURE_PATH: &str = "/editable";
1668
1669    #[derive(Default)]
1670    struct FakeEditableState {
1671        contents: String,
1672        mutations: usize,
1673        character_count_reads: usize,
1674        get_text_reads: usize,
1675        numeric_value: f64,
1676        numeric_writes: usize,
1677    }
1678
1679    #[derive(Clone, Copy)]
1680    enum FakeMutation {
1681        Apply,
1682        NoOp,
1683        Delayed,
1684        Normalize,
1685        Mask,
1686        Reject,
1687        Fail,
1688    }
1689
1690    #[derive(Clone, Copy, Debug)]
1691    enum FakeTextOffsetModel {
1692        UnicodeScalars,
1693        Utf16CodeUnits,
1694    }
1695
1696    #[derive(Clone, Copy)]
1697    enum FakeTextBehavior {
1698        Normal,
1699        CharacterCountError,
1700        GetTextError,
1701        NegativeCount,
1702        StallCharacterCount,
1703    }
1704
1705    #[derive(Clone, Copy)]
1706    struct FakeEditableConfig {
1707        mutation: FakeMutation,
1708        text: FakeTextBehavior,
1709        text_offset_model: FakeTextOffsetModel,
1710        role: atspi::Role,
1711        has_text: bool,
1712        has_value: bool,
1713    }
1714
1715    impl Default for FakeEditableConfig {
1716        fn default() -> Self {
1717            Self {
1718                mutation: FakeMutation::Apply,
1719                text: FakeTextBehavior::Normal,
1720                text_offset_model: FakeTextOffsetModel::Utf16CodeUnits,
1721                role: atspi::Role::Text,
1722                has_text: true,
1723                has_value: false,
1724            }
1725        }
1726    }
1727
1728    struct FakeEditableAccessible(FakeEditableConfig);
1729
1730    #[zbus::interface(name = "org.a11y.atspi.Accessible")]
1731    impl FakeEditableAccessible {
1732        #[zbus(property)]
1733        fn child_count(&self) -> i32 {
1734            0
1735        }
1736
1737        fn get_interfaces(&self) -> Vec<&str> {
1738            let mut interfaces = vec!["org.a11y.atspi.Accessible", "org.a11y.atspi.EditableText"];
1739            if self.0.has_text {
1740                interfaces.push("org.a11y.atspi.Text");
1741            }
1742            if self.0.has_value {
1743                interfaces.push("org.a11y.atspi.Value");
1744            }
1745            interfaces
1746        }
1747
1748        fn get_role(&self) -> atspi::Role {
1749            self.0.role
1750        }
1751    }
1752
1753    struct FakeEditableText {
1754        state: Arc<Mutex<FakeEditableState>>,
1755        mutation: FakeMutation,
1756    }
1757
1758    #[zbus::interface(name = "org.a11y.atspi.EditableText")]
1759    impl FakeEditableText {
1760        fn set_text_contents(&self, new_contents: &str) -> zbus::fdo::Result<bool> {
1761            self.state.lock().unwrap().mutations += 1;
1762            match self.mutation {
1763                FakeMutation::Apply => {
1764                    self.state.lock().unwrap().contents = new_contents.to_string();
1765                }
1766                FakeMutation::NoOp => {}
1767                FakeMutation::Delayed => {
1768                    let state = Arc::clone(&self.state);
1769                    let new_contents = new_contents.to_string();
1770                    tokio::spawn(async move {
1771                        tokio::time::sleep(Duration::from_millis(150)).await;
1772                        state.lock().unwrap().contents = new_contents;
1773                    });
1774                }
1775                FakeMutation::Normalize => {
1776                    self.state.lock().unwrap().contents = new_contents.replace("\r\n", "\n");
1777                }
1778                FakeMutation::Mask => {
1779                    self.state.lock().unwrap().contents = "•".repeat(new_contents.chars().count());
1780                }
1781                FakeMutation::Reject => return Ok(false),
1782                FakeMutation::Fail => {
1783                    return Err(zbus::fdo::Error::Failed(
1784                        "injected SetTextContents failure".into(),
1785                    ));
1786                }
1787            }
1788            Ok(true)
1789        }
1790    }
1791
1792    fn unicode_scalar_offset_to_byte_index(contents: &str, offset: usize) -> Option<usize> {
1793        contents
1794            .char_indices()
1795            .map(|(byte_index, _)| byte_index)
1796            .chain(std::iter::once(contents.len()))
1797            .nth(offset)
1798    }
1799
1800    fn utf16_offset_to_byte_index(contents: &str, offset: usize) -> Option<usize> {
1801        let mut utf16_offset = 0;
1802        for (byte_index, character) in contents.char_indices() {
1803            if utf16_offset == offset {
1804                return Some(byte_index);
1805            }
1806            utf16_offset += character.len_utf16();
1807        }
1808        (utf16_offset == offset).then_some(contents.len())
1809    }
1810
1811    struct FakeText {
1812        state: Arc<Mutex<FakeEditableState>>,
1813        behavior: FakeTextBehavior,
1814        text_offset_model: FakeTextOffsetModel,
1815    }
1816
1817    #[zbus::interface(name = "org.a11y.atspi.Text")]
1818    impl FakeText {
1819        #[zbus(property)]
1820        async fn character_count(&self) -> zbus::fdo::Result<i32> {
1821            self.state.lock().unwrap().character_count_reads += 1;
1822            match self.behavior {
1823                FakeTextBehavior::CharacterCountError => Err(zbus::fdo::Error::Failed(
1824                    "injected CharacterCount failure".into(),
1825                )),
1826                FakeTextBehavior::NegativeCount => Ok(-1),
1827                FakeTextBehavior::StallCharacterCount => std::future::pending().await,
1828                _ => {
1829                    let state = self.state.lock().unwrap();
1830                    let count = match self.text_offset_model {
1831                        FakeTextOffsetModel::UnicodeScalars => state.contents.chars().count(),
1832                        FakeTextOffsetModel::Utf16CodeUnits => {
1833                            state.contents.encode_utf16().count()
1834                        }
1835                    };
1836                    Ok(count as i32)
1837                }
1838            }
1839        }
1840
1841        fn get_text(&self, start_offset: i32, end_offset: i32) -> zbus::fdo::Result<String> {
1842            let mut state = self.state.lock().unwrap();
1843            state.get_text_reads += 1;
1844            if matches!(self.behavior, FakeTextBehavior::GetTextError) {
1845                return Err(zbus::fdo::Error::Failed("injected GetText failure".into()));
1846            }
1847            if start_offset < 0 || end_offset < start_offset {
1848                return Err(zbus::fdo::Error::InvalidArgs("invalid text offsets".into()));
1849            }
1850            let offset_to_byte_index = match self.text_offset_model {
1851                FakeTextOffsetModel::UnicodeScalars => unicode_scalar_offset_to_byte_index,
1852                FakeTextOffsetModel::Utf16CodeUnits => utf16_offset_to_byte_index,
1853            };
1854            let start = offset_to_byte_index(&state.contents, start_offset as usize)
1855                .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid text offsets".into()))?;
1856            let end = offset_to_byte_index(&state.contents, end_offset as usize)
1857                .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid text offsets".into()))?;
1858            Ok(state.contents[start..end].to_string())
1859        }
1860    }
1861
1862    struct FakeValue(Arc<Mutex<FakeEditableState>>);
1863
1864    #[zbus::interface(name = "org.a11y.atspi.Value")]
1865    impl FakeValue {
1866        #[zbus(property)]
1867        fn current_value(&self) -> f64 {
1868            self.0.lock().unwrap().numeric_value
1869        }
1870
1871        #[zbus(property)]
1872        fn set_current_value(&self, value: f64) {
1873            let mut state = self.0.lock().unwrap();
1874            state.numeric_value = value;
1875            state.numeric_writes += 1;
1876        }
1877    }
1878
1879    async fn start_editable_fixture(
1880        initial_contents: &str,
1881        config: FakeEditableConfig,
1882    ) -> (
1883        PrivateBus,
1884        zbus::Connection,
1885        zbus::Connection,
1886        Arc<Mutex<FakeEditableState>>,
1887    ) {
1888        let bus = PrivateBus::start("cul-atspi-bus");
1889        let address = bus.address();
1890        let state = Arc::new(Mutex::new(FakeEditableState {
1891            contents: initial_contents.to_string(),
1892            ..Default::default()
1893        }));
1894        let mut builder = zbus::connection::Builder::address(address)
1895            .unwrap()
1896            .serve_at(EDITABLE_FIXTURE_PATH, FakeEditableAccessible(config))
1897            .unwrap()
1898            .serve_at(
1899                EDITABLE_FIXTURE_PATH,
1900                FakeEditableText {
1901                    state: Arc::clone(&state),
1902                    mutation: config.mutation,
1903                },
1904            )
1905            .unwrap();
1906        if config.has_text {
1907            builder = builder
1908                .serve_at(
1909                    EDITABLE_FIXTURE_PATH,
1910                    FakeText {
1911                        state: Arc::clone(&state),
1912                        behavior: config.text,
1913                        text_offset_model: config.text_offset_model,
1914                    },
1915                )
1916                .unwrap();
1917        }
1918        if config.has_value {
1919            builder = builder
1920                .serve_at(EDITABLE_FIXTURE_PATH, FakeValue(Arc::clone(&state)))
1921                .unwrap();
1922        }
1923        let peer = builder.build().await.unwrap();
1924        let client = zbus::connection::Builder::address(address)
1925            .unwrap()
1926            .build()
1927            .await
1928            .unwrap();
1929        (bus, peer, client, state)
1930    }
1931
1932    async fn dispatch_editable_fixture(
1933        client: &zbus::Connection,
1934        peer: &zbus::Connection,
1935        value: &str,
1936    ) -> Result<ValueSetInvocation> {
1937        let proxy = AccessibleProxy::builder(client)
1938            .destination(peer.unique_name().unwrap().clone())
1939            .unwrap()
1940            .path(EDITABLE_FIXTURE_PATH)
1941            .unwrap()
1942            .cache_properties(zbus::proxy::CacheProperties::No)
1943            .build()
1944            .await
1945            .unwrap();
1946        let proxies = proxy.proxies().await.unwrap();
1947        set_element_value_with_proxies(&proxy, &proxies, value).await
1948    }
1949
1950    fn editable_outcome(invocation: ValueSetInvocation) -> EditableTextSetOutcome {
1951        match invocation {
1952            ValueSetInvocation::EditableText { outcome } => outcome,
1953            ValueSetInvocation::Numeric { .. } => panic!("expected editable-text dispatch"),
1954        }
1955    }
1956
1957    struct FakeAccessible {
1958        name: String,
1959        children: Vec<(String, OwnedObjectPath)>,
1960        has_action: bool,
1961    }
1962
1963    #[zbus::interface(name = "org.a11y.atspi.Accessible")]
1964    impl FakeAccessible {
1965        #[zbus(property)]
1966        fn child_count(&self) -> i32 {
1967            self.children.len() as i32
1968        }
1969
1970        #[zbus(property)]
1971        fn name(&self) -> &str {
1972            &self.name
1973        }
1974
1975        fn get_interfaces(&self) -> Vec<&str> {
1976            let mut interfaces = vec!["org.a11y.atspi.Accessible"];
1977            if self.has_action {
1978                interfaces.push("org.a11y.atspi.Action");
1979            }
1980            interfaces
1981        }
1982
1983        fn get_child_at_index(
1984            &self,
1985            index: i32,
1986        ) -> zbus::fdo::Result<((String, OwnedObjectPath),)> {
1987            self.children
1988                .get(index as usize)
1989                .cloned()
1990                .map(|child| (child,))
1991                .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid child index".into()))
1992        }
1993    }
1994
1995    struct FakeWebKitAction {
1996        count: i32,
1997        stall_indexed_reads: bool,
1998        name_reads: Arc<AtomicUsize>,
1999    }
2000
2001    impl Default for FakeWebKitAction {
2002        fn default() -> Self {
2003            Self {
2004                count: 1,
2005                stall_indexed_reads: false,
2006                name_reads: Arc::default(),
2007            }
2008        }
2009    }
2010
2011    #[zbus::interface(name = "org.a11y.atspi.Action")]
2012    impl FakeWebKitAction {
2013        async fn get_actions(&self) -> Vec<atspi::Action> {
2014            std::future::pending().await
2015        }
2016
2017        #[zbus(property, name = "NActions")]
2018        fn nactions(&self) -> i32 {
2019            self.count
2020        }
2021
2022        async fn get_name(&self, index: i32) -> &str {
2023            assert!((0..self.count).contains(&index));
2024            self.name_reads.fetch_add(1, Ordering::SeqCst);
2025            if self.stall_indexed_reads {
2026                std::future::pending::<()>().await;
2027            }
2028            "click"
2029        }
2030
2031        async fn get_localized_name(&self, index: i32) -> &str {
2032            assert!((0..self.count).contains(&index));
2033            "Activate"
2034        }
2035
2036        fn get_description(&self, index: i32) -> &str {
2037            assert!((0..self.count).contains(&index));
2038            "Activate the control"
2039        }
2040
2041        fn get_key_binding(&self, index: i32) -> &str {
2042            assert!((0..self.count).contains(&index));
2043            "Return"
2044        }
2045
2046        fn do_action(&self, index: i32) -> bool {
2047            index == 0
2048        }
2049    }
2050
2051    struct FakeQtAction;
2052
2053    #[zbus::interface(name = "org.a11y.atspi.Action")]
2054    impl FakeQtAction {
2055        #[zbus(property, name = "NActions")]
2056        fn nactions(&self) -> i32 {
2057            2
2058        }
2059
2060        fn get_name(&self, index: i32) -> &str {
2061            match index {
2062                0 => "Press",
2063                1 => "Set Focus",
2064                _ => panic!("invalid action index"),
2065            }
2066        }
2067
2068        fn get_description(&self, index: i32) -> &str {
2069            match index {
2070                0 => "Eylemi tetikler",
2071                1 => "Odağı ayarlar",
2072                _ => panic!("invalid action index"),
2073            }
2074        }
2075
2076        fn get_key_binding(&self, _index: i32) -> &str {
2077            ""
2078        }
2079    }
2080
2081    #[tokio::test]
2082    async fn editable_text_true_without_publication_is_mismatch_and_mutates_once() {
2083        let (_bus, peer, client, state) = start_editable_fixture(
2084            "original",
2085            FakeEditableConfig {
2086                mutation: FakeMutation::NoOp,
2087                ..Default::default()
2088            },
2089        )
2090        .await;
2091
2092        let outcome = editable_outcome(
2093            dispatch_editable_fixture(&client, &peer, "modified")
2094                .await
2095                .unwrap(),
2096        );
2097
2098        // A last poll can cross the same fixed deadline. Both outcomes mean
2099        // an unverified write; neither permits verification or replay.
2100        assert!(
2101            matches!(
2102                outcome,
2103                EditableTextSetOutcome::MismatchAfterDeadline
2104                    | EditableTextSetOutcome::ReadbackTimedOut
2105            ),
2106            "{outcome:?}"
2107        );
2108        let state = state.lock().unwrap();
2109        assert_eq!(state.mutations, 1);
2110        assert!(state.character_count_reads > 1);
2111        assert!(state.get_text_reads > 1);
2112        assert_eq!(state.contents, "original");
2113    }
2114
2115    #[tokio::test]
2116    async fn editable_text_exact_readback_accepts_empty_whitespace_and_trailing_newline() {
2117        let (_bus, peer, client, state) =
2118            start_editable_fixture("initial", FakeEditableConfig::default()).await;
2119        let values = [
2120            "",
2121            " \t\n",
2122            "  leading and trailing\t \n",
2123            "trailing newline\n",
2124            "Case and unicode € 内容 😀🦀𐐷",
2125        ];
2126
2127        for value in values {
2128            let outcome = editable_outcome(
2129                dispatch_editable_fixture(&client, &peer, value)
2130                    .await
2131                    .unwrap(),
2132            );
2133            assert_eq!(outcome, EditableTextSetOutcome::Verified);
2134            assert_eq!(state.lock().unwrap().contents, value);
2135        }
2136        assert_eq!(state.lock().unwrap().mutations, values.len());
2137    }
2138
2139    #[tokio::test]
2140    async fn editable_text_non_bmp_readback_supports_scalar_and_utf16_peers() {
2141        let requested = "🦀 GTK verified ";
2142        for text_offset_model in [
2143            FakeTextOffsetModel::UnicodeScalars,
2144            FakeTextOffsetModel::Utf16CodeUnits,
2145        ] {
2146            let (_bus, peer, client, state) = start_editable_fixture(
2147                "",
2148                FakeEditableConfig {
2149                    text_offset_model,
2150                    ..Default::default()
2151                },
2152            )
2153            .await;
2154
2155            let outcome = editable_outcome(
2156                dispatch_editable_fixture(&client, &peer, requested)
2157                    .await
2158                    .unwrap(),
2159            );
2160
2161            assert_eq!(
2162                outcome,
2163                EditableTextSetOutcome::Verified,
2164                "{text_offset_model:?}"
2165            );
2166            let state = state.lock().unwrap();
2167            assert_eq!(state.mutations, 1);
2168            assert_eq!(state.contents, requested);
2169        }
2170    }
2171
2172    #[tokio::test]
2173    async fn editable_text_non_bmp_matching_count_does_not_accept_different_content() {
2174        let requested = "🦀 GTK verified ";
2175        let published = "🦀 GTK verified!";
2176        assert_eq!(requested.chars().count(), published.chars().count());
2177        assert_eq!(
2178            requested.encode_utf16().count(),
2179            published.encode_utf16().count()
2180        );
2181        for text_offset_model in [
2182            FakeTextOffsetModel::UnicodeScalars,
2183            FakeTextOffsetModel::Utf16CodeUnits,
2184        ] {
2185            let (_bus, peer, client, state) = start_editable_fixture(
2186                published,
2187                FakeEditableConfig {
2188                    mutation: FakeMutation::NoOp,
2189                    text_offset_model,
2190                    ..Default::default()
2191                },
2192            )
2193            .await;
2194
2195            let outcome = editable_outcome(
2196                dispatch_editable_fixture(&client, &peer, requested)
2197                    .await
2198                    .unwrap(),
2199            );
2200
2201            // A last poll can cross the same fixed deadline. Both outcomes
2202            // leave the write unverified without extending its deadline.
2203            assert!(
2204                matches!(
2205                    outcome,
2206                    EditableTextSetOutcome::MismatchAfterDeadline
2207                        | EditableTextSetOutcome::ReadbackTimedOut
2208                ),
2209                "{text_offset_model:?}: {outcome:?}"
2210            );
2211            let state = state.lock().unwrap();
2212            assert_eq!(state.mutations, 1);
2213            assert!(state.get_text_reads > 0, "{text_offset_model:?}");
2214            assert_eq!(state.contents, published);
2215        }
2216    }
2217
2218    #[tokio::test]
2219    async fn editable_text_readback_chunks_utf16_without_splitting_non_bmp() {
2220        let (_bus, peer, client, state) = start_editable_fixture(
2221            "",
2222            FakeEditableConfig {
2223                text_offset_model: FakeTextOffsetModel::Utf16CodeUnits,
2224                ..Default::default()
2225            },
2226        )
2227        .await;
2228        let mut expected = "a".repeat(EDITABLE_TEXT_READ_CHUNK_UNITS - 1);
2229        // The crab's surrogate pair straddles the first 4096-unit read boundary.
2230        expected.push_str("🦀é");
2231        expected.push_str(&"内容\n".repeat(1_200));
2232        assert!(expected.encode_utf16().count() > MAX_TEXT_READBACK_CHARS as usize);
2233
2234        let outcome = editable_outcome(
2235            dispatch_editable_fixture(&client, &peer, &expected)
2236                .await
2237                .unwrap(),
2238        );
2239
2240        assert_eq!(outcome, EditableTextSetOutcome::Verified);
2241        let state = state.lock().unwrap();
2242        assert_eq!(state.mutations, 1);
2243        assert!(state.get_text_reads >= 2);
2244        assert_eq!(state.contents, expected);
2245    }
2246
2247    #[tokio::test]
2248    async fn editable_text_readback_waits_for_async_publication() {
2249        let (_bus, peer, client, state) = start_editable_fixture(
2250            "pending",
2251            FakeEditableConfig {
2252                mutation: FakeMutation::Delayed,
2253                ..Default::default()
2254            },
2255        )
2256        .await;
2257
2258        let outcome = editable_outcome(
2259            dispatch_editable_fixture(&client, &peer, "published asynchronously")
2260                .await
2261                .unwrap(),
2262        );
2263
2264        assert_eq!(outcome, EditableTextSetOutcome::Verified);
2265        let state = state.lock().unwrap();
2266        assert_eq!(state.mutations, 1);
2267        assert!(state.character_count_reads > 1);
2268        assert_eq!(state.contents, "published asynchronously");
2269    }
2270
2271    #[tokio::test]
2272    async fn editable_text_normalization_mismatch_is_not_verified() {
2273        let (_bus, peer, client, state) = start_editable_fixture(
2274            "",
2275            FakeEditableConfig {
2276                mutation: FakeMutation::Normalize,
2277                ..Default::default()
2278            },
2279        )
2280        .await;
2281
2282        let outcome = editable_outcome(
2283            dispatch_editable_fixture(&client, &peer, "first\r\nsecond\n")
2284                .await
2285                .unwrap(),
2286        );
2287
2288        // A last poll can cross the same fixed deadline. Both outcomes mean
2289        // an unverified write; neither permits verification or replay.
2290        assert!(
2291            matches!(
2292                outcome,
2293                EditableTextSetOutcome::MismatchAfterDeadline
2294                    | EditableTextSetOutcome::ReadbackTimedOut
2295            ),
2296            "{outcome:?}"
2297        );
2298        let state = state.lock().unwrap();
2299        assert_eq!(state.mutations, 1);
2300        assert!(state.character_count_reads > 1);
2301        assert_eq!(state.contents, "first\nsecond\n");
2302    }
2303
2304    #[tokio::test]
2305    async fn password_text_is_unverifiable_even_when_requested_text_is_bullets() {
2306        let (_bus, peer, client, state) = start_editable_fixture(
2307            "",
2308            FakeEditableConfig {
2309                mutation: FakeMutation::Mask,
2310                role: atspi::Role::PasswordText,
2311                ..Default::default()
2312            },
2313        )
2314        .await;
2315
2316        let outcome = editable_outcome(
2317            dispatch_editable_fixture(&client, &peer, "••••••••")
2318                .await
2319                .unwrap(),
2320        );
2321
2322        assert_eq!(outcome, EditableTextSetOutcome::PasswordUnverifiable);
2323        let state = state.lock().unwrap();
2324        assert_eq!(state.mutations, 1);
2325        assert_eq!(state.character_count_reads, 0);
2326        assert_eq!(state.contents, "••••••••");
2327    }
2328
2329    #[tokio::test]
2330    async fn editable_text_missing_text_and_read_errors_are_structured_outcomes() {
2331        let configs = [
2332            FakeEditableConfig {
2333                has_text: false,
2334                ..Default::default()
2335            },
2336            FakeEditableConfig {
2337                text: FakeTextBehavior::CharacterCountError,
2338                ..Default::default()
2339            },
2340            FakeEditableConfig {
2341                text: FakeTextBehavior::GetTextError,
2342                ..Default::default()
2343            },
2344        ];
2345
2346        for config in configs {
2347            let (_bus, peer, client, state) = start_editable_fixture("before", config).await;
2348            let outcome = editable_outcome(
2349                dispatch_editable_fixture(&client, &peer, "readback may have changed")
2350                    .await
2351                    .unwrap(),
2352            );
2353            assert_eq!(outcome, EditableTextSetOutcome::ReadbackFailed);
2354            assert_eq!(state.lock().unwrap().mutations, 1);
2355        }
2356    }
2357
2358    #[tokio::test]
2359    async fn editable_text_negative_counts_and_stalled_readback_are_structured_outcomes() {
2360        let cases = [
2361            (
2362                FakeTextBehavior::NegativeCount,
2363                EditableTextSetOutcome::ReadbackFailed,
2364            ),
2365            (
2366                FakeTextBehavior::StallCharacterCount,
2367                EditableTextSetOutcome::ReadbackTimedOut,
2368            ),
2369        ];
2370
2371        for (text, expected) in cases {
2372            let (_bus, peer, client, state) = start_editable_fixture(
2373                "before",
2374                FakeEditableConfig {
2375                    text,
2376                    ..Default::default()
2377                },
2378            )
2379            .await;
2380            let outcome = editable_outcome(
2381                dispatch_editable_fixture(&client, &peer, "after")
2382                    .await
2383                    .unwrap(),
2384            );
2385            assert_eq!(outcome, expected);
2386            let state = state.lock().unwrap();
2387            assert_eq!(state.mutations, 1);
2388            assert_eq!(state.get_text_reads, 0);
2389        }
2390    }
2391
2392    #[tokio::test]
2393    async fn editable_text_rejection_and_mutation_errors_remain_errors() {
2394        let cases = [
2395            (FakeMutation::Reject, "rejected"),
2396            (FakeMutation::Fail, "failed to set"),
2397        ];
2398
2399        for (mutation, expected_error) in cases {
2400            let (_bus, peer, client, state) = start_editable_fixture(
2401                "before",
2402                FakeEditableConfig {
2403                    mutation,
2404                    ..Default::default()
2405                },
2406            )
2407            .await;
2408            let error = dispatch_editable_fixture(&client, &peer, "after")
2409                .await
2410                .unwrap_err();
2411            assert!(error.to_string().contains(expected_error));
2412            let state = state.lock().unwrap();
2413            assert_eq!(state.mutations, 1);
2414            assert_eq!(state.character_count_reads, 0);
2415        }
2416    }
2417
2418    #[tokio::test]
2419    async fn numeric_value_dispatch_remains_isolated_from_editable_text() {
2420        let (_bus, peer, client, state) = start_editable_fixture(
2421            "unchanged",
2422            FakeEditableConfig {
2423                has_value: true,
2424                ..Default::default()
2425            },
2426        )
2427        .await;
2428
2429        let invocation = dispatch_editable_fixture(&client, &peer, "42.5")
2430            .await
2431            .unwrap();
2432
2433        match invocation {
2434            ValueSetInvocation::Numeric { value } => assert_eq!(value, 42.5),
2435            ValueSetInvocation::EditableText { .. } => panic!("expected numeric dispatch"),
2436        }
2437        let state = state.lock().unwrap();
2438        assert_eq!(state.numeric_value, 42.5);
2439        assert_eq!(state.numeric_writes, 1);
2440        assert_eq!(state.mutations, 0);
2441        assert_eq!(state.character_count_reads, 0);
2442        assert_eq!(state.contents, "unchanged");
2443    }
2444
2445    #[tokio::test]
2446    async fn qt_action_names_do_not_depend_on_localized_names() {
2447        let bus = PrivateBus::start("cul-atspi-bus");
2448        let address = bus.address();
2449        let peer = zbus::connection::Builder::address(address)
2450            .unwrap()
2451            .serve_at("/actions/qt", FakeQtAction)
2452            .unwrap()
2453            .build()
2454            .await
2455            .unwrap();
2456        let client = zbus::connection::Builder::address(address)
2457            .unwrap()
2458            .build()
2459            .await
2460            .unwrap();
2461        let action = ActionProxy::builder(&client)
2462            .destination(peer.unique_name().unwrap().clone())
2463            .unwrap()
2464            .path("/actions/qt")
2465            .unwrap()
2466            .cache_properties(zbus::proxy::CacheProperties::No)
2467            .build()
2468            .await
2469            .unwrap();
2470
2471        let actions = timeout(Duration::from_secs(1), read_actions(&action))
2472            .await
2473            .expect("Qt action reads must not require GetLocalizedName")
2474            .unwrap();
2475
2476        assert_eq!(actions.len(), 2);
2477        assert_eq!(actions[0].name, "Press");
2478        assert_eq!(actions[0].description, "Eylemi tetikler");
2479        assert_eq!(actions[1].name, "Set Focus");
2480        assert_eq!(actions[1].description, "Odağı ayarlar");
2481        assert_eq!(select_named_action_index(&actions, "press").unwrap(), 0);
2482    }
2483
2484    #[tokio::test]
2485    async fn pid_roots_keep_all_toolkits_and_a_missing_pid_stays_empty() {
2486        let bus = PrivateBus::start("cul-atspi-roots");
2487        let root_path = "/org/a11y/atspi/accessible/root";
2488        let mut peers = Vec::new();
2489        for child_count in [0, 1] {
2490            let children = (0..child_count)
2491                .map(|_| (String::new(), "/child".try_into().unwrap()))
2492                .collect();
2493            peers.push(
2494                zbus::connection::Builder::address(bus.address())
2495                    .unwrap()
2496                    .serve_at(
2497                        root_path,
2498                        FakeAccessible {
2499                            name: "qt-app".into(),
2500                            children,
2501                            has_action: false,
2502                        },
2503                    )
2504                    .unwrap()
2505                    .build()
2506                    .await
2507                    .unwrap(),
2508            );
2509        }
2510        let conn = AccessibilityConnection::from_address(bus.address().parse().unwrap())
2511            .await
2512            .unwrap();
2513        let roots = peers
2514            .iter()
2515            .map(|peer| {
2516                object_ref_from_id(&format!("{}{root_path}", peer.unique_name().unwrap())).unwrap()
2517            })
2518            .collect::<Vec<_>>();
2519        let pid = std::process::id();
2520        let mut budget = MAX_DISCOVERY_CHILD_READS;
2521        let selected = select_roots(&conn, roots.clone(), None, Some(pid), None, &mut budget).await;
2522        assert_eq!(selected.roots, roots);
2523        assert_eq!(selected.pid, Some(pid));
2524        assert!(selected.scoped);
2525        assert!(selected.app_filter_matched);
2526
2527        let explicit = object_ref_id(&roots[1]);
2528        let selected = select_roots(
2529            &conn,
2530            roots.clone(),
2531            Some(&explicit),
2532            Some(pid),
2533            None,
2534            &mut budget,
2535        )
2536        .await;
2537        assert_eq!(selected.roots, vec![roots[1].clone()]);
2538        assert_eq!(selected.pid, Some(pid));
2539        assert!(selected.app_filter_matched);
2540
2541        let selected = select_roots(
2542            &conn,
2543            roots.clone(),
2544            None,
2545            Some(u32::MAX),
2546            None,
2547            &mut budget,
2548        )
2549        .await;
2550        assert!(selected.roots.is_empty());
2551        assert_eq!(selected.pid, None);
2552        assert!(selected.scoped);
2553        assert!(!selected.app_filter_matched);
2554
2555        // A listed window whose pid owns no root (a Flatpak app behind
2556        // xdg-dbus-proxy) finds the root by name. No pid is recorded.
2557        let selected = select_roots(
2558            &conn,
2559            roots.clone(),
2560            None,
2561            Some(u32::MAX),
2562            Some("qt-app"),
2563            &mut budget,
2564        )
2565        .await;
2566        assert_eq!(selected.roots, roots);
2567        assert_eq!(selected.pid, None);
2568        assert!(selected.scoped);
2569        assert!(selected.app_filter_matched);
2570
2571        // The name is a fallback only: the pid's own roots win.
2572        let selected = select_roots(
2573            &conn,
2574            roots.clone(),
2575            None,
2576            Some(pid),
2577            Some("no such window"),
2578            &mut budget,
2579        )
2580        .await;
2581        assert_eq!(selected.roots, roots);
2582        assert_eq!(selected.pid, Some(pid));
2583
2584        let selected = select_roots(
2585            &conn,
2586            roots,
2587            None,
2588            Some(u32::MAX),
2589            Some("no such window"),
2590            &mut budget,
2591        )
2592        .await;
2593        assert!(selected.roots.is_empty());
2594        assert!(!selected.app_filter_matched);
2595    }
2596
2597    #[tokio::test]
2598    async fn indexed_children_resolve_webkit_names_on_the_accessibility_bus() {
2599        let bus = PrivateBus::start("cul-atspi-bus");
2600        let address = bus.address();
2601
2602        let webkit_name = "org.webkit.MockWebProcess";
2603        let child_path = "/org/a11y/webkit/accessible/child";
2604        let webkit = zbus::connection::Builder::address(address)
2605            .unwrap()
2606            .name(webkit_name)
2607            .unwrap()
2608            .serve_at(
2609                child_path,
2610                FakeAccessible {
2611                    name: "WebKit page".into(),
2612                    children: Vec::new(),
2613                    has_action: true,
2614                },
2615            )
2616            .unwrap()
2617            .serve_at(child_path, FakeWebKitAction::default())
2618            .unwrap()
2619            .build()
2620            .await
2621            .unwrap();
2622        let owner = webkit.unique_name().unwrap().to_string();
2623        let gtk = zbus::connection::Builder::address(address)
2624            .unwrap()
2625            .serve_at(
2626                "/org/a11y/atspi/accessible/root",
2627                FakeAccessible {
2628                    name: "GTK socket".into(),
2629                    has_action: false,
2630                    children: vec![
2631                        (webkit_name.into(), child_path.try_into().unwrap()),
2632                        (owner.clone(), child_path.try_into().unwrap()),
2633                        (String::new(), "/org/a11y/atspi/null".try_into().unwrap()),
2634                        (
2635                            "org.webkit.UnownedWebProcess".into(),
2636                            child_path.try_into().unwrap(),
2637                        ),
2638                    ],
2639                },
2640            )
2641            .unwrap()
2642            .build()
2643            .await
2644            .unwrap();
2645        let client = zbus::connection::Builder::address(address)
2646            .unwrap()
2647            .build()
2648            .await
2649            .unwrap();
2650        let proxy = AccessibleProxy::builder(&client)
2651            .destination(gtk.unique_name().unwrap().clone())
2652            .unwrap()
2653            .path("/org/a11y/atspi/accessible/root")
2654            .unwrap()
2655            .build()
2656            .await
2657            .unwrap();
2658        let mut remaining = 4;
2659        let reply = proxy
2660            .inner()
2661            .call_method("GetChildAtIndex", &(0_i32,))
2662            .await
2663            .unwrap();
2664        assert_eq!(reply.body().signature().to_string(), "(so)");
2665        let batch = children_up_to(&proxy, 4, &mut remaining).await.unwrap();
2666
2667        assert_eq!(batch.attempted, 4);
2668        assert_eq!(remaining, 0);
2669        assert!(!batch.incomplete);
2670        assert_eq!(batch.items.len(), 3, "only the unowned name should fail");
2671        assert_eq!(batch.items[0].name_as_str(), Some(owner.as_str()));
2672        assert_eq!(batch.items[0].path_as_str(), child_path);
2673        assert_eq!(batch.items[0], batch.items[1]);
2674        assert!(batch.items[2].is_null());
2675
2676        // The canonical reference remains usable by the normal read/action path.
2677        let child = batch.items[0].as_accessible_proxy(&client).await.unwrap();
2678        assert_eq!(child.name().await.unwrap(), "WebKit page");
2679        assert_eq!(
2680            object_ref_from_id(&object_ref_id(&batch.items[0])).unwrap(),
2681            batch.items[0]
2682        );
2683        let action = child.proxies().await.unwrap().action().await.unwrap();
2684        let actions = timeout(Duration::from_secs(1), read_actions(&action))
2685            .await
2686            .expect("indexed action reads must not wait for GetActions")
2687            .unwrap();
2688        assert_eq!(actions.len(), 1);
2689        assert_eq!(actions[0].name, "click");
2690        assert_eq!(actions[0].description, "Activate the control");
2691        assert_eq!(actions[0].keybinding, "Return");
2692        assert_eq!(select_named_action_index(&actions, "click").unwrap(), 0);
2693        assert!(action.do_action(0).await.unwrap());
2694    }
2695
2696    #[tokio::test]
2697    async fn indexed_actions_bound_untrusted_counts_before_reading_indices() {
2698        let bus = PrivateBus::start("cul-atspi-bus");
2699        let address = bus.address();
2700        let name_reads = Arc::new(AtomicUsize::new(0));
2701        let counts = [
2702            -1,
2703            MAX_ACTIONS_PER_OBJECT + 1,
2704            i32::MAX,
2705            0,
2706            MAX_ACTIONS_PER_OBJECT,
2707        ];
2708        let mut builder = zbus::connection::Builder::address(address).unwrap();
2709        for (index, count) in counts.iter().enumerate() {
2710            builder = builder
2711                .serve_at(
2712                    format!("/actions/{index}"),
2713                    FakeWebKitAction {
2714                        count: *count,
2715                        name_reads: Arc::clone(&name_reads),
2716                        ..Default::default()
2717                    },
2718                )
2719                .unwrap();
2720        }
2721        let peer = builder.build().await.unwrap();
2722        let client = zbus::connection::Builder::address(address)
2723            .unwrap()
2724            .build()
2725            .await
2726            .unwrap();
2727
2728        for (index, count) in counts.iter().enumerate() {
2729            let action = ActionProxy::builder(&client)
2730                .destination(peer.unique_name().unwrap().clone())
2731                .unwrap()
2732                .path(format!("/actions/{index}"))
2733                .unwrap()
2734                .cache_properties(zbus::proxy::CacheProperties::No)
2735                .build()
2736                .await
2737                .unwrap();
2738            let result = timeout(Duration::from_secs(1), read_actions(&action))
2739                .await
2740                .expect("untrusted counts must not cause unbounded work");
2741            if (0..=MAX_ACTIONS_PER_OBJECT).contains(count) {
2742                assert_eq!(result.unwrap().len(), *count as usize);
2743            } else {
2744                let error = result.unwrap_err().to_string();
2745                assert!(error.contains("action count"), "{error}");
2746                assert_eq!(name_reads.load(Ordering::SeqCst), 0);
2747            }
2748        }
2749        assert_eq!(
2750            name_reads.load(Ordering::SeqCst),
2751            MAX_ACTIONS_PER_OBJECT as usize
2752        );
2753    }
2754
2755    #[tokio::test]
2756    async fn indexed_actions_time_out_the_entire_read() {
2757        let bus = PrivateBus::start("cul-atspi-bus");
2758        let address = bus.address();
2759        let name_reads = Arc::new(AtomicUsize::new(0));
2760        let peer = zbus::connection::Builder::address(address)
2761            .unwrap()
2762            .serve_at(
2763                "/actions/stalled",
2764                FakeWebKitAction {
2765                    stall_indexed_reads: true,
2766                    name_reads: Arc::clone(&name_reads),
2767                    ..Default::default()
2768                },
2769            )
2770            .unwrap()
2771            .build()
2772            .await
2773            .unwrap();
2774        let client = zbus::connection::Builder::address(address)
2775            .unwrap()
2776            .build()
2777            .await
2778            .unwrap();
2779        let action = ActionProxy::builder(&client)
2780            .destination(peer.unique_name().unwrap().clone())
2781            .unwrap()
2782            .path("/actions/stalled")
2783            .unwrap()
2784            .cache_properties(zbus::proxy::CacheProperties::No)
2785            .build()
2786            .await
2787            .unwrap();
2788
2789        let error = timeout(
2790            ACTION_QUERY_TIMEOUT + Duration::from_secs(1),
2791            read_actions(&action),
2792        )
2793        .await
2794        .expect("indexed reads must have an overall deadline")
2795        .unwrap_err();
2796        assert!(error.to_string().contains("5-second deadline"));
2797        assert_eq!(name_reads.load(Ordering::SeqCst), 1);
2798    }
2799
2800    fn app_children(children: &[(&str, &str)]) -> Vec<(String, Option<String>)> {
2801        children
2802            .iter()
2803            .map(|(role, name)| (role.to_string(), Some(name.to_string())))
2804            .collect()
2805    }
2806
2807    #[test]
2808    fn a_window_title_picks_one_of_several_app_windows() {
2809        let chromium = app_children(&[
2810            ("frame", "GitHub - Chromium"),
2811            ("frame", "PlanetScale - Chromium"),
2812            ("menu", ""),
2813        ]);
2814        assert_eq!(
2815            matching_window(&chromium, "PlanetScale - Chromium"),
2816            Some(1)
2817        );
2818        assert_eq!(
2819            matching_window(&chromium, "planetscale - chromium"),
2820            Some(1)
2821        );
2822        // A title substring, as a caller passes it.
2823        assert_eq!(matching_window(&chromium, "PlanetScale"), Some(1));
2824        // Matches every window: keep them all.
2825        assert_eq!(matching_window(&chromium, "Chromium"), None);
2826        assert_eq!(matching_window(&chromium, "Gmail"), None);
2827    }
2828
2829    #[test]
2830    fn a_decorated_compositor_title_picks_the_frame_it_decorates() {
2831        let chromium = app_children(&[
2832            ("frame", "GitHub - Chromium"),
2833            ("frame", "PlanetScale - Chromium"),
2834        ]);
2835        assert_eq!(
2836            matching_window(&chromium, "PlanetScale - Chromium (Profile 1)"),
2837            Some(1)
2838        );
2839        // Both names are decorated prefixes: the longest wins.
2840        let nested = app_children(&[("frame", "Docs"), ("frame", "Docs - Settings")]);
2841        assert_eq!(
2842            matching_window(&nested, "Docs - Settings (Profile 1)"),
2843            Some(1)
2844        );
2845        // A name inside the title but not its prefix is not a decoration.
2846        let generic = app_children(&[("frame", "Settings"), ("frame", "Mail")]);
2847        assert_eq!(
2848            matching_window(&generic, "Docs - Settings (Profile 1)"),
2849            None
2850        );
2851        assert_eq!(matching_window(&generic, "Mailbox"), None);
2852    }
2853
2854    #[test]
2855    fn an_exact_window_name_wins_over_a_substring_match() {
2856        let windows = app_children(&[("frame", "Docs"), ("frame", "Docs - Settings")]);
2857        assert_eq!(matching_window(&windows, "Docs"), Some(0));
2858    }
2859
2860    #[test]
2861    fn narrowing_keeps_popups_and_dialogs_beside_the_window() {
2862        let chromium = app_children(&[
2863            ("frame", "GitHub - Chromium"),
2864            ("frame", "PlanetScale - Chromium"),
2865            ("window", ""),
2866            ("dialog", "Open File"),
2867            ("menu", ""),
2868        ]);
2869        let chosen = matching_window(&chromium, "PlanetScale").unwrap();
2870        assert_eq!(
2871            kept_beside_window(&chromium, chosen),
2872            [false, true, true, true, true]
2873        );
2874    }
2875
2876    #[test]
2877    fn one_window_is_never_narrowed() {
2878        let single = app_children(&[("frame", "PlanetScale - Chromium"), ("menu", "")]);
2879        assert_eq!(matching_window(&single, "PlanetScale"), None);
2880    }
2881
2882    #[test]
2883    fn split_object_ref_id_separates_bus_name_and_path() {
2884        let (name, path) = split_object_ref_id(":1.42/org/a11y/atspi/accessible/7").unwrap();
2885
2886        assert_eq!(name, ":1.42");
2887        assert_eq!(path, "/org/a11y/atspi/accessible/7");
2888    }
2889
2890    #[test]
2891    fn select_action_index_uses_named_action() {
2892        let actions = vec![
2893            atspi::Action {
2894                name: "click".to_string(),
2895                description: "Clicks".to_string(),
2896                keybinding: String::new(),
2897            },
2898            atspi::Action {
2899                name: "show-menu".to_string(),
2900                description: "Shows menu".to_string(),
2901                keybinding: String::new(),
2902            },
2903        ];
2904
2905        assert_eq!(select_action_index(&actions, Some("show-menu")).unwrap(), 1);
2906    }
2907
2908    #[test]
2909    fn cached_action_name_is_revalidated_against_current_actions() {
2910        let mut actions = vec![atspi::Action {
2911            name: "show-menu".into(),
2912            description: "Click".into(),
2913            keybinding: String::new(),
2914        }];
2915        // Neither a stale index nor a matching description may invoke another action.
2916        assert!(select_named_action_index(&actions, "Click").is_err());
2917        actions.push(atspi::Action {
2918            name: "click".into(),
2919            description: String::new(),
2920            keybinding: String::new(),
2921        });
2922        assert_eq!(select_named_action_index(&actions, "Click").unwrap(), 1);
2923    }
2924
2925    #[test]
2926    fn select_action_index_defaults_to_secondary_when_available() {
2927        let actions = vec![
2928            atspi::Action {
2929                name: "click".to_string(),
2930                description: String::new(),
2931                keybinding: String::new(),
2932            },
2933            atspi::Action {
2934                name: "show-menu".to_string(),
2935                description: String::new(),
2936                keybinding: String::new(),
2937            },
2938        ];
2939
2940        assert_eq!(select_action_index(&actions, None).unwrap(), 1);
2941    }
2942
2943    #[test]
2944    fn state_labels_serialize_in_bit_order() {
2945        let labels = state_labels(StateSet::new(atspi::State::Focused | atspi::State::Checked));
2946
2947        assert_eq!(labels, vec!["checked".to_string(), "focused".to_string()]);
2948    }
2949
2950    #[test]
2951    fn default_snapshot_limits_cover_deep_gtk4_trees() {
2952        // Nautilus 50 places file-list cells below depth 20 and can expose
2953        // more than 850 raw nodes. Keep the defaults above that known shape.
2954        assert_eq!(snapshot_limits(None, None), (1_000, 32));
2955    }
2956
2957    #[test]
2958    fn app_name_needle_ignores_blank_values_and_normalizes_the_rest() {
2959        assert_eq!(app_name_needle(None), None);
2960        assert_eq!(app_name_needle(Some("")), None);
2961        assert_eq!(app_name_needle(Some("   ")), None);
2962        assert_eq!(
2963            app_name_needle(Some("Calculator")).as_deref(),
2964            Some("calculator")
2965        );
2966        assert_eq!(
2967            app_name_needle(Some(" :1.64/org/a11y/atspi/accessible/root ")).as_deref(),
2968            Some(":1.64/org/a11y/atspi/accessible/root")
2969        );
2970    }
2971
2972    #[test]
2973    fn requested_snapshot_limits_remain_bounded() {
2974        assert_eq!(snapshot_limits(Some(0), Some(0)), (1, 0));
2975        assert_eq!(snapshot_limits(Some(100_000), Some(128)), (10_000, 64));
2976    }
2977
2978    #[test]
2979    fn app_discovery_budget_is_independent_of_requested_tree_size() {
2980        assert_eq!(snapshot_child_read_budgets(1), (256, 2_048, 1));
2981    }
2982
2983    #[test]
2984    fn traversal_attempts_and_queue_share_one_work_budget() {
2985        let mut traversal = BoundedTraversal::new(4);
2986        traversal.enqueue([1]);
2987
2988        assert_eq!(traversal.pop(), Some(1));
2989        traversal.enqueue(2..=10_000);
2990        assert_eq!(traversal.queue, VecDeque::from([2, 3, 4]));
2991
2992        assert_eq!(traversal.pop(), Some(2));
2993        traversal.enqueue(5..=10_000);
2994        assert_eq!(traversal.queue, VecDeque::from([3, 4]));
2995        assert_eq!(traversal.pop(), Some(3));
2996        assert_eq!(traversal.pop(), Some(4));
2997        assert_eq!(traversal.pop(), None);
2998        assert_eq!(traversal.attempted, 4);
2999        assert!(
3000            traversal.truncated(),
3001            "dropped enqueue items must be reported"
3002        );
3003    }
3004
3005    #[test]
3006    fn traversal_that_drains_within_budget_is_not_truncated() {
3007        let mut traversal = BoundedTraversal::new(3);
3008        traversal.enqueue([1, 2, 3]);
3009        assert_eq!(traversal.pop(), Some(1));
3010        assert_eq!(traversal.pop(), Some(2));
3011        assert_eq!(traversal.pop(), Some(3));
3012        assert_eq!(traversal.pop(), None);
3013        assert!(
3014            !traversal.truncated(),
3015            "exactly max_items real nodes is complete"
3016        );
3017    }
3018
3019    #[test]
3020    fn enqueue_at_zero_capacity_marks_the_traversal_truncated() {
3021        let mut traversal = BoundedTraversal::new(2);
3022        traversal.enqueue([1, 2]);
3023        assert_eq!(traversal.pop(), Some(1));
3024        assert_eq!(traversal.pop(), Some(2));
3025        assert!(!traversal.truncated(), "nothing dropped yet");
3026        // Capacity is zero; the offered item is dropped, which is the only
3027        // path that can mark truncation.
3028        traversal.enqueue([3]);
3029        assert!(traversal.queue.is_empty());
3030        assert_eq!(traversal.pop(), None);
3031        assert!(traversal.truncated());
3032    }
3033
3034    #[test]
3035    fn child_count_is_clamped_before_indexed_reads() {
3036        assert_eq!(bounded_child_count(-1, 4), 0);
3037        assert_eq!(bounded_child_count(3, 4), 3);
3038        assert_eq!(bounded_child_count(i32::MAX, 4), 4);
3039    }
3040
3041    #[tokio::test]
3042    async fn indexed_child_reads_consume_attempts_even_when_one_fails() {
3043        let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3044        let mut remaining_attempts = 3;
3045        let batch = fetch_indexed_up_to(100, 10, &mut remaining_attempts, {
3046            let calls = calls.clone();
3047            move |index| {
3048                let calls = calls.clone();
3049                async move {
3050                    calls.lock().unwrap().push(index);
3051                    if index == 1 {
3052                        Err(())
3053                    } else {
3054                        Ok(index)
3055                    }
3056                }
3057            }
3058        })
3059        .await;
3060
3061        assert_eq!(batch.items, vec![0, 2]);
3062        assert_eq!(batch.attempted, 3);
3063        assert!(
3064            batch.incomplete,
3065            "100 reported children, 3 attempted: budget cut must be reported"
3066        );
3067        assert!(!batch.all_failed());
3068        assert_eq!(*calls.lock().unwrap(), vec![0, 1, 2]);
3069        assert_eq!(remaining_attempts, 0);
3070
3071        let no_children = fetch_indexed_up_to(100, 10, &mut remaining_attempts, |_| async {
3072            Ok::<_, ()>(99)
3073        })
3074        .await;
3075        assert!(no_children.items.is_empty());
3076        assert_eq!(no_children.attempted, 0);
3077        assert!(!no_children.all_failed());
3078
3079        let mut failed_attempts = 2;
3080        let all_failed =
3081            fetch_indexed_up_to(2, 2, &mut failed_attempts, |_| async { Err::<i32, _>(()) }).await;
3082        assert!(all_failed.all_failed());
3083    }
3084
3085    fn document() -> FocusedElementSummary {
3086        FocusedElementSummary {
3087            role: "document web".into(),
3088            name: Some("Synthetic editor host".into()),
3089            editable: false,
3090            states: vec!["focused".into()],
3091            is_terminal: false,
3092        }
3093    }
3094
3095    #[test]
3096    fn focused_document_does_not_end_the_search_before_contenteditable() {
3097        assert!(focus_is_container(&document()));
3098        let mut entry = document();
3099        entry.role = "entry".into();
3100        entry.states.push("editable".into());
3101        // Chromium need not implement EditableText for keyboard input.
3102        assert!(!entry.editable);
3103        assert!(!focus_is_container(&entry));
3104    }
3105
3106    #[test]
3107    fn incomplete_descendants_do_not_make_the_document_a_definitive_recipient() {
3108        assert!(matches!(
3109            finish_focus_probe(true, false, Some(document())),
3110            FocusProbe::Incomplete
3111        ));
3112        assert!(matches!(
3113            finish_focus_probe(false, false, Some(document())),
3114            FocusProbe::Found(_)
3115        ));
3116    }
3117
3118    #[test]
3119    fn opt_in_snapshot_budget_reaches_controls_after_embedded_browser_content() {
3120        let (limit, _) = snapshot_limits(Some(4_000), None);
3121        assert_eq!(limit, 4_000);
3122        let mut traversal = BoundedTraversal::new(limit);
3123        traversal.enqueue(0..3_000);
3124        let mut reached_editor = false;
3125        while let Some(node) = traversal.pop() {
3126            reached_editor |= node == 2_500;
3127        }
3128        assert!(reached_editor);
3129        assert!(!traversal.truncated());
3130        let (default, _) = snapshot_limits(None, None);
3131        assert_eq!(default, 1_000);
3132    }
3133
3134    #[test]
3135    fn narrowing_keeps_the_spent_budget_and_forgets_dropped_work() {
3136        let mut traversal = BoundedTraversal::new(4);
3137        traversal.enqueue(0..6);
3138        assert!(traversal.truncated());
3139        assert_eq!(traversal.pop(), Some(0));
3140        traversal.narrow();
3141        assert!(
3142            !traversal.truncated(),
3143            "work dropped outside the subtree no longer counts"
3144        );
3145        assert_eq!(traversal.pop(), None);
3146        traversal.enqueue([10, 11, 12, 13]);
3147        assert_eq!(traversal.pop(), Some(10));
3148        assert_eq!(traversal.pop(), Some(11));
3149        assert_eq!(traversal.pop(), Some(12));
3150        assert_eq!(
3151            traversal.pop(),
3152            None,
3153            "the budget spent before narrowing still counts"
3154        );
3155        assert!(traversal.truncated());
3156    }
3157}