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