Skip to main content

computer_use_linux/
atspi_tree.rs

1use crate::diagnostics::hydrate_session_bus_env;
2use anyhow::{anyhow, Context, Result};
3use atspi::{
4    proxy::{
5        accessible::{AccessibleProxy, ObjectRefExt},
6        action::ActionProxy,
7        editable_text::EditableTextProxy,
8        proxy_ext::{Proxies, ProxyExt},
9        text::TextProxy,
10    },
11    CoordType, ObjectRef, ObjectRefOwned, StateSet,
12};
13// Direct dependency (p2p feature off) — see Cargo.toml for why we bypass
14// atspi's "connection" re-export.
15use atspi_connection::AccessibilityConnection;
16use futures_util::{stream, StreamExt};
17use schemars::JsonSchema;
18use serde::Serialize;
19use std::{collections::VecDeque, future::Future, time::Duration};
20use tokio::time::{sleep_until, timeout, timeout_at, Instant};
21use zbus::{
22    fdo::DBusProxy,
23    names::{BusName, UniqueName},
24    zvariant::{ObjectPath, OwnedObjectPath},
25};
26
27#[derive(Debug, Clone, Serialize, JsonSchema)]
28pub struct AccessibleAppSummary {
29    pub object_ref: String,
30    pub name: Option<String>,
31    pub pid: Option<u32>,
32    pub role: String,
33    pub child_count: i32,
34    pub bounds: Option<Bounds>,
35}
36
37#[derive(Debug, Clone, Serialize, JsonSchema)]
38pub struct AccessibilityNode {
39    pub index: u32,
40    pub parent_index: Option<u32>,
41    pub depth: u32,
42    pub object_ref: String,
43    pub role: String,
44    pub name: Option<String>,
45    pub description: Option<String>,
46    pub child_count: i32,
47    pub bounds: Option<Bounds>,
48    pub states: Vec<String>,
49    pub actions: Vec<AccessibilityAction>,
50    pub value: Option<AccessibilityValue>,
51    pub text: Option<AccessibilityText>,
52    pub supports_editable_text: bool,
53}
54
55#[derive(Debug, Clone, Serialize, JsonSchema)]
56pub struct Bounds {
57    pub x: i32,
58    pub y: i32,
59    pub width: i32,
60    pub height: i32,
61}
62
63#[derive(Debug, Clone, Serialize, JsonSchema)]
64pub struct AccessibilityAction {
65    pub index: i32,
66    pub name: String,
67    pub description: String,
68    pub keybinding: String,
69}
70
71#[derive(Debug, Clone, Serialize, JsonSchema)]
72pub struct AccessibilityValue {
73    pub current: f64,
74    pub minimum: f64,
75    pub maximum: f64,
76    pub minimum_increment: f64,
77    pub text: Option<String>,
78}
79
80#[derive(Debug, Clone, Serialize, JsonSchema)]
81pub struct AccessibilityText {
82    pub character_count: i32,
83    pub caret_offset: Option<i32>,
84    pub content: Option<String>,
85    pub truncated: bool,
86    pub selections: Vec<AccessibilityTextSelection>,
87}
88
89#[derive(Debug, Clone, Serialize, JsonSchema)]
90pub struct AccessibilityTextSelection {
91    pub start_offset: i32,
92    pub end_offset: i32,
93}
94
95#[derive(Debug, Clone)]
96pub struct ActionInvocation {
97    pub action_index: i32,
98    pub action_name: Option<String>,
99    pub ok: bool,
100}
101
102#[derive(Debug, Clone)]
103pub enum ValueSetInvocation {
104    Numeric { value: f64 },
105    EditableText { outcome: EditableTextSetOutcome },
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum EditableTextSetOutcome {
110    Verified,
111    MismatchAfterDeadline,
112    ReadbackFailed,
113    ReadbackTimedOut,
114    PasswordUnverifiable,
115}
116
117const MAX_TEXT_READBACK_CHARS: i32 = 4096;
118const EDITABLE_TEXT_READ_CHUNK_UNITS: usize = 4096;
119const EDITABLE_TEXT_VERIFY_TIMEOUT: Duration = Duration::from_secs(1);
120const EDITABLE_TEXT_VERIFY_INTERVAL: Duration = Duration::from_millis(50);
121const MAX_TEXT_SELECTIONS: i32 = 8;
122const DEFAULT_SNAPSHOT_MAX_NODES: usize = 1_000;
123// Larger trees are opt-in; the default and the 10-second deadline stay unchanged.
124const HARD_SNAPSHOT_MAX_NODES: usize = 10_000;
125const DEFAULT_SNAPSHOT_MAX_DEPTH: u32 = 32;
126const HARD_SNAPSHOT_MAX_DEPTH: u32 = 64;
127const CHILD_READ_CONCURRENCY: usize = 16;
128const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
129/// Time past `SNAPSHOT_TIMEOUT` for the node being read to finish; a node still
130/// reading after it is dropped and the tree is returned truncated.
131const SNAPSHOT_HARD_GRACE: Duration = Duration::from_secs(2);
132const MAX_ACTIONS_PER_OBJECT: i32 = 64;
133const ACTION_QUERY_TIMEOUT: Duration = Duration::from_secs(5);
134const MAX_DISCOVERY_ROOTS: usize = 256;
135const ROOT_MATCH_CHILD_LIMIT: usize = 8;
136const MAX_DISCOVERY_CHILD_READS: usize = MAX_DISCOVERY_ROOTS * ROOT_MATCH_CHILD_LIMIT;
137
138fn snapshot_child_read_budgets(max_nodes: usize) -> (usize, usize, usize) {
139    (MAX_DISCOVERY_ROOTS, MAX_DISCOVERY_CHILD_READS, max_nodes)
140}
141
142pub(crate) fn snapshot_limits(
143    requested_max_nodes: Option<usize>,
144    requested_max_depth: Option<u32>,
145) -> (usize, u32) {
146    (
147        requested_max_nodes
148            .unwrap_or(DEFAULT_SNAPSHOT_MAX_NODES)
149            .clamp(1, HARD_SNAPSHOT_MAX_NODES),
150        requested_max_depth
151            .unwrap_or(DEFAULT_SNAPSHOT_MAX_DEPTH)
152            .min(HARD_SNAPSHOT_MAX_DEPTH),
153    )
154}
155
156struct BoundedTraversal<T> {
157    queue: VecDeque<T>,
158    attempted: usize,
159    max_items: usize,
160    /// Set once an enqueue had to drop offered items for lack of capacity.
161    dropped: bool,
162}
163
164impl<T> BoundedTraversal<T> {
165    fn new(max_items: usize) -> Self {
166        Self {
167            queue: VecDeque::new(),
168            attempted: 0,
169            max_items,
170            dropped: false,
171        }
172    }
173
174    fn enqueue(&mut self, items: impl IntoIterator<Item = T>) {
175        let capacity = self.remaining_capacity();
176        let mut items = items.into_iter();
177        self.queue.extend(items.by_ref().take(capacity));
178        if items.next().is_some() {
179            self.dropped = true;
180        }
181    }
182
183    fn pop(&mut self) -> Option<T> {
184        if self.attempted >= self.max_items {
185            return None;
186        }
187        let item = self.queue.pop_front()?;
188        self.attempted += 1;
189        Some(item)
190    }
191
192    fn remaining_capacity(&self) -> usize {
193        self.max_items
194            .saturating_sub(self.attempted.saturating_add(self.queue.len()))
195    }
196
197    /// Drop pending and earlier dropped work, keeping the spent budget, so the
198    /// rest of the budget goes to the subtree enqueued next.
199    fn narrow(&mut self) {
200        self.queue.clear();
201        self.dropped = false;
202    }
203
204    /// True when the budget stopped work that was actually offered. `enqueue`
205    /// is the only gate: it never exceeds `remaining_capacity`, so
206    /// `attempted + queue.len() <= max_items` always holds and the queue can
207    /// never be non-empty at the attempt cap. Dropped items are the whole story.
208    fn truncated(&self) -> bool {
209        self.dropped
210    }
211}
212
213fn bounded_child_count(reported: i32, limit: usize) -> usize {
214    usize::try_from(reported).unwrap_or_default().min(limit)
215}
216
217struct IndexedReadBatch<T> {
218    items: Vec<T>,
219    attempted: usize,
220    /// True when fewer children were attempted than the parent reported, because
221    /// the caller's limit or the shared read budget ran out. Failed reads do not
222    /// count; they were attempted.
223    incomplete: bool,
224}
225
226impl<T> IndexedReadBatch<T> {
227    fn all_failed(&self) -> bool {
228        self.attempted > 0 && self.items.is_empty()
229    }
230}
231
232async fn fetch_indexed_up_to<T, E, F, Fut>(
233    reported: i32,
234    limit: usize,
235    remaining_attempts: &mut usize,
236    fetch: F,
237) -> IndexedReadBatch<T>
238where
239    F: Fn(i32) -> Fut,
240    Fut: Future<Output = std::result::Result<T, E>>,
241{
242    let attempt_count = bounded_child_count(reported, limit).min(*remaining_attempts);
243    *remaining_attempts = (*remaining_attempts).saturating_sub(attempt_count);
244    let end_index = i32::try_from(attempt_count).unwrap_or(i32::MAX);
245
246    let items = stream::iter(0..end_index)
247        .map(fetch)
248        .buffered(CHILD_READ_CONCURRENCY)
249        .filter_map(|result| async move { result.ok() })
250        .collect()
251        .await;
252
253    IndexedReadBatch {
254        items,
255        attempted: attempt_count,
256        incomplete: attempt_count < usize::try_from(reported).unwrap_or_default(),
257    }
258}
259
260async fn children_up_to(
261    proxy: &AccessibleProxy<'_>,
262    limit: usize,
263    remaining_attempts: &mut usize,
264) -> zbus::Result<IndexedReadBatch<ObjectRefOwned>> {
265    if limit == 0 || *remaining_attempts == 0 {
266        // Budget already spent: stay I/O-free. `incomplete` is false here because
267        // nothing was asked of the parent; callers that already hold the node's
268        // child_count (snapshot_tree_inner) derive truncation from that instead.
269        return Ok(IndexedReadBatch {
270            items: Vec::new(),
271            attempted: 0,
272            incomplete: false,
273        });
274    }
275
276    let child_count = proxy.child_count().await?;
277    Ok(
278        fetch_indexed_up_to(child_count, limit, remaining_attempts, |index| {
279            child_at_index(proxy, index)
280        })
281        .await,
282    )
283}
284
285async fn child_at_index(proxy: &AccessibleProxy<'_>, index: i32) -> zbus::Result<ObjectRefOwned> {
286    // WebKit sockets return a well-known bus name. atspi's ObjectRef decoder
287    // only accepts unique names, so resolve the wire reference before using it.
288    let (name, path): (String, OwnedObjectPath) =
289        proxy.inner().call("GetChildAtIndex", &(index,)).await?;
290    if path.as_str() == "/org/a11y/atspi/null" {
291        return Ok(ObjectRef::Null.into());
292    }
293    match BusName::try_from(name)? {
294        BusName::Unique(name) => Ok(ObjectRef::new_owned(name, path)),
295        name => {
296            let owner = DBusProxy::new(proxy.inner().connection())
297                .await?
298                .get_name_owner(name)
299                .await?;
300            Ok(ObjectRef::new_owned(owner, path))
301        }
302    }
303}
304
305pub async fn list_accessible_apps(limit: usize) -> Result<Vec<AccessibleAppSummary>> {
306    let conn = connect().await?;
307    let mut remaining_child_reads = limit;
308    let roots = registry_children(&conn, limit, &mut remaining_child_reads).await?;
309    let dbus = DBusProxy::new(conn.connection()).await.ok();
310    let mut apps = Vec::new();
311
312    for object_ref in roots.into_iter().take(limit) {
313        if let Ok(proxy) = open_accessible(&conn, &object_ref).await {
314            apps.push(read_app_summary(&proxy, &object_ref, dbus.as_ref()).await);
315        }
316    }
317
318    Ok(apps)
319}
320
321#[derive(Debug, Clone)]
322pub(crate) struct AccessibilitySnapshot {
323    pub nodes: Vec<AccessibilityNode>,
324    /// True when registry roots were filtered to an app name and/or pid.
325    pub scoped: bool,
326    /// The pid every root belongs to, when roots were matched by pid. None
327    /// when they were matched by app name alone or not filtered at all: those
328    /// roots can belong to any app, so no single pid describes the tree.
329    pub root_pid: Option<u32>,
330    /// True when selected roots satisfy the supplied app/name filter, or no
331    /// filter was supplied. Normal snapshots retain their PID-only fallback;
332    /// action recovery requires this evidence before invoking a fresh node.
333    pub app_filter_matched: bool,
334    /// True when max_nodes, max_depth, the child read budget, or the deadline stopped
335    /// traversal with unread elements left. Failed element reads do not count.
336    pub truncated: bool,
337    /// Name of the AT-SPI window the tree was narrowed to, when an app root
338    /// exposed several windows and exactly one matched the window title.
339    pub window_scope: Option<String>,
340}
341
342pub async fn snapshot_tree(
343    app_name_or_bundle_identifier: Option<&str>,
344    target_pid: Option<u32>,
345    max_nodes: usize,
346    max_depth: u32,
347) -> Result<Vec<AccessibilityNode>> {
348    Ok(snapshot_accessibility_tree(
349        app_name_or_bundle_identifier,
350        target_pid,
351        None,
352        None,
353        max_nodes,
354        max_depth,
355    )
356    .await?
357    .nodes)
358}
359
360/// Read the accessibility tree of the matching app roots. With
361/// `window_title`, an app root that exposes several windows (Chromium puts
362/// every browser window under one application) is read only through the
363/// window whose name matches it; its other children stay.
364/// `pid_fallback_name` finds the roots by name when `target_pid` owns none;
365/// pass it only for a pid read from a window the compositor listed.
366pub(crate) async fn snapshot_accessibility_tree(
367    app_name_or_bundle_identifier: Option<&str>,
368    target_pid: Option<u32>,
369    window_title: Option<&str>,
370    pid_fallback_name: Option<&str>,
371    max_nodes: usize,
372    max_depth: u32,
373) -> Result<AccessibilitySnapshot> {
374    let (max_nodes, max_depth) = snapshot_limits(Some(max_nodes), Some(max_depth));
375    // Traversal stops at the deadline and returns what it read, marked truncated.
376    // The outer timeout only bounds app discovery, which runs before any node
377    // is read; the traversal itself ends by `stop_reading` plus the grace.
378    let stop_reading = Instant::now() + SNAPSHOT_TIMEOUT;
379    timeout(
380        SNAPSHOT_TIMEOUT + SNAPSHOT_HARD_GRACE + SNAPSHOT_HARD_GRACE,
381        snapshot_tree_inner(
382            app_name_or_bundle_identifier,
383            target_pid,
384            window_title
385                .map(str::trim)
386                .filter(|title| !title.is_empty()),
387            pid_fallback_name,
388            max_nodes,
389            max_depth,
390            stop_reading,
391        ),
392    )
393    .await
394    .context("AT-SPI snapshot could not find its target before the deadline")?
395}
396
397async fn snapshot_tree_inner(
398    app_name_or_bundle_identifier: Option<&str>,
399    target_pid: Option<u32>,
400    window_title: Option<&str>,
401    pid_fallback_name: Option<&str>,
402    max_nodes: usize,
403    max_depth: u32,
404    stop_reading: Instant,
405) -> Result<AccessibilitySnapshot> {
406    let conn = connect().await?;
407    // App discovery is bounded independently so a tiny requested tree still
408    // finds a target registered after the first accessibility root.
409    let (mut remaining_registry_reads, mut remaining_filter_reads, mut remaining_traversal_reads) =
410        snapshot_child_read_budgets(max_nodes);
411    let roots =
412        registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
413    let selected_roots = select_roots(
414        &conn,
415        roots,
416        app_name_or_bundle_identifier,
417        target_pid,
418        pid_fallback_name,
419        &mut remaining_filter_reads,
420    )
421    .await;
422    let scoped = selected_roots.scoped;
423    let root_pid = selected_roots.pid;
424    let app_filter_matched = selected_roots.app_filter_matched;
425    let mut nodes = Vec::new();
426    let mut truncated = false;
427    let mut window_scope = None;
428    let mut traversal = BoundedTraversal::new(max_nodes);
429
430    traversal.enqueue(
431        selected_roots
432            .roots
433            .into_iter()
434            .map(|object_ref| (object_ref, 0_u32, None)),
435    );
436
437    // Each node's reads may run past `stop_reading` by the grace, never more: a
438    // slow action query (up to ACTION_QUERY_TIMEOUT) near the deadline cuts the
439    // tree there instead of losing it to the outer timeout.
440    let node_cutoff = stop_reading + SNAPSHOT_HARD_GRACE;
441    while let Some((object_ref, depth, parent_index)) = traversal.pop() {
442        if Instant::now() >= stop_reading {
443            truncated = true;
444            break;
445        }
446        let index = nodes.len() as u32;
447        let remaining = traversal.remaining_capacity();
448        let read = timeout_at(node_cutoff, async {
449            let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
450                return None;
451            };
452            let node = read_node(&proxy, &object_ref, index, parent_index, depth).await;
453            let mut cut = false;
454            let mut scope = None;
455            let child_refs = if depth < max_depth && remaining > 0 {
456                match children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await {
457                    Ok(batch) => {
458                        // A read-budget cut inside the fetch reports `incomplete`; an
459                        // already-exhausted budget returns the I/O-free empty batch, so
460                        // fall back to the child_count read_node already fetched.
461                        cut = batch.incomplete || (batch.attempted == 0 && node.child_count > 0);
462                        match window_title.filter(|_| depth == 0) {
463                            Some(title) => {
464                                let (children, matched) =
465                                    scope_to_window(&conn, batch.items, title).await;
466                                scope = matched;
467                                children
468                            }
469                            None => batch.items,
470                        }
471                    }
472                    Err(_) => Vec::new(),
473                }
474            } else {
475                // Depth or node cap reached: any child this node reports is unread.
476                cut = node.child_count > 0;
477                Vec::new()
478            };
479            Some((node, child_refs, cut, scope))
480        })
481        .await;
482        let Ok(read) = read else {
483            truncated = true;
484            break;
485        };
486        let Some((node, child_refs, cut, scope)) = read else {
487            continue;
488        };
489        truncated |= cut;
490        if scope.is_some() {
491            window_scope = scope;
492        }
493        nodes.push(node);
494
495        traversal.enqueue(
496            child_refs
497                .into_iter()
498                .map(|child| (child, depth + 1, Some(index))),
499        );
500    }
501    truncated |= traversal.truncated();
502
503    Ok(AccessibilitySnapshot {
504        nodes,
505        scoped,
506        root_pid,
507        app_filter_matched,
508        truncated,
509        window_scope,
510    })
511}
512
513/// Children of an app root, narrowed to the window named `title` when the root
514/// holds several windows. Returns the children to read and the matched window
515/// name. Only the other named frames are dropped: popups, menus, dialogs and
516/// unnamed windows are kept, since Chromium puts a window's popups and dialogs
517/// beside it under the app root.
518async fn scope_to_window(
519    conn: &AccessibilityConnection,
520    children: Vec<ObjectRefOwned>,
521    title: &str,
522) -> (Vec<ObjectRefOwned>, Option<String>) {
523    // An app root has a handful of windows; read them in order.
524    let mut labels = Vec::with_capacity(children.len());
525    for child in &children {
526        labels.push(match open_accessible(conn, child).await {
527            Ok(proxy) => (
528                role_name(&proxy).await,
529                optional_string(proxy.name().await.ok()),
530            ),
531            Err(_) => (String::new(), None),
532        });
533    }
534    let Some(chosen) = matching_window(&labels, title) else {
535        return (children, None);
536    };
537    let name = labels[chosen].1.clone();
538    let keep = kept_beside_window(&labels, chosen);
539    let kept = children
540        .into_iter()
541        .zip(keep)
542        .filter_map(|(child, keep)| keep.then_some(child))
543        .collect();
544    (kept, name)
545}
546
547/// Which app root children (`(role, name)`) stay when `chosen` is the targeted
548/// window: it, and every child that is not another named frame.
549fn kept_beside_window(children: &[(String, Option<String>)], chosen: usize) -> Vec<bool> {
550    children
551        .iter()
552        .enumerate()
553        .map(|(index, (role, name))| {
554            index == chosen
555                || role != "frame"
556                || name.as_deref().is_none_or(|name| name.trim().is_empty())
557        })
558        .collect()
559}
560
561pub(crate) fn is_window_role(role: &str) -> bool {
562    matches!(role, "frame" | "window" | "dialog" | "file chooser")
563}
564
565/// Index of the one window among an app root's children (`(role, name)`)
566/// that `title` names, when the root has at least two windows. An exact name
567/// wins, then a case-insensitive one, then the one name that contains
568/// `title`, ignoring case. A tie at the first rule that matches returns
569/// `None`, which keeps every window. With no match at all, the longest name
570/// that `title` starts with, followed by a decoration (` (...)` or
571/// ` - ...`), wins: a compositor title such as Chromium's
572/// "PlanetScale - Chromium (Profile 1)" decorates the AT-SPI frame name
573/// "PlanetScale - Chromium".
574fn matching_window(children: &[(String, Option<String>)], title: &str) -> Option<usize> {
575    let windows: Vec<(usize, &str)> = children
576        .iter()
577        .enumerate()
578        .filter(|(_, (role, _))| is_window_role(role))
579        .map(|(index, (_, name))| (index, name.as_deref().unwrap_or_default().trim()))
580        .collect();
581    if windows.len() < 2 {
582        return None;
583    }
584    let title = title.trim();
585    let lowered = title.to_lowercase();
586    let rules: [&dyn Fn(&str) -> bool; 3] = [
587        &|name| name == title,
588        &|name| name.to_lowercase() == lowered,
589        &|name| name.to_lowercase().contains(&lowered),
590    ];
591    for rule in rules {
592        let mut matches = windows.iter().filter(|(_, name)| rule(name));
593        if let Some((index, _)) = matches.next() {
594            return matches.next().is_none().then_some(*index);
595        }
596    }
597    let decorated = |name: &str| {
598        let name = name.to_lowercase();
599        !name.is_empty()
600            && lowered.strip_prefix(&name).is_some_and(|rest| {
601                rest.starts_with(" (") || rest.starts_with(" - ") || rest.starts_with(" \u{2014} ")
602            })
603    };
604    let decorated_matches = windows.iter().filter(|(_, name)| decorated(name));
605    let longest = decorated_matches
606        .clone()
607        .map(|(_, name)| name.len())
608        .max()?;
609    let mut longest_matches = decorated_matches.filter(|(_, name)| name.len() == longest);
610    let (index, _) = longest_matches.next()?;
611    longest_matches.next().is_none().then_some(*index)
612}
613
614/// Compact description of the AT-SPI element that currently holds keyboard
615/// focus, used as post-input feedback for type_text/press_key.
616#[derive(Debug, Clone, Serialize, JsonSchema)]
617pub struct FocusedElementSummary {
618    pub role: String,
619    pub name: Option<String>,
620    pub editable: bool,
621    pub states: Vec<String>,
622    /// AT-SPI `Role::Terminal`, read from the role enum rather than the
623    /// localized role name. Internal routing only; not part of the output.
624    #[serde(skip)]
625    #[schemars(skip)]
626    pub is_terminal: bool,
627}
628
629impl FocusedElementSummary {
630    /// A field that takes typed text: editable, or a text-entry role.
631    pub(crate) fn accepts_text(&self) -> bool {
632        self.editable
633            || self.states.iter().any(|state| state == "editable")
634            || matches!(
635                self.role.as_str(),
636                "text" | "entry" | "password text" | "combo box"
637            )
638    }
639}
640
641const FOCUS_PROBE_MAX_NODES: usize = 400;
642const FOCUS_PROBE_MAX_DEPTH: u32 = 16;
643
644/// Find the element with the `focused` state inside the target app (by pid) or
645/// across all apps. Best-effort and bounded: returns Ok(None) when no focused
646/// element is reachable through AT-SPI (common for apps without accessibility
647/// support, e.g. Electron without --force-renderer-accessibility).
648/// Outcome of the bounded focused-element search.
649#[derive(Debug, Clone)]
650pub(crate) enum FocusProbe {
651    Found(FocusedElementSummary),
652    /// The search covered the target's whole tree and nothing holds focus.
653    NoneFocused,
654    /// A complete scoped search saw only window/menu chrome, no content.
655    ShellOnly,
656    /// A node, depth, or read limit stopped the search before it finished, so
657    /// the focused element may exist past the limit.
658    Incomplete,
659    /// A pid was given but no AT-SPI app belongs to it (xterm, or Electron
660    /// without --force-renderer-accessibility), so focus cannot be read.
661    NoAccessibleApp,
662}
663
664/// Focused element in the app owning `target_pid`, or across all apps when no
665/// pid is given. With a pid, only that app's tree is searched: falling back to
666/// every other app would report some unrelated widget as the focus.
667pub(crate) async fn probe_focused_element(target_pid: Option<u32>) -> Result<FocusProbe> {
668    focused_element_probe(target_pid, target_pid.is_some()).await
669}
670
671/// The focused element in the app that owns `target_pid`, for callers that
672/// only need an answer when one exists. Every non-`Found` probe result maps to
673/// `None`; see [`probe_focused_element`] for the distinctions.
674pub(crate) async fn focused_element_summary_in_app(
675    target_pid: u32,
676) -> Result<Option<FocusedElementSummary>> {
677    Ok(match focused_element_probe(Some(target_pid), true).await? {
678        FocusProbe::Found(summary) => Some(summary),
679        _ => None,
680    })
681}
682
683async fn focused_element_probe(
684    target_pid: Option<u32>,
685    require_scoped: bool,
686) -> Result<FocusProbe> {
687    let conn = connect().await?;
688    Ok(focused_element_search(&conn, target_pid, require_scoped)
689        .await?
690        .0)
691}
692
693/// The bounded focus search. A `Found` result for a non-container element
694/// also returns that element's reference.
695async fn focused_element_search(
696    conn: &AccessibilityConnection,
697    target_pid: Option<u32>,
698    require_scoped: bool,
699) -> Result<(FocusProbe, Option<ObjectRefOwned>)> {
700    let mut remaining_registry_reads = MAX_DISCOVERY_ROOTS;
701    let roots = registry_children(conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
702    let mut remaining_filter_reads = MAX_DISCOVERY_CHILD_READS;
703    let selected_roots = select_roots(
704        conn,
705        roots,
706        None,
707        target_pid,
708        None,
709        &mut remaining_filter_reads,
710    )
711    .await;
712    if require_scoped && selected_roots.pid != target_pid {
713        return Ok((FocusProbe::NoAccessibleApp, None));
714    }
715    focused_element_search_from(conn, selected_roots.roots, require_scoped).await
716}
717
718/// The focus search below `roots`.
719async fn focused_element_search_from(
720    conn: &AccessibilityConnection,
721    roots: Vec<ObjectRefOwned>,
722    require_scoped: bool,
723) -> Result<(FocusProbe, Option<ObjectRefOwned>)> {
724    let mut traversal = BoundedTraversal::new(FOCUS_PROBE_MAX_NODES);
725    let mut remaining_traversal_reads = FOCUS_PROBE_MAX_NODES;
726    let mut incomplete = false;
727    let mut shell_only = true;
728    let mut observed_nodes = 0;
729    let mut focused_container = None;
730    let mut depth_base = 0_u32;
731
732    traversal.enqueue(
733        roots
734            .into_iter()
735            .map(|object_ref| (object_ref, 0_u32, false)),
736    );
737
738    while let Some((object_ref, depth, in_menu)) = traversal.pop() {
739        let Ok(proxy) = open_accessible(conn, &object_ref).await else {
740            incomplete = true;
741            continue;
742        };
743        let Ok(state) = proxy.get_state().await else {
744            incomplete = true;
745            continue;
746        };
747        observed_nodes += 1;
748        let mut child_menu = false;
749        if shell_only {
750            match accessibility_chrome_context(&role_name(&proxy).await, in_menu) {
751                Some(menu) => child_menu = menu,
752                None => shell_only = false,
753            }
754        }
755        if state.contains(atspi::State::Focused) {
756            let proxies = proxy.proxies().await.ok();
757            let is_terminal = matches!(proxy.get_role().await, Ok(atspi::Role::Terminal));
758            let summary = FocusedElementSummary {
759                role: role_name(&proxy).await,
760                name: optional_string(proxy.name().await.ok()),
761                editable: supports_editable_text(proxies.as_ref()).await,
762                states: state_labels(state),
763                is_terminal,
764            };
765            // Chromium can mark both a document and its contenteditable entry
766            // focused. Do not stop at the ancestor before visiting the editor:
767            // the focus is inside it, so the rest of the budget searches only
768            // its subtree, and nodes left unread elsewhere no longer matter.
769            if focus_is_container(&summary) {
770                focused_container = Some(summary);
771                // Only within one app: across apps a stale focused document in
772                // another app must not hide the real focus.
773                if require_scoped {
774                    traversal.narrow();
775                    // The discarded queue had spent its child reads; the subtree
776                    // gets the rest of the node budget, and its depth counts from
777                    // the container.
778                    remaining_traversal_reads = traversal.remaining_capacity();
779                    depth_base = depth;
780                    incomplete = false;
781                }
782            } else {
783                return Ok((FocusProbe::Found(summary), Some(object_ref.clone())));
784            }
785        }
786        let remaining = traversal.remaining_capacity();
787        if depth - depth_base < FOCUS_PROBE_MAX_DEPTH && remaining > 0 {
788            if let Ok(batch) =
789                children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await
790            {
791                incomplete |= batch.incomplete;
792                // An exhausted shared read budget takes children_up_to's
793                // I/O-free path (attempted 0, incomplete false), so the node's
794                // own child count has to say whether children went unread.
795                // With budget left, attempted 0 just means a leaf: no read.
796                if batch.attempted == 0 && remaining_traversal_reads == 0 && !incomplete {
797                    incomplete = proxy.child_count().await.map_or(true, |count| count > 0);
798                }
799                traversal.enqueue(
800                    batch
801                        .items
802                        .into_iter()
803                        .map(|child| (child, depth + 1, child_menu)),
804                );
805            } else {
806                incomplete = true;
807            }
808        } else if !incomplete {
809            // Depth or node cap: any child this node reports goes unread. One
810            // child-count read settles it, and only until the first cut is seen.
811            incomplete = proxy.child_count().await.map_or(true, |count| count > 0);
812        }
813    }
814    Ok((
815        finish_focus_probe(
816            incomplete || traversal.truncated(),
817            require_scoped && shell_only && observed_nodes > 0,
818            focused_container,
819        ),
820        None,
821    ))
822}
823
824pub(crate) fn focus_is_container(element: &FocusedElementSummary) -> bool {
825    !element.editable
826        && !element.states.iter().any(|state| state == "editable")
827        && !element.is_terminal
828        && matches!(
829            element.role.as_str(),
830            "application"
831                | "frame"
832                | "window"
833                | "panel"
834                | "filler"
835                | "section"
836                | "document web"
837                | "document frame"
838                | "document text"
839        )
840}
841
842fn finish_focus_probe(
843    incomplete: bool,
844    shell_only: bool,
845    focused_container: Option<FocusedElementSummary>,
846) -> FocusProbe {
847    // A focused container is not a definitive recipient if descendants were
848    // unread. Preserve uncertainty rather than reporting a non-editable field.
849    if incomplete {
850        FocusProbe::Incomplete
851    } else if let Some(container) = focused_container {
852        FocusProbe::Found(container)
853    } else if shell_only {
854        FocusProbe::ShellOnly
855    } else {
856        FocusProbe::NoneFocused
857    }
858}
859
860/// Window and native menu chrome can exist without Electron renderer content.
861/// This is evidence about tree coverage, not positive Electron detection.
862pub(crate) fn accessibility_chrome_context(role: &str, in_menu: bool) -> Option<bool> {
863    match role {
864        "application" | "frame" | "window" | "panel" | "filler" | "separator" => Some(in_menu),
865        "menu bar" | "menu" | "menu item" | "check menu item" | "radio menu item"
866        | "tearoff menu item" => Some(true),
867        // Electron's native menu buttons are exposed as buttons, not menu items.
868        "button" | "push button" | "label" | "static" | "image" | "icon" if in_menu => Some(true),
869        _ => None,
870    }
871}
872
873pub async fn perform_action(
874    object_ref_id: &str,
875    requested_action: Option<&str>,
876) -> Result<ActionInvocation> {
877    perform_action_inner(object_ref_id, requested_action, false).await
878}
879
880pub(crate) async fn perform_named_action(
881    object_ref_id: &str,
882    name: &str,
883) -> Result<ActionInvocation> {
884    perform_action_inner(object_ref_id, Some(name), true).await
885}
886
887async fn perform_action_inner(
888    object_ref_id: &str,
889    requested_action: Option<&str>,
890    name_only: bool,
891) -> Result<ActionInvocation> {
892    let object_ref = object_ref_from_id(object_ref_id)?;
893    let (action, action_index, action_name) = timeout(ACTION_QUERY_TIMEOUT, async {
894        let conn = connect().await?;
895        let proxy = open_accessible(&conn, &object_ref)
896            .await
897            .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
898        let action = proxy
899            .proxies()
900            .await?
901            .action()
902            .await
903            .context("element does not expose the AT-SPI Action interface")?;
904        let actions = read_actions(&action).await?;
905        let action_index = if name_only {
906            select_named_action_index(&actions, requested_action.unwrap_or_default())?
907        } else {
908            select_action_index(&actions, requested_action)?
909        };
910        let action_name = actions
911            .get(action_index as usize)
912            .map(|action| action.name.clone());
913        Ok::<_, anyhow::Error>((action, action_index, action_name))
914    })
915    .await
916    .context("AT-SPI action revalidation exceeded its 5-second deadline")??;
917    // Only time out the reads: once DoAction is sent, it may already have acted.
918    let ok = action
919        .do_action(action_index)
920        .await
921        .with_context(|| format!("failed to invoke AT-SPI action {action_index}"))?;
922
923    Ok(ActionInvocation {
924        action_index,
925        action_name,
926        ok,
927    })
928}
929
930pub async fn set_element_value(object_ref_id: &str, value: &str) -> Result<ValueSetInvocation> {
931    let conn = connect().await?;
932    let object_ref = object_ref_from_id(object_ref_id)?;
933    let proxy = open_accessible(&conn, &object_ref)
934        .await
935        .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
936    let proxies = proxy.proxies().await?;
937    set_element_value_with_proxies(&proxy, &proxies, value).await
938}
939
940async fn set_element_value_with_proxies(
941    proxy: &AccessibleProxy<'_>,
942    proxies: &Proxies<'_>,
943    value: &str,
944) -> Result<ValueSetInvocation> {
945    if let Ok(numeric_value) = value.parse::<f64>() {
946        if let Ok(value_proxy) = proxies.value().await {
947            value_proxy
948                .set_current_value(numeric_value)
949                .await
950                .with_context(|| {
951                    format!("failed to set AT-SPI numeric value to {numeric_value}")
952                })?;
953            return Ok(ValueSetInvocation::Numeric {
954                value: numeric_value,
955            });
956        }
957    }
958
959    if let Ok(editable_text) = proxies.editable_text().await {
960        return set_editable_text_contents(proxy, proxies, &editable_text, value).await;
961    }
962
963    if value.parse::<f64>().is_err() && proxies.value().await.is_ok() {
964        return Err(anyhow!(
965            "element exposes the AT-SPI Value interface, but the requested value is not numeric"
966        ));
967    }
968
969    Err(anyhow!(
970        "element does not expose AT-SPI Value or EditableText interfaces"
971    ))
972}
973
974async fn set_editable_text_contents(
975    proxy: &AccessibleProxy<'_>,
976    proxies: &Proxies<'_>,
977    editable_text: &EditableTextProxy<'_>,
978    value: &str,
979) -> Result<ValueSetInvocation> {
980    let ok = editable_text
981        .set_text_contents(value)
982        .await
983        .context("failed to set AT-SPI editable text contents")?;
984    if !ok {
985        return Err(anyhow!("AT-SPI EditableText rejected the new contents"));
986    }
987
988    let outcome = verify_editable_text_contents(proxy, proxies, value).await;
989    Ok(ValueSetInvocation::EditableText { outcome })
990}
991
992#[derive(Debug, Clone, Copy, PartialEq, Eq)]
993enum EditableTextReadbackError {
994    Failed,
995    TimedOut,
996}
997
998async fn readback_within<T, E>(
999    deadline: Instant,
1000    read: impl Future<Output = std::result::Result<T, E>>,
1001) -> std::result::Result<T, EditableTextReadbackError> {
1002    timeout_at(deadline, read)
1003        .await
1004        .map_err(|_| EditableTextReadbackError::TimedOut)?
1005        .map_err(|_| EditableTextReadbackError::Failed)
1006}
1007
1008async fn text_readback_matches(
1009    text: &TextProxy<'_>,
1010    requested: &str,
1011    deadline: Instant,
1012) -> std::result::Result<bool, EditableTextReadbackError> {
1013    let character_count = readback_within(deadline, text.character_count()).await?;
1014    if character_count < 0 {
1015        return Err(EditableTextReadbackError::Failed);
1016    }
1017    if character_count as usize == requested.chars().count() {
1018        return unicode_scalar_text_readback_matches(text, 0, requested, deadline).await;
1019    }
1020
1021    let requested_utf16 = requested.encode_utf16().collect::<Vec<_>>();
1022    if character_count as usize != requested_utf16.len() {
1023        return Ok(false);
1024    }
1025    utf16_text_readback_matches(text, 0, &requested_utf16, deadline).await
1026}
1027
1028/// Whether the text from offset `base` on starts with `requested`, with
1029/// offsets counted in Unicode scalars.
1030async fn unicode_scalar_text_readback_matches(
1031    text: &TextProxy<'_>,
1032    base: usize,
1033    requested: &str,
1034    deadline: Instant,
1035) -> std::result::Result<bool, EditableTextReadbackError> {
1036    let requested_chars = requested.chars().collect::<Vec<_>>();
1037    let mut offset = 0;
1038    while offset < requested_chars.len() {
1039        let end = offset
1040            .saturating_add(EDITABLE_TEXT_READ_CHUNK_UNITS)
1041            .min(requested_chars.len());
1042        let chunk = readback_within(
1043            deadline,
1044            text.get_text((base + offset) as i32, (base + end) as i32),
1045        )
1046        .await?;
1047        if !chunk
1048            .chars()
1049            .eq(requested_chars[offset..end].iter().copied())
1050        {
1051            return Ok(false);
1052        }
1053        offset = end;
1054    }
1055
1056    Ok(true)
1057}
1058
1059/// The same check with offsets counted in UTF-16 code units.
1060async fn utf16_text_readback_matches(
1061    text: &TextProxy<'_>,
1062    base: usize,
1063    requested: &[u16],
1064    deadline: Instant,
1065) -> std::result::Result<bool, EditableTextReadbackError> {
1066    let mut offset = 0;
1067    while offset < requested.len() {
1068        let mut end = offset
1069            .saturating_add(EDITABLE_TEXT_READ_CHUNK_UNITS)
1070            .min(requested.len());
1071        if end < requested.len() && (0xD800..=0xDBFF).contains(&requested[end - 1]) {
1072            end -= 1;
1073        }
1074        let chunk = readback_within(
1075            deadline,
1076            text.get_text((base + offset) as i32, (base + end) as i32),
1077        )
1078        .await?;
1079        if !chunk
1080            .encode_utf16()
1081            .eq(requested[offset..end].iter().copied())
1082        {
1083            return Ok(false);
1084        }
1085        offset = end;
1086    }
1087
1088    Ok(true)
1089}
1090
1091async fn verify_editable_text_contents(
1092    proxy: &AccessibleProxy<'_>,
1093    proxies: &Proxies<'_>,
1094    requested: &str,
1095) -> EditableTextSetOutcome {
1096    let deadline = Instant::now() + EDITABLE_TEXT_VERIFY_TIMEOUT;
1097    match readback_within(deadline, proxy.get_role()).await {
1098        Ok(atspi::Role::PasswordText) => return EditableTextSetOutcome::PasswordUnverifiable,
1099        Ok(_) => {}
1100        Err(EditableTextReadbackError::Failed) => {
1101            return EditableTextSetOutcome::ReadbackFailed;
1102        }
1103        Err(EditableTextReadbackError::TimedOut) => {
1104            return EditableTextSetOutcome::ReadbackTimedOut;
1105        }
1106    }
1107    let text = match readback_within(deadline, proxies.text()).await {
1108        Ok(text) => text,
1109        Err(EditableTextReadbackError::Failed) => {
1110            return EditableTextSetOutcome::ReadbackFailed;
1111        }
1112        Err(EditableTextReadbackError::TimedOut) => {
1113            return EditableTextSetOutcome::ReadbackTimedOut;
1114        }
1115    };
1116
1117    loop {
1118        match text_readback_matches(&text, requested, deadline).await {
1119            Ok(true) => return EditableTextSetOutcome::Verified,
1120            Ok(false) => {}
1121            Err(EditableTextReadbackError::Failed) => {
1122                return EditableTextSetOutcome::ReadbackFailed;
1123            }
1124            Err(EditableTextReadbackError::TimedOut) => {
1125                return EditableTextSetOutcome::ReadbackTimedOut;
1126            }
1127        }
1128
1129        let now = Instant::now();
1130        if now >= deadline {
1131            return EditableTextSetOutcome::MismatchAfterDeadline;
1132        }
1133        sleep_until((now + EDITABLE_TEXT_VERIFY_INTERVAL).min(deadline)).await;
1134        if Instant::now() >= deadline {
1135            return EditableTextSetOutcome::MismatchAfterDeadline;
1136        }
1137    }
1138}
1139
1140/// Characters of a mismatched readback that a failure report quotes.
1141const MAX_REPORTED_READBACK_CHARS: usize = 256;
1142
1143/// Outcome of [`insert_text_at_focus`].
1144#[derive(Debug, Clone, PartialEq, Eq)]
1145pub(crate) enum FocusedTextInsert {
1146    /// The field reads the text at the insertion point and has the expected
1147    /// length.
1148    Verified {
1149        role: String,
1150        replaced_selection: bool,
1151        /// The caret is after the inserted text, where typing leaves it.
1152        caret_after_text: bool,
1153    },
1154    /// No text was changed. The focus is not a field this route can fill; the
1155    /// string says why.
1156    NotEditable(String),
1157    /// No text was changed. The focused element is a password field.
1158    PasswordField,
1159    /// The app took the edit, but the field does not read back the text at
1160    /// the insertion point.
1161    Mismatch {
1162        expected_count: i32,
1163        read_count: i32,
1164        /// The text read from the insertion point.
1165        read_text: String,
1166        read_truncated: bool,
1167    },
1168    ReadbackFailed,
1169    ReadbackTimedOut,
1170    /// The app refused or failed an edit call.
1171    EditFailed {
1172        reason: String,
1173        may_have_changed: bool,
1174    },
1175}
1176
1177/// Insert `text` at the caret of the focused element in the target window,
1178/// through AT-SPI EditableText, and read it back. The window is the app
1179/// root's window named `window_title` (any title works when the app has one
1180/// window), and it must be active. A selection is replaced, as typing would.
1181/// An empty or fully selected field gets its contents set, the same call
1182/// set_value makes. `Err` means nothing was changed.
1183pub(crate) async fn insert_text_at_focus(
1184    target_pid: u32,
1185    window_title: Option<&str>,
1186    text: &str,
1187) -> Result<FocusedTextInsert> {
1188    let conn = connect().await?;
1189    insert_text_at_focus_on(&conn, target_pid, window_title, text).await
1190}
1191
1192pub(crate) async fn insert_text_at_focus_on(
1193    conn: &AccessibilityConnection,
1194    target_pid: u32,
1195    window_title: Option<&str>,
1196    text: &str,
1197) -> Result<FocusedTextInsert> {
1198    let window = timeout(
1199        ACTION_QUERY_TIMEOUT,
1200        target_window_root(conn, target_pid, window_title),
1201    )
1202    .await
1203    .context("finding the target window in AT-SPI exceeded its 5-second deadline")??;
1204    let window = match window {
1205        Ok(window) => window,
1206        Err(reason) => return Ok(FocusedTextInsert::NotEditable(reason)),
1207    };
1208    let (probe, object_ref) = timeout(
1209        ACTION_QUERY_TIMEOUT,
1210        focused_element_search_from(conn, vec![window], true),
1211    )
1212    .await
1213    .context("the AT-SPI focus search exceeded its 5-second deadline")??;
1214    let (summary, object_ref) = match (probe, object_ref) {
1215        (FocusProbe::Found(summary), Some(object_ref)) => (summary, object_ref),
1216        (FocusProbe::Found(summary), None) => {
1217            return Ok(FocusedTextInsert::NotEditable(format!(
1218                "keyboard focus is on a {} container, not on a text field",
1219                summary.role
1220            )))
1221        }
1222        (FocusProbe::NoneFocused | FocusProbe::ShellOnly, _) => {
1223            return Ok(FocusedTextInsert::NotEditable(
1224                "no element in the target window has keyboard focus".to_string(),
1225            ))
1226        }
1227        (FocusProbe::Incomplete, _) => {
1228            return Ok(FocusedTextInsert::NotEditable(
1229                "the focused element was not found within the AT-SPI search limits".to_string(),
1230            ))
1231        }
1232        (FocusProbe::NoAccessibleApp, _) => {
1233            return Ok(FocusedTextInsert::NotEditable(
1234                "the target app exposes no AT-SPI tree".to_string(),
1235            ))
1236        }
1237    };
1238    let proxy = open_accessible(conn, &object_ref)
1239        .await
1240        .context("failed to open the focused AT-SPI element")?;
1241    let proxies = proxy
1242        .proxies()
1243        .await
1244        .context("failed to read the focused element's interfaces")?;
1245    insert_text_with_proxies(&proxy, &proxies, &summary, text).await
1246}
1247
1248/// The AT-SPI window of the app owning `target_pid` that matches the verified
1249/// compositor window, or why none can be proven to. One app root can hold
1250/// several windows (Chromium holds them all), and its AT-SPI focus can lag
1251/// behind the compositor's, so a focused field elsewhere in the app is not
1252/// the target. The window must be active.
1253async fn target_window_root(
1254    conn: &AccessibilityConnection,
1255    target_pid: u32,
1256    window_title: Option<&str>,
1257) -> Result<std::result::Result<ObjectRefOwned, String>> {
1258    let mut remaining_registry_reads = MAX_DISCOVERY_ROOTS;
1259    let roots = registry_children(conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
1260    let mut remaining_filter_reads = MAX_DISCOVERY_CHILD_READS;
1261    let selected = select_roots(
1262        conn,
1263        roots,
1264        None,
1265        Some(target_pid),
1266        None,
1267        &mut remaining_filter_reads,
1268    )
1269    .await;
1270    if selected.pid != Some(target_pid) {
1271        return Ok(Err("the target app exposes no AT-SPI tree".to_string()));
1272    }
1273    let mut windows = Vec::new();
1274    let mut labels = Vec::new();
1275    let mut remaining_child_reads = MAX_DISCOVERY_CHILD_READS;
1276    for root in &selected.roots {
1277        let Ok(root) = open_accessible(conn, root).await else {
1278            continue;
1279        };
1280        let Ok(batch) =
1281            children_up_to(&root, MAX_DISCOVERY_ROOTS, &mut remaining_child_reads).await
1282        else {
1283            continue;
1284        };
1285        for child in batch.items {
1286            let Ok(proxy) = open_accessible(conn, &child).await else {
1287                continue;
1288            };
1289            let role = role_name(&proxy).await;
1290            if !is_window_role(&role) {
1291                continue;
1292            }
1293            let name = optional_string(proxy.name().await.ok());
1294            let active = proxy
1295                .get_state()
1296                .await
1297                .is_ok_and(|state| state.contains(atspi::State::Active));
1298            labels.push((role, name));
1299            windows.push((child, active));
1300        }
1301    }
1302    let chosen = match windows.len() {
1303        0 => return Ok(Err("the target app exposes no AT-SPI window".to_string())),
1304        1 => 0,
1305        count => {
1306            let Some(chosen) = window_title.and_then(|title| matching_window(&labels, title))
1307            else {
1308                return Ok(Err(format!(
1309                    "the target window could not be matched to one of the app's {count} AT-SPI windows"
1310                )));
1311            };
1312            chosen
1313        }
1314    };
1315    let (window, active) = windows.swap_remove(chosen);
1316    if !active {
1317        return Ok(Err(format!(
1318            "the target window's AT-SPI {} is not active, so its focus may be stale",
1319            labels[chosen].0
1320        )));
1321    }
1322    Ok(Ok(window))
1323}
1324
1325async fn read_before_edit<T, E: std::fmt::Display>(
1326    deadline: Instant,
1327    read: impl Future<Output = std::result::Result<T, E>>,
1328    what: &str,
1329) -> Result<T> {
1330    match timeout_at(deadline, read).await {
1331        Ok(Ok(value)) => Ok(value),
1332        Ok(Err(error)) => Err(anyhow!("failed to read the field's {what}: {error}")),
1333        Err(_) => Err(anyhow!("reading the field's {what} timed out")),
1334    }
1335}
1336
1337/// InsertText's length argument for `requested`. GTK and the other ATK
1338/// bridges count it in bytes, as the AT-SPI documentation does. Qt's bridge
1339/// resizes the text to that many UTF-16 code units, padding a longer text.
1340/// ASCII text has one length in every unit; other text needs the app's
1341/// toolkit, and the error explains a toolkit that cannot be read.
1342async fn insert_text_length(
1343    proxy: &AccessibleProxy<'_>,
1344    requested: &str,
1345    deadline: Instant,
1346) -> std::result::Result<i32, String> {
1347    let too_long = |_| "the text is too long for AT-SPI InsertText".to_string();
1348    if requested.is_ascii() {
1349        return i32::try_from(requested.len()).map_err(too_long);
1350    }
1351    let Some(toolkit) = toolkit_name(proxy, deadline).await else {
1352        return Err("the app's toolkit could not be read, and InsertText counts the length of non-ASCII text differently per toolkit".to_string());
1353    };
1354    if toolkit.trim().to_ascii_lowercase().starts_with("qt") {
1355        i32::try_from(requested.encode_utf16().count()).map_err(too_long)
1356    } else {
1357        i32::try_from(requested.len()).map_err(too_long)
1358    }
1359}
1360
1361/// `ToolkitName` of the element's application: its `GetApplication` root, or
1362/// else the root on the element's own connection.
1363async fn toolkit_name(proxy: &AccessibleProxy<'_>, deadline: Instant) -> Option<String> {
1364    let connection = proxy.inner().connection();
1365    let application = timeout_at(deadline, proxy.get_application())
1366        .await
1367        .ok()
1368        .and_then(|reply| reply.ok())
1369        .filter(|application| !application.is_null());
1370    let (name, path) = match &application {
1371        Some(application) => (
1372            application.name_as_str()?.to_string(),
1373            application.path_as_str().to_string(),
1374        ),
1375        None => (
1376            proxy.inner().destination().to_string(),
1377            "/org/a11y/atspi/accessible/root".to_string(),
1378        ),
1379    };
1380    let app = atspi::proxy::application::ApplicationProxy::builder(connection)
1381        .destination(name)
1382        .ok()?
1383        .path(path)
1384        .ok()?
1385        .cache_properties(zbus::proxy::CacheProperties::No)
1386        .build()
1387        .await
1388        .ok()?;
1389    timeout_at(deadline, app.toolkit_name()).await.ok()?.ok()
1390}
1391
1392async fn insert_text_with_proxies(
1393    proxy: &AccessibleProxy<'_>,
1394    proxies: &Proxies<'_>,
1395    summary: &FocusedElementSummary,
1396    requested: &str,
1397) -> Result<FocusedTextInsert> {
1398    let deadline = Instant::now() + ACTION_QUERY_TIMEOUT;
1399    let role = read_before_edit(deadline, proxy.get_role(), "role").await?;
1400    if role == atspi::Role::PasswordText || summary.role.to_ascii_lowercase().contains("password") {
1401        return Ok(FocusedTextInsert::PasswordField);
1402    }
1403    if summary.is_terminal || role == atspi::Role::Terminal {
1404        return Ok(FocusedTextInsert::NotEditable(
1405            "the focused element is a terminal".to_string(),
1406        ));
1407    }
1408    if matches!(role, atspi::Role::Invalid | atspi::Role::Unknown) {
1409        return Ok(FocusedTextInsert::NotEditable(
1410            "the focused element's role does not show whether it hides input".to_string(),
1411        ));
1412    }
1413    if !summary.states.iter().any(|state| state == "editable") {
1414        return Ok(FocusedTextInsert::NotEditable(format!(
1415            "the focused {} is not editable",
1416            summary.role
1417        )));
1418    }
1419    let Ok(editable) = proxies.editable_text().await else {
1420        return Ok(FocusedTextInsert::NotEditable(format!(
1421            "the focused {} does not expose AT-SPI EditableText",
1422            summary.role
1423        )));
1424    };
1425    let Ok(text) = proxies.text().await else {
1426        return Ok(FocusedTextInsert::NotEditable(format!(
1427            "the focused {} does not expose AT-SPI Text, so the result could not be read back",
1428            summary.role
1429        )));
1430    };
1431    let count = read_before_edit(deadline, text.character_count(), "character count").await?;
1432    let caret = read_before_edit(deadline, text.caret_offset(), "caret offset").await?;
1433    let selections =
1434        read_before_edit(deadline, text_selection_count(&text), "selection count").await?;
1435    let (start, end) = match selections {
1436        ..=0 => (caret, caret),
1437        1 => {
1438            let (a, b) = read_before_edit(deadline, text.get_selection(0), "selection").await?;
1439            (a.min(b), a.max(b))
1440        }
1441        n => {
1442            return Ok(FocusedTextInsert::NotEditable(format!(
1443                "the field has {n} selections, so the insertion point is ambiguous"
1444            )))
1445        }
1446    };
1447    if count < 0 || start < 0 || end > count {
1448        return Err(anyhow!(
1449            "the field reported an insertion point ({start}..{end}) outside its {count} characters"
1450        ));
1451    }
1452
1453    let replaced_selection = start < end;
1454    let whole_field = start == 0 && end == count;
1455    // Rewriting the whole field for a partial edit would flatten embedded
1456    // objects and formatting, so a partial edit always uses InsertText.
1457    let insert_length = if whole_field {
1458        None
1459    } else {
1460        match insert_text_length(proxy, requested, deadline).await {
1461            Ok(length) => Some(length),
1462            Err(reason) => return Ok(FocusedTextInsert::NotEditable(reason)),
1463        }
1464    };
1465    let untouched = read_untouched_text(&text, start, end, count, deadline).await?;
1466
1467    if let Some(length) = insert_length {
1468        if replaced_selection {
1469            match edit_call(editable.delete_text(start, end), "DeleteText").await {
1470                Ok(()) => {}
1471                Err(EditCallError::Refused) => {
1472                    return Ok(FocusedTextInsert::EditFailed {
1473                        reason: "the app refused to delete the selection".to_string(),
1474                        may_have_changed: false,
1475                    })
1476                }
1477                Err(EditCallError::Failed(reason)) => {
1478                    return Ok(FocusedTextInsert::EditFailed {
1479                        reason,
1480                        may_have_changed: true,
1481                    })
1482                }
1483            }
1484        }
1485        match edit_call(editable.insert_text(start, requested, length), "InsertText").await {
1486            Ok(()) => {}
1487            Err(EditCallError::Refused) => {
1488                return Ok(FocusedTextInsert::EditFailed {
1489                    reason: if replaced_selection {
1490                        "the app deleted the selection but refused InsertText".to_string()
1491                    } else {
1492                        "the app refused InsertText".to_string()
1493                    },
1494                    may_have_changed: replaced_selection,
1495                })
1496            }
1497            Err(EditCallError::Failed(reason)) => {
1498                return Ok(FocusedTextInsert::EditFailed {
1499                    reason,
1500                    may_have_changed: true,
1501                })
1502            }
1503        }
1504    } else {
1505        match edit_call(editable.set_text_contents(requested), "SetTextContents").await {
1506            Ok(()) => {}
1507            Err(EditCallError::Refused) => {
1508                return Ok(FocusedTextInsert::EditFailed {
1509                    reason: "the app refused SetTextContents".to_string(),
1510                    may_have_changed: false,
1511                })
1512            }
1513            Err(EditCallError::Failed(reason)) => {
1514                return Ok(FocusedTextInsert::EditFailed {
1515                    reason,
1516                    may_have_changed: true,
1517                })
1518            }
1519        }
1520    }
1521
1522    verify_inserted_text(&text, start, count - (end - start), requested, &untouched)
1523        .await
1524        .map(|outcome| match outcome {
1525            InsertReadback::Verified { caret_after_text } => FocusedTextInsert::Verified {
1526                role: summary.role.clone(),
1527                replaced_selection,
1528                caret_after_text,
1529            },
1530            InsertReadback::Other(outcome) => outcome,
1531        })
1532}
1533
1534enum EditCallError {
1535    /// The app answered false: it did not make the edit.
1536    Refused,
1537    /// The call failed or timed out, so the edit may have happened.
1538    Failed(String),
1539}
1540
1541/// One EditableText call, bounded like the other AT-SPI calls.
1542async fn edit_call(
1543    call: impl Future<Output = zbus::Result<bool>>,
1544    name: &str,
1545) -> std::result::Result<(), EditCallError> {
1546    match timeout(ACTION_QUERY_TIMEOUT, call).await {
1547        Ok(Ok(true)) => Ok(()),
1548        Ok(Ok(false)) => Err(EditCallError::Refused),
1549        Ok(Err(error)) => Err(EditCallError::Failed(format!("{name} failed: {error}"))),
1550        Err(_) => Err(EditCallError::Failed(format!(
1551            "{name} did not answer within 5 seconds"
1552        ))),
1553    }
1554}
1555
1556/// Characters on each side of the edit that a large field's readback checks.
1557const UNTOUCHED_SPAN_UNITS: i32 = 32;
1558
1559/// The text an edit of `start..end` must leave alone, read before the edit.
1560enum UntouchedText {
1561    /// A field of at most `MAX_TEXT_READBACK_CHARS`: everything before and
1562    /// after the edit.
1563    Whole { before: String, after: String },
1564    /// A larger field: the spans just before and after the edit.
1565    Spans {
1566        before: String,
1567        before_units: i32,
1568        after: String,
1569        after_units: i32,
1570    },
1571}
1572
1573async fn read_untouched_text(
1574    text: &TextProxy<'_>,
1575    start: i32,
1576    end: i32,
1577    count: i32,
1578    deadline: Instant,
1579) -> Result<UntouchedText> {
1580    if count <= MAX_TEXT_READBACK_CHARS {
1581        return Ok(UntouchedText::Whole {
1582            before: read_before_edit(deadline, text.get_text(0, start), "text").await?,
1583            after: read_before_edit(deadline, text.get_text(end, count), "text").await?,
1584        });
1585    }
1586    let before_units = start.min(UNTOUCHED_SPAN_UNITS);
1587    let after_units = (count - end).min(UNTOUCHED_SPAN_UNITS);
1588    Ok(UntouchedText::Spans {
1589        before: read_before_edit(deadline, text.get_text(start - before_units, start), "text")
1590            .await?,
1591        before_units,
1592        after: read_before_edit(deadline, text.get_text(end, end + after_units), "text").await?,
1593        after_units,
1594    })
1595}
1596
1597enum InsertReadback {
1598    Verified { caret_after_text: bool },
1599    Other(FocusedTextInsert),
1600}
1601
1602/// Whether the field holds `requested` at `start`, `units` long in the app's
1603/// offsets, with the untouched text unchanged around it.
1604async fn edit_reads_back(
1605    text: &TextProxy<'_>,
1606    start: i32,
1607    units: i32,
1608    read_count: i32,
1609    requested: &str,
1610    untouched: &UntouchedText,
1611    deadline: Instant,
1612) -> std::result::Result<bool, EditableTextReadbackError> {
1613    match untouched {
1614        UntouchedText::Whole { before, after } => {
1615            let contents = readback_within(deadline, text.get_text(0, read_count)).await?;
1616            Ok(
1617                contents.len() == before.len() + requested.len() + after.len()
1618                    && contents.starts_with(before.as_str())
1619                    && contents[before.len()..].starts_with(requested)
1620                    && contents.ends_with(after.as_str()),
1621            )
1622        }
1623        UntouchedText::Spans {
1624            before,
1625            before_units,
1626            after,
1627            after_units,
1628        } => {
1629            let inserted = readback_within(deadline, text.get_text(start, start + units)).await?;
1630            if inserted != requested {
1631                return Ok(false);
1632            }
1633            let read_before =
1634                readback_within(deadline, text.get_text(start - before_units, start)).await?;
1635            let after_start = start + units;
1636            let read_after = readback_within(
1637                deadline,
1638                text.get_text(after_start, after_start + after_units),
1639            )
1640            .await?;
1641            Ok(&read_before == before && &read_after == after)
1642        }
1643    }
1644}
1645
1646/// Poll until the edit reads back: `requested` at `start`, `kept` other
1647/// characters in Unicode scalars or UTF-16 code units (whichever the app
1648/// counts in), and the untouched text unchanged. Then put the caret after the
1649/// text.
1650async fn verify_inserted_text(
1651    text: &TextProxy<'_>,
1652    start: i32,
1653    kept: i32,
1654    requested: &str,
1655    untouched: &UntouchedText,
1656) -> Result<InsertReadback> {
1657    let deadline = Instant::now() + EDITABLE_TEXT_VERIFY_TIMEOUT;
1658    let scalars = requested.chars().count() as i64;
1659    let utf16 = requested.encode_utf16().count() as i64;
1660    let mut read_count = 0;
1661    // A read cut by the deadline after the field already read back wrong is
1662    // a mismatch, not a readback timeout.
1663    let mut mismatched = false;
1664    loop {
1665        let count = match readback_within(deadline, text.character_count()).await {
1666            Ok(count) => count,
1667            Err(EditableTextReadbackError::Failed) => {
1668                return Ok(InsertReadback::Other(FocusedTextInsert::ReadbackFailed))
1669            }
1670            Err(EditableTextReadbackError::TimedOut) => {
1671                if mismatched {
1672                    break;
1673                }
1674                return Ok(InsertReadback::Other(FocusedTextInsert::ReadbackTimedOut));
1675            }
1676        };
1677        read_count = count;
1678        let units = i64::from(read_count) - i64::from(kept);
1679        let matched = if units == scalars || units == utf16 {
1680            edit_reads_back(
1681                text,
1682                start,
1683                units as i32,
1684                read_count,
1685                requested,
1686                untouched,
1687                deadline,
1688            )
1689            .await
1690            .map(|matched| matched.then_some(units))
1691        } else {
1692            Ok(None)
1693        };
1694        match matched {
1695            Ok(Some(units)) => {
1696                let after = (i64::from(start) + units) as i32;
1697                let caret_deadline = Instant::now() + EDITABLE_TEXT_VERIFY_TIMEOUT;
1698                let caret_after_text =
1699                    match readback_within(caret_deadline, text.caret_offset()).await {
1700                        Ok(caret) if caret == after => true,
1701                        _ => matches!(
1702                            readback_within(caret_deadline, text.set_caret_offset(after)).await,
1703                            Ok(true)
1704                        ),
1705                    };
1706                return Ok(InsertReadback::Verified { caret_after_text });
1707            }
1708            Ok(None) => mismatched = true,
1709            Err(EditableTextReadbackError::Failed) => {
1710                return Ok(InsertReadback::Other(FocusedTextInsert::ReadbackFailed))
1711            }
1712            Err(EditableTextReadbackError::TimedOut) => {
1713                if mismatched {
1714                    break;
1715                }
1716                return Ok(InsertReadback::Other(FocusedTextInsert::ReadbackTimedOut));
1717            }
1718        }
1719        let now = Instant::now();
1720        if now >= deadline {
1721            break;
1722        }
1723        sleep_until((now + EDITABLE_TEXT_VERIFY_INTERVAL).min(deadline)).await;
1724        if Instant::now() >= deadline {
1725            break;
1726        }
1727    }
1728
1729    // Quote what the field holds where the text should be.
1730    let wanted = scalars.max(utf16);
1731    let shown = wanted.min(MAX_REPORTED_READBACK_CHARS as i64);
1732    let read_end = (i64::from(start) + shown).min(i64::from(read_count.max(0)));
1733    let read_text = if read_end > i64::from(start) {
1734        let quote_deadline = Instant::now() + EDITABLE_TEXT_VERIFY_TIMEOUT;
1735        readback_within(quote_deadline, text.get_text(start, read_end as i32))
1736            .await
1737            .unwrap_or_default()
1738    } else {
1739        String::new()
1740    };
1741    let read_truncated = wanted > shown;
1742    Ok(InsertReadback::Other(FocusedTextInsert::Mismatch {
1743        expected_count: (i64::from(kept) + scalars) as i32,
1744        read_count,
1745        read_text,
1746        read_truncated,
1747    }))
1748}
1749
1750async fn connect() -> Result<AccessibilityConnection> {
1751    hydrate_session_bus_env();
1752    AccessibilityConnection::new()
1753        .await
1754        .context("failed to connect to AT-SPI bus")
1755}
1756
1757/// Open an `AccessibleProxy` for an object on the a11y bus.
1758///
1759/// We deliberately avoid `AccessibilityConnection::object_as_accessible` (the
1760/// `P2P` trait). For apps that advertise a peer-to-peer bus address it routes
1761/// reads over that socket, but for apps that don't (notably GTK4 apps such as
1762/// Nautilus / Text Editor / baobab, which don't implement the legacy
1763/// `GetApplicationBusAddress`) it falls back to a proxy built with only a path
1764/// and *no destination*. On the shared a11y bus that proxy can't address the
1765/// app and every call fails with `ServiceUnknown`, which surfaces as an empty
1766/// tree (`role: "unknown"`, `child_count: 0`). `as_accessible_proxy` always
1767/// pins the destination to the object's bus name, so it works for every app
1768/// regardless of P2P support. See issue #31.
1769async fn open_accessible<'r>(
1770    conn: &AccessibilityConnection,
1771    object_ref: &'r ObjectRefOwned,
1772) -> Result<AccessibleProxy<'r>, atspi::AtspiError> {
1773    object_ref.as_accessible_proxy(conn.connection()).await
1774}
1775
1776async fn registry_children(
1777    conn: &AccessibilityConnection,
1778    limit: usize,
1779    remaining_child_reads: &mut usize,
1780) -> Result<Vec<ObjectRefOwned>> {
1781    let root = conn
1782        .root_accessible_on_registry()
1783        .await
1784        .context("failed to open AT-SPI registry root")?;
1785    let batch = children_up_to(&root, limit, remaining_child_reads)
1786        .await
1787        .context("failed to read AT-SPI registry children")?;
1788    if batch.all_failed() {
1789        return Err(anyhow!(
1790            "AT-SPI registry reported children, but every indexed child read failed"
1791        ));
1792    }
1793    Ok(batch.items)
1794}
1795
1796struct SelectedRoots {
1797    roots: Vec<ObjectRefOwned>,
1798    scoped: bool,
1799    /// Set only when the roots were chosen because they belong to this pid.
1800    pid: Option<u32>,
1801    app_filter_matched: bool,
1802}
1803
1804/// Normalized app-name filter, or `None` when the caller passed nothing usable.
1805/// A `None` needle without a PID target means the snapshot covers the whole desktop.
1806fn app_name_needle(app_name_or_bundle_identifier: Option<&str>) -> Option<String> {
1807    app_name_or_bundle_identifier
1808        .map(str::trim)
1809        .filter(|value| !value.is_empty())
1810        .map(|value| value.to_ascii_lowercase())
1811}
1812
1813async fn select_roots(
1814    conn: &AccessibilityConnection,
1815    roots: Vec<ObjectRefOwned>,
1816    app_name_or_bundle_identifier: Option<&str>,
1817    target_pid: Option<u32>,
1818    pid_fallback_name: Option<&str>,
1819    remaining_child_reads: &mut usize,
1820) -> SelectedRoots {
1821    let needle = app_name_needle(app_name_or_bundle_identifier);
1822    let dbus = DBusProxy::new(conn.connection()).await.ok();
1823    let mut remaining = roots;
1824
1825    if let Some(target_pid) = target_pid {
1826        let mut pid_and_filter_matches = Vec::new();
1827        let mut pid_matches = Vec::new();
1828        let mut non_pid_matches = Vec::new();
1829
1830        for object_ref in remaining {
1831            if object_ref_pid(dbus.as_ref(), &object_ref).await == Some(target_pid) {
1832                if let Some(needle) = needle.as_deref() {
1833                    if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
1834                        pid_and_filter_matches.push(object_ref);
1835                    } else {
1836                        pid_matches.push(object_ref);
1837                    }
1838                } else {
1839                    pid_matches.push(object_ref);
1840                }
1841            } else {
1842                non_pid_matches.push(object_ref);
1843            }
1844        }
1845
1846        if !pid_and_filter_matches.is_empty() {
1847            return SelectedRoots {
1848                roots: pid_and_filter_matches,
1849                scoped: true,
1850                pid: Some(target_pid),
1851                app_filter_matched: true,
1852            };
1853        }
1854        if !pid_matches.is_empty() {
1855            return SelectedRoots {
1856                roots: pid_matches,
1857                scoped: true,
1858                pid: Some(target_pid),
1859                app_filter_matched: needle.is_none(),
1860            };
1861        }
1862
1863        if needle.is_none() {
1864            // A listed window's pid can own no root: a Flatpak app registers
1865            // through xdg-dbus-proxy. Its name then finds the root, and the
1866            // tree records no pid, so element calls check each node's owner.
1867            if let Some(fallback) = app_name_needle(pid_fallback_name) {
1868                let mut selected = Vec::new();
1869                for object_ref in non_pid_matches {
1870                    if root_matches(conn, &object_ref, &fallback, remaining_child_reads).await {
1871                        selected.push(object_ref);
1872                    }
1873                }
1874                if !selected.is_empty() {
1875                    return SelectedRoots {
1876                        roots: selected,
1877                        scoped: true,
1878                        pid: None,
1879                        app_filter_matched: true,
1880                    };
1881                }
1882            }
1883            // A PID-only target that disappeared must not widen to the desktop.
1884            return SelectedRoots {
1885                roots: Vec::new(),
1886                scoped: true,
1887                pid: None,
1888                app_filter_matched: false,
1889            };
1890        }
1891
1892        remaining = non_pid_matches;
1893    }
1894
1895    let Some(needle) = needle.as_deref() else {
1896        return SelectedRoots {
1897            roots: remaining,
1898            scoped: false,
1899            pid: None,
1900            app_filter_matched: true,
1901        };
1902    };
1903
1904    let mut selected = Vec::new();
1905    for object_ref in remaining {
1906        if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
1907            selected.push(object_ref);
1908        }
1909    }
1910
1911    let app_filter_matched = !selected.is_empty();
1912    SelectedRoots {
1913        roots: selected,
1914        scoped: true,
1915        pid: None,
1916        app_filter_matched,
1917    }
1918}
1919
1920async fn root_matches(
1921    conn: &AccessibilityConnection,
1922    object_ref: &ObjectRefOwned,
1923    needle: &str,
1924    remaining_child_reads: &mut usize,
1925) -> bool {
1926    let Ok(proxy) = open_accessible(conn, object_ref).await else {
1927        return object_ref_id(object_ref)
1928            .to_ascii_lowercase()
1929            .contains(needle);
1930    };
1931
1932    if proxy_matches(&proxy, object_ref, needle).await {
1933        return true;
1934    }
1935
1936    for child_ref in children_up_to(&proxy, ROOT_MATCH_CHILD_LIMIT, remaining_child_reads)
1937        .await
1938        .map(|batch| batch.items)
1939        .unwrap_or_default()
1940    {
1941        let Ok(child_proxy) = open_accessible(conn, &child_ref).await else {
1942            continue;
1943        };
1944        if proxy_matches(&child_proxy, &child_ref, needle).await {
1945            return true;
1946        }
1947    }
1948
1949    false
1950}
1951
1952async fn proxy_matches(
1953    proxy: &AccessibleProxy<'_>,
1954    object_ref: &ObjectRefOwned,
1955    needle: &str,
1956) -> bool {
1957    let name = proxy.name().await.unwrap_or_default();
1958    let role = proxy.get_role_name().await.unwrap_or_default();
1959    format!("{} {} {}", object_ref_id(object_ref), name, role)
1960        .to_ascii_lowercase()
1961        .contains(needle)
1962}
1963
1964async fn read_app_summary(
1965    proxy: &AccessibleProxy<'_>,
1966    object_ref: &ObjectRefOwned,
1967    dbus: Option<&DBusProxy<'_>>,
1968) -> AccessibleAppSummary {
1969    AccessibleAppSummary {
1970        object_ref: object_ref_id(object_ref),
1971        name: optional_string(proxy.name().await.ok()),
1972        pid: object_ref_pid(dbus, object_ref).await,
1973        role: role_name(proxy).await,
1974        child_count: proxy.child_count().await.unwrap_or_default(),
1975        bounds: bounds(proxy).await,
1976    }
1977}
1978
1979async fn read_node(
1980    proxy: &AccessibleProxy<'_>,
1981    object_ref: &ObjectRefOwned,
1982    index: u32,
1983    parent_index: Option<u32>,
1984    depth: u32,
1985) -> AccessibilityNode {
1986    let proxies = proxy.proxies().await.ok();
1987
1988    AccessibilityNode {
1989        index,
1990        parent_index,
1991        depth,
1992        object_ref: object_ref_id(object_ref),
1993        role: role_name(proxy).await,
1994        name: optional_string(proxy.name().await.ok()),
1995        description: optional_string(proxy.description().await.ok()),
1996        child_count: proxy.child_count().await.unwrap_or_default(),
1997        bounds: bounds_from_proxies(proxies.as_ref(), proxy).await,
1998        states: states_from_proxy(proxy).await,
1999        actions: actions_from_proxies(proxies.as_ref()).await,
2000        value: value_from_proxies(proxies.as_ref()).await,
2001        text: text_from_proxies(proxies.as_ref()).await,
2002        supports_editable_text: supports_editable_text(proxies.as_ref()).await,
2003    }
2004}
2005
2006/// Role reported when neither AT-SPI role read succeeds.
2007pub(crate) const UNKNOWN_ROLE: &str = "unknown";
2008
2009async fn role_name(proxy: &AccessibleProxy<'_>) -> String {
2010    if let Ok(role) = proxy.get_role_name().await {
2011        if !role.trim().is_empty() {
2012            return role;
2013        }
2014    }
2015    proxy
2016        .get_role()
2017        .await
2018        .map(|role| format!("{role:?}"))
2019        .unwrap_or_else(|_| UNKNOWN_ROLE.to_string())
2020}
2021
2022async fn bounds(proxy: &AccessibleProxy<'_>) -> Option<Bounds> {
2023    bounds_from_proxies(proxy.proxies().await.ok().as_ref(), proxy).await
2024}
2025
2026/// Pid of the process that owns an AT-SPI object ref (`:bus/path`), from the
2027/// accessibility bus. `None` when the owner is gone or the bus cannot say.
2028pub(crate) async fn object_ref_owner_pid(object_ref_id: &str) -> Result<Option<u32>> {
2029    let object_ref = object_ref_from_id(object_ref_id)?;
2030    let conn = connect().await?;
2031    let dbus = DBusProxy::new(conn.connection()).await.ok();
2032    Ok(object_ref_pid(dbus.as_ref(), &object_ref).await)
2033}
2034
2035/// PID of the application root, which may differ from an embedded element's process.
2036pub(crate) async fn object_ref_application_pid(object_ref_id: &str) -> Result<Option<u32>> {
2037    let object_ref = object_ref_from_id(object_ref_id)?;
2038    let conn = connect().await?;
2039    let application = timeout(ACTION_QUERY_TIMEOUT, async {
2040        let proxy = open_accessible(&conn, &object_ref).await.ok()?;
2041        proxy.get_application().await.ok()
2042    })
2043    .await
2044    .ok()
2045    .flatten()
2046    .filter(|application| !application.is_null());
2047    let dbus = DBusProxy::new(conn.connection()).await.ok();
2048    Ok(object_ref_pid(dbus.as_ref(), application.as_ref().unwrap_or(&object_ref)).await)
2049}
2050
2051async fn object_ref_pid(dbus: Option<&DBusProxy<'_>>, object_ref: &ObjectRefOwned) -> Option<u32> {
2052    let dbus = dbus?;
2053    let bus_name = BusName::try_from(object_ref.name_as_str()?.to_string()).ok()?;
2054    dbus.get_connection_unix_process_id(bus_name).await.ok()
2055}
2056
2057async fn bounds_from_proxies(
2058    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
2059    proxy: &AccessibleProxy<'_>,
2060) -> Option<Bounds> {
2061    let owned_proxies;
2062    let proxies = if let Some(proxies) = proxies {
2063        proxies
2064    } else {
2065        owned_proxies = proxy.proxies().await.ok()?;
2066        &owned_proxies
2067    };
2068    let component = proxies.component().await.ok()?;
2069    let (x, y, width, height) = component.get_extents(CoordType::Screen).await.ok()?;
2070    normalize_bounds(Bounds {
2071        x,
2072        y,
2073        width,
2074        height,
2075    })
2076}
2077
2078fn normalize_bounds(bounds: Bounds) -> Option<Bounds> {
2079    if bounds.width <= 0 || bounds.height <= 0 {
2080        return None;
2081    }
2082    if bounds.x <= i32::MIN / 2 || bounds.y <= i32::MIN / 2 {
2083        return None;
2084    }
2085    Some(bounds)
2086}
2087
2088async fn actions_from_proxies(
2089    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
2090) -> Vec<AccessibilityAction> {
2091    let Some(proxies) = proxies else {
2092        return Vec::new();
2093    };
2094    let Ok(action_proxy) = proxies.action().await else {
2095        return Vec::new();
2096    };
2097
2098    read_actions(&action_proxy)
2099        .await
2100        .unwrap_or_default()
2101        .into_iter()
2102        .enumerate()
2103        .map(|(index, action)| AccessibilityAction {
2104            index: index as i32,
2105            name: action.name,
2106            description: action.description,
2107            keybinding: action.keybinding,
2108        })
2109        .collect()
2110}
2111
2112async fn read_actions(proxy: &ActionProxy<'_>) -> Result<Vec<atspi::Action>> {
2113    // WebKit can advertise GetActions without replying to it. The indexed
2114    // methods work, and must also be used when revalidating a cached action.
2115    // Keep the wire spelling: the current atspi binding asks for "Nactions".
2116    timeout(ACTION_QUERY_TIMEOUT, async {
2117        let count = proxy
2118            .inner()
2119            .get_property::<i32>("NActions")
2120            .await
2121            .context("failed to read AT-SPI action count")?;
2122        if count > MAX_ACTIONS_PER_OBJECT {
2123            return Err(anyhow!(
2124                "AT-SPI action count {count} exceeds the {MAX_ACTIONS_PER_OBJECT}-action read budget"
2125            ));
2126        }
2127        if count < 0 {
2128            return Err(anyhow!("invalid negative AT-SPI action count {count}"));
2129        }
2130        Ok(stream::iter(0..count)
2131            .map(|index| async move {
2132                let (name, description, keybinding) = tokio::join!(
2133                    proxy.get_name(index),
2134                    proxy.get_description(index),
2135                    proxy.get_key_binding(index),
2136                );
2137                atspi::Action {
2138                    name: name.unwrap_or_default(),
2139                    description: description.unwrap_or_default(),
2140                    keybinding: keybinding.unwrap_or_default(),
2141                }
2142            })
2143            .buffered(CHILD_READ_CONCURRENCY)
2144            .collect()
2145            .await)
2146    })
2147    .await
2148    .context("AT-SPI action reads exceeded their 5-second deadline")?
2149}
2150
2151async fn states_from_proxy(proxy: &AccessibleProxy<'_>) -> Vec<String> {
2152    proxy
2153        .get_state()
2154        .await
2155        .map(state_labels)
2156        .unwrap_or_default()
2157}
2158
2159async fn value_from_proxies(
2160    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
2161) -> Option<AccessibilityValue> {
2162    let value = proxies?.value().await.ok()?;
2163    Some(AccessibilityValue {
2164        current: value.current_value().await.ok()?,
2165        minimum: value.minimum_value().await.ok()?,
2166        maximum: value.maximum_value().await.ok()?,
2167        minimum_increment: value.minimum_increment().await.ok()?,
2168        text: optional_string(value.text().await.ok()),
2169    })
2170}
2171
2172/// `GetNSelections`. The generated proxy method calls `GetNselections`,
2173/// which no toolkit implements, so call the method by its real name.
2174async fn text_selection_count(text: &TextProxy<'_>) -> zbus::Result<i32> {
2175    text.inner()
2176        .call_method("GetNSelections", &())
2177        .await?
2178        .body()
2179        .deserialize()
2180}
2181
2182async fn text_from_proxies(
2183    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
2184) -> Option<AccessibilityText> {
2185    let text = proxies?.text().await.ok()?;
2186    let character_count = text.character_count().await.ok()?.max(0);
2187    let caret_offset = text.caret_offset().await.ok();
2188    let capped_count = character_count.min(MAX_TEXT_READBACK_CHARS);
2189    let content = if capped_count > 0 {
2190        optional_string(text.get_text(0, capped_count).await.ok())
2191    } else {
2192        None
2193    };
2194    let selection_count = text_selection_count(&text)
2195        .await
2196        .unwrap_or_default()
2197        .clamp(0, MAX_TEXT_SELECTIONS);
2198    let mut selections = Vec::new();
2199    for index in 0..selection_count {
2200        if let Ok((start_offset, end_offset)) = text.get_selection(index).await {
2201            selections.push(AccessibilityTextSelection {
2202                start_offset,
2203                end_offset,
2204            });
2205        }
2206    }
2207
2208    Some(AccessibilityText {
2209        character_count,
2210        caret_offset,
2211        content,
2212        truncated: character_count > MAX_TEXT_READBACK_CHARS,
2213        selections,
2214    })
2215}
2216
2217async fn supports_editable_text(proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>) -> bool {
2218    let Some(proxies) = proxies else {
2219        return false;
2220    };
2221    proxies.editable_text().await.is_ok()
2222}
2223
2224fn state_labels(state_set: StateSet) -> Vec<String> {
2225    state_set.iter().map(|state| state.to_string()).collect()
2226}
2227
2228fn select_action_index(actions: &[atspi::Action], requested_action: Option<&str>) -> Result<i32> {
2229    if actions.is_empty() {
2230        return Err(anyhow!("element exposes no AT-SPI actions"));
2231    }
2232
2233    if let Some(requested_action) = requested_action
2234        .map(str::trim)
2235        .filter(|value| !value.is_empty())
2236    {
2237        let requested_action = requested_action.to_ascii_lowercase();
2238        if let Some((index, _)) = actions.iter().enumerate().find(|(_, action)| {
2239            action.name.to_ascii_lowercase() == requested_action
2240                || action.description.to_ascii_lowercase() == requested_action
2241        }) {
2242            return Ok(index as i32);
2243        }
2244
2245        if let Ok(index) = requested_action.parse::<usize>() {
2246            if index < actions.len() {
2247                return Ok(index as i32);
2248            }
2249        }
2250
2251        return Err(anyhow!(
2252            "requested AT-SPI action was not found; available actions: {}",
2253            actions
2254                .iter()
2255                .map(|action| action.name.as_str())
2256                .collect::<Vec<_>>()
2257                .join(", ")
2258        ));
2259    }
2260
2261    Ok(if actions.len() > 1 { 1 } else { 0 })
2262}
2263
2264fn select_named_action_index(actions: &[atspi::Action], name: &str) -> Result<i32> {
2265    actions
2266        .iter()
2267        .position(|action| action.name.eq_ignore_ascii_case(name))
2268        .map(|index| index as i32)
2269        .ok_or_else(|| {
2270            anyhow!("cached AT-SPI action name is no longer available; refresh get_app_state")
2271        })
2272}
2273
2274fn optional_string(value: Option<String>) -> Option<String> {
2275    value
2276        .map(|value| value.trim().to_string())
2277        .filter(|value| !value.is_empty())
2278}
2279
2280fn object_ref_from_id(object_ref_id: &str) -> Result<ObjectRefOwned> {
2281    let (name, path) = split_object_ref_id(object_ref_id)?;
2282    let name = UniqueName::try_from(name.to_string())
2283        .with_context(|| format!("invalid AT-SPI bus name in object ref {object_ref_id}"))?;
2284    let path = ObjectPath::try_from(path.to_string())
2285        .with_context(|| format!("invalid AT-SPI object path in object ref {object_ref_id}"))?;
2286    Ok(ObjectRef::new_owned(name, path))
2287}
2288
2289fn split_object_ref_id(object_ref_id: &str) -> Result<(&str, &str)> {
2290    let Some(path_start) = object_ref_id.find('/') else {
2291        return Err(anyhow!(
2292            "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
2293        ));
2294    };
2295    let (name, path) = object_ref_id.split_at(path_start);
2296    if name.is_empty() || path.is_empty() {
2297        return Err(anyhow!(
2298            "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
2299        ));
2300    }
2301    Ok((name, path))
2302}
2303
2304fn object_ref_id(object_ref: &ObjectRefOwned) -> String {
2305    format!(
2306        "{}{}",
2307        object_ref.name_as_str().unwrap_or(""),
2308        object_ref.path_as_str()
2309    )
2310}
2311
2312#[cfg(test)]
2313mod tests {
2314    use super::insert_fixture::*;
2315    use super::*;
2316    use crate::test_sockets::PrivateBus;
2317    use std::sync::{
2318        atomic::{AtomicUsize, Ordering},
2319        Arc, Mutex,
2320    };
2321
2322    const EDITABLE_FIXTURE_PATH: &str = "/editable";
2323
2324    #[derive(Default)]
2325    struct FakeEditableState {
2326        contents: String,
2327        mutations: usize,
2328        character_count_reads: usize,
2329        get_text_reads: usize,
2330        numeric_value: f64,
2331        numeric_writes: usize,
2332    }
2333
2334    #[derive(Clone, Copy)]
2335    enum FakeMutation {
2336        Apply,
2337        NoOp,
2338        Delayed,
2339        Normalize,
2340        Mask,
2341        Reject,
2342        Fail,
2343    }
2344
2345    #[derive(Clone, Copy, Debug)]
2346    enum FakeTextOffsetModel {
2347        UnicodeScalars,
2348        Utf16CodeUnits,
2349    }
2350
2351    #[derive(Clone, Copy)]
2352    enum FakeTextBehavior {
2353        Normal,
2354        CharacterCountError,
2355        GetTextError,
2356        NegativeCount,
2357        StallCharacterCount,
2358    }
2359
2360    #[derive(Clone, Copy)]
2361    struct FakeEditableConfig {
2362        mutation: FakeMutation,
2363        text: FakeTextBehavior,
2364        text_offset_model: FakeTextOffsetModel,
2365        role: atspi::Role,
2366        has_text: bool,
2367        has_value: bool,
2368    }
2369
2370    impl Default for FakeEditableConfig {
2371        fn default() -> Self {
2372            Self {
2373                mutation: FakeMutation::Apply,
2374                text: FakeTextBehavior::Normal,
2375                text_offset_model: FakeTextOffsetModel::Utf16CodeUnits,
2376                role: atspi::Role::Text,
2377                has_text: true,
2378                has_value: false,
2379            }
2380        }
2381    }
2382
2383    struct FakeEditableAccessible(FakeEditableConfig);
2384
2385    #[zbus::interface(name = "org.a11y.atspi.Accessible")]
2386    impl FakeEditableAccessible {
2387        #[zbus(property)]
2388        fn child_count(&self) -> i32 {
2389            0
2390        }
2391
2392        fn get_interfaces(&self) -> Vec<&str> {
2393            let mut interfaces = vec!["org.a11y.atspi.Accessible", "org.a11y.atspi.EditableText"];
2394            if self.0.has_text {
2395                interfaces.push("org.a11y.atspi.Text");
2396            }
2397            if self.0.has_value {
2398                interfaces.push("org.a11y.atspi.Value");
2399            }
2400            interfaces
2401        }
2402
2403        fn get_role(&self) -> atspi::Role {
2404            self.0.role
2405        }
2406    }
2407
2408    struct FakeEditableText {
2409        state: Arc<Mutex<FakeEditableState>>,
2410        mutation: FakeMutation,
2411    }
2412
2413    #[zbus::interface(name = "org.a11y.atspi.EditableText")]
2414    impl FakeEditableText {
2415        fn set_text_contents(&self, new_contents: &str) -> zbus::fdo::Result<bool> {
2416            self.state.lock().unwrap().mutations += 1;
2417            match self.mutation {
2418                FakeMutation::Apply => {
2419                    self.state.lock().unwrap().contents = new_contents.to_string();
2420                }
2421                FakeMutation::NoOp => {}
2422                FakeMutation::Delayed => {
2423                    let state = Arc::clone(&self.state);
2424                    let new_contents = new_contents.to_string();
2425                    tokio::spawn(async move {
2426                        tokio::time::sleep(Duration::from_millis(150)).await;
2427                        state.lock().unwrap().contents = new_contents;
2428                    });
2429                }
2430                FakeMutation::Normalize => {
2431                    self.state.lock().unwrap().contents = new_contents.replace("\r\n", "\n");
2432                }
2433                FakeMutation::Mask => {
2434                    self.state.lock().unwrap().contents = "•".repeat(new_contents.chars().count());
2435                }
2436                FakeMutation::Reject => return Ok(false),
2437                FakeMutation::Fail => {
2438                    return Err(zbus::fdo::Error::Failed(
2439                        "injected SetTextContents failure".into(),
2440                    ));
2441                }
2442            }
2443            Ok(true)
2444        }
2445    }
2446
2447    fn unicode_scalar_offset_to_byte_index(contents: &str, offset: usize) -> Option<usize> {
2448        contents
2449            .char_indices()
2450            .map(|(byte_index, _)| byte_index)
2451            .chain(std::iter::once(contents.len()))
2452            .nth(offset)
2453    }
2454
2455    fn utf16_offset_to_byte_index(contents: &str, offset: usize) -> Option<usize> {
2456        let mut utf16_offset = 0;
2457        for (byte_index, character) in contents.char_indices() {
2458            if utf16_offset == offset {
2459                return Some(byte_index);
2460            }
2461            utf16_offset += character.len_utf16();
2462        }
2463        (utf16_offset == offset).then_some(contents.len())
2464    }
2465
2466    struct FakeText {
2467        state: Arc<Mutex<FakeEditableState>>,
2468        behavior: FakeTextBehavior,
2469        text_offset_model: FakeTextOffsetModel,
2470    }
2471
2472    #[zbus::interface(name = "org.a11y.atspi.Text")]
2473    impl FakeText {
2474        #[zbus(property)]
2475        async fn character_count(&self) -> zbus::fdo::Result<i32> {
2476            self.state.lock().unwrap().character_count_reads += 1;
2477            match self.behavior {
2478                FakeTextBehavior::CharacterCountError => Err(zbus::fdo::Error::Failed(
2479                    "injected CharacterCount failure".into(),
2480                )),
2481                FakeTextBehavior::NegativeCount => Ok(-1),
2482                FakeTextBehavior::StallCharacterCount => std::future::pending().await,
2483                _ => {
2484                    let state = self.state.lock().unwrap();
2485                    let count = match self.text_offset_model {
2486                        FakeTextOffsetModel::UnicodeScalars => state.contents.chars().count(),
2487                        FakeTextOffsetModel::Utf16CodeUnits => {
2488                            state.contents.encode_utf16().count()
2489                        }
2490                    };
2491                    Ok(count as i32)
2492                }
2493            }
2494        }
2495
2496        fn get_text(&self, start_offset: i32, end_offset: i32) -> zbus::fdo::Result<String> {
2497            let mut state = self.state.lock().unwrap();
2498            state.get_text_reads += 1;
2499            if matches!(self.behavior, FakeTextBehavior::GetTextError) {
2500                return Err(zbus::fdo::Error::Failed("injected GetText failure".into()));
2501            }
2502            if start_offset < 0 || end_offset < start_offset {
2503                return Err(zbus::fdo::Error::InvalidArgs("invalid text offsets".into()));
2504            }
2505            let offset_to_byte_index = match self.text_offset_model {
2506                FakeTextOffsetModel::UnicodeScalars => unicode_scalar_offset_to_byte_index,
2507                FakeTextOffsetModel::Utf16CodeUnits => utf16_offset_to_byte_index,
2508            };
2509            let start = offset_to_byte_index(&state.contents, start_offset as usize)
2510                .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid text offsets".into()))?;
2511            let end = offset_to_byte_index(&state.contents, end_offset as usize)
2512                .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid text offsets".into()))?;
2513            Ok(state.contents[start..end].to_string())
2514        }
2515    }
2516
2517    struct FakeValue(Arc<Mutex<FakeEditableState>>);
2518
2519    #[zbus::interface(name = "org.a11y.atspi.Value")]
2520    impl FakeValue {
2521        #[zbus(property)]
2522        fn current_value(&self) -> f64 {
2523            self.0.lock().unwrap().numeric_value
2524        }
2525
2526        #[zbus(property)]
2527        fn set_current_value(&self, value: f64) {
2528            let mut state = self.0.lock().unwrap();
2529            state.numeric_value = value;
2530            state.numeric_writes += 1;
2531        }
2532    }
2533
2534    async fn start_editable_fixture(
2535        initial_contents: &str,
2536        config: FakeEditableConfig,
2537    ) -> (
2538        PrivateBus,
2539        zbus::Connection,
2540        zbus::Connection,
2541        Arc<Mutex<FakeEditableState>>,
2542    ) {
2543        let bus = PrivateBus::start("cul-atspi-bus");
2544        let address = bus.address();
2545        let state = Arc::new(Mutex::new(FakeEditableState {
2546            contents: initial_contents.to_string(),
2547            ..Default::default()
2548        }));
2549        let mut builder = zbus::connection::Builder::address(address)
2550            .unwrap()
2551            .serve_at(EDITABLE_FIXTURE_PATH, FakeEditableAccessible(config))
2552            .unwrap()
2553            .serve_at(
2554                EDITABLE_FIXTURE_PATH,
2555                FakeEditableText {
2556                    state: Arc::clone(&state),
2557                    mutation: config.mutation,
2558                },
2559            )
2560            .unwrap();
2561        if config.has_text {
2562            builder = builder
2563                .serve_at(
2564                    EDITABLE_FIXTURE_PATH,
2565                    FakeText {
2566                        state: Arc::clone(&state),
2567                        behavior: config.text,
2568                        text_offset_model: config.text_offset_model,
2569                    },
2570                )
2571                .unwrap();
2572        }
2573        if config.has_value {
2574            builder = builder
2575                .serve_at(EDITABLE_FIXTURE_PATH, FakeValue(Arc::clone(&state)))
2576                .unwrap();
2577        }
2578        let peer = builder.build().await.unwrap();
2579        let client = zbus::connection::Builder::address(address)
2580            .unwrap()
2581            .build()
2582            .await
2583            .unwrap();
2584        (bus, peer, client, state)
2585    }
2586
2587    async fn dispatch_editable_fixture(
2588        client: &zbus::Connection,
2589        peer: &zbus::Connection,
2590        value: &str,
2591    ) -> Result<ValueSetInvocation> {
2592        let proxy = AccessibleProxy::builder(client)
2593            .destination(peer.unique_name().unwrap().clone())
2594            .unwrap()
2595            .path(EDITABLE_FIXTURE_PATH)
2596            .unwrap()
2597            .cache_properties(zbus::proxy::CacheProperties::No)
2598            .build()
2599            .await
2600            .unwrap();
2601        let proxies = proxy.proxies().await.unwrap();
2602        set_element_value_with_proxies(&proxy, &proxies, value).await
2603    }
2604
2605    fn editable_outcome(invocation: ValueSetInvocation) -> EditableTextSetOutcome {
2606        match invocation {
2607            ValueSetInvocation::EditableText { outcome } => outcome,
2608            ValueSetInvocation::Numeric { .. } => panic!("expected editable-text dispatch"),
2609        }
2610    }
2611
2612    struct FakeAccessible {
2613        name: String,
2614        children: Vec<(String, OwnedObjectPath)>,
2615        has_action: bool,
2616    }
2617
2618    #[zbus::interface(name = "org.a11y.atspi.Accessible")]
2619    impl FakeAccessible {
2620        #[zbus(property)]
2621        fn child_count(&self) -> i32 {
2622            self.children.len() as i32
2623        }
2624
2625        #[zbus(property)]
2626        fn name(&self) -> &str {
2627            &self.name
2628        }
2629
2630        fn get_interfaces(&self) -> Vec<&str> {
2631            let mut interfaces = vec!["org.a11y.atspi.Accessible"];
2632            if self.has_action {
2633                interfaces.push("org.a11y.atspi.Action");
2634            }
2635            interfaces
2636        }
2637
2638        fn get_child_at_index(
2639            &self,
2640            index: i32,
2641        ) -> zbus::fdo::Result<((String, OwnedObjectPath),)> {
2642            self.children
2643                .get(index as usize)
2644                .cloned()
2645                .map(|child| (child,))
2646                .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid child index".into()))
2647        }
2648    }
2649
2650    struct FakeWebKitAction {
2651        count: i32,
2652        stall_indexed_reads: bool,
2653        name_reads: Arc<AtomicUsize>,
2654    }
2655
2656    impl Default for FakeWebKitAction {
2657        fn default() -> Self {
2658            Self {
2659                count: 1,
2660                stall_indexed_reads: false,
2661                name_reads: Arc::default(),
2662            }
2663        }
2664    }
2665
2666    #[zbus::interface(name = "org.a11y.atspi.Action")]
2667    impl FakeWebKitAction {
2668        async fn get_actions(&self) -> Vec<atspi::Action> {
2669            std::future::pending().await
2670        }
2671
2672        #[zbus(property, name = "NActions")]
2673        fn nactions(&self) -> i32 {
2674            self.count
2675        }
2676
2677        async fn get_name(&self, index: i32) -> &str {
2678            assert!((0..self.count).contains(&index));
2679            self.name_reads.fetch_add(1, Ordering::SeqCst);
2680            if self.stall_indexed_reads {
2681                std::future::pending::<()>().await;
2682            }
2683            "click"
2684        }
2685
2686        async fn get_localized_name(&self, index: i32) -> &str {
2687            assert!((0..self.count).contains(&index));
2688            "Activate"
2689        }
2690
2691        fn get_description(&self, index: i32) -> &str {
2692            assert!((0..self.count).contains(&index));
2693            "Activate the control"
2694        }
2695
2696        fn get_key_binding(&self, index: i32) -> &str {
2697            assert!((0..self.count).contains(&index));
2698            "Return"
2699        }
2700
2701        fn do_action(&self, index: i32) -> bool {
2702            index == 0
2703        }
2704    }
2705
2706    struct FakeQtAction;
2707
2708    #[zbus::interface(name = "org.a11y.atspi.Action")]
2709    impl FakeQtAction {
2710        #[zbus(property, name = "NActions")]
2711        fn nactions(&self) -> i32 {
2712            2
2713        }
2714
2715        fn get_name(&self, index: i32) -> &str {
2716            match index {
2717                0 => "Press",
2718                1 => "Set Focus",
2719                _ => panic!("invalid action index"),
2720            }
2721        }
2722
2723        fn get_description(&self, index: i32) -> &str {
2724            match index {
2725                0 => "Eylemi tetikler",
2726                1 => "Odağı ayarlar",
2727                _ => panic!("invalid action index"),
2728            }
2729        }
2730
2731        fn get_key_binding(&self, _index: i32) -> &str {
2732            ""
2733        }
2734    }
2735
2736    #[tokio::test]
2737    async fn editable_text_true_without_publication_is_mismatch_and_mutates_once() {
2738        let (_bus, peer, client, state) = start_editable_fixture(
2739            "original",
2740            FakeEditableConfig {
2741                mutation: FakeMutation::NoOp,
2742                ..Default::default()
2743            },
2744        )
2745        .await;
2746
2747        let outcome = editable_outcome(
2748            dispatch_editable_fixture(&client, &peer, "modified")
2749                .await
2750                .unwrap(),
2751        );
2752
2753        // A last poll can cross the same fixed deadline. Both outcomes mean
2754        // an unverified write; neither permits verification or replay.
2755        assert!(
2756            matches!(
2757                outcome,
2758                EditableTextSetOutcome::MismatchAfterDeadline
2759                    | EditableTextSetOutcome::ReadbackTimedOut
2760            ),
2761            "{outcome:?}"
2762        );
2763        let state = state.lock().unwrap();
2764        assert_eq!(state.mutations, 1);
2765        assert!(state.character_count_reads > 1);
2766        assert!(state.get_text_reads > 1);
2767        assert_eq!(state.contents, "original");
2768    }
2769
2770    #[tokio::test]
2771    async fn editable_text_exact_readback_accepts_empty_whitespace_and_trailing_newline() {
2772        let (_bus, peer, client, state) =
2773            start_editable_fixture("initial", FakeEditableConfig::default()).await;
2774        let values = [
2775            "",
2776            " \t\n",
2777            "  leading and trailing\t \n",
2778            "trailing newline\n",
2779            "Case and unicode € 内容 😀🦀𐐷",
2780        ];
2781
2782        for value in values {
2783            let outcome = editable_outcome(
2784                dispatch_editable_fixture(&client, &peer, value)
2785                    .await
2786                    .unwrap(),
2787            );
2788            assert_eq!(outcome, EditableTextSetOutcome::Verified);
2789            assert_eq!(state.lock().unwrap().contents, value);
2790        }
2791        assert_eq!(state.lock().unwrap().mutations, values.len());
2792    }
2793
2794    #[tokio::test]
2795    async fn editable_text_non_bmp_readback_supports_scalar_and_utf16_peers() {
2796        let requested = "🦀 GTK verified ";
2797        for text_offset_model in [
2798            FakeTextOffsetModel::UnicodeScalars,
2799            FakeTextOffsetModel::Utf16CodeUnits,
2800        ] {
2801            let (_bus, peer, client, state) = start_editable_fixture(
2802                "",
2803                FakeEditableConfig {
2804                    text_offset_model,
2805                    ..Default::default()
2806                },
2807            )
2808            .await;
2809
2810            let outcome = editable_outcome(
2811                dispatch_editable_fixture(&client, &peer, requested)
2812                    .await
2813                    .unwrap(),
2814            );
2815
2816            assert_eq!(
2817                outcome,
2818                EditableTextSetOutcome::Verified,
2819                "{text_offset_model:?}"
2820            );
2821            let state = state.lock().unwrap();
2822            assert_eq!(state.mutations, 1);
2823            assert_eq!(state.contents, requested);
2824        }
2825    }
2826
2827    #[tokio::test]
2828    async fn editable_text_non_bmp_matching_count_does_not_accept_different_content() {
2829        let requested = "🦀 GTK verified ";
2830        let published = "🦀 GTK verified!";
2831        assert_eq!(requested.chars().count(), published.chars().count());
2832        assert_eq!(
2833            requested.encode_utf16().count(),
2834            published.encode_utf16().count()
2835        );
2836        for text_offset_model in [
2837            FakeTextOffsetModel::UnicodeScalars,
2838            FakeTextOffsetModel::Utf16CodeUnits,
2839        ] {
2840            let (_bus, peer, client, state) = start_editable_fixture(
2841                published,
2842                FakeEditableConfig {
2843                    mutation: FakeMutation::NoOp,
2844                    text_offset_model,
2845                    ..Default::default()
2846                },
2847            )
2848            .await;
2849
2850            let outcome = editable_outcome(
2851                dispatch_editable_fixture(&client, &peer, requested)
2852                    .await
2853                    .unwrap(),
2854            );
2855
2856            // A last poll can cross the same fixed deadline. Both outcomes
2857            // leave the write unverified without extending its deadline.
2858            assert!(
2859                matches!(
2860                    outcome,
2861                    EditableTextSetOutcome::MismatchAfterDeadline
2862                        | EditableTextSetOutcome::ReadbackTimedOut
2863                ),
2864                "{text_offset_model:?}: {outcome:?}"
2865            );
2866            let state = state.lock().unwrap();
2867            assert_eq!(state.mutations, 1);
2868            assert!(state.get_text_reads > 0, "{text_offset_model:?}");
2869            assert_eq!(state.contents, published);
2870        }
2871    }
2872
2873    #[tokio::test]
2874    async fn editable_text_readback_chunks_utf16_without_splitting_non_bmp() {
2875        let (_bus, peer, client, state) = start_editable_fixture(
2876            "",
2877            FakeEditableConfig {
2878                text_offset_model: FakeTextOffsetModel::Utf16CodeUnits,
2879                ..Default::default()
2880            },
2881        )
2882        .await;
2883        let mut expected = "a".repeat(EDITABLE_TEXT_READ_CHUNK_UNITS - 1);
2884        // The crab's surrogate pair straddles the first 4096-unit read boundary.
2885        expected.push_str("🦀é");
2886        expected.push_str(&"内容\n".repeat(1_200));
2887        assert!(expected.encode_utf16().count() > MAX_TEXT_READBACK_CHARS as usize);
2888
2889        let outcome = editable_outcome(
2890            dispatch_editable_fixture(&client, &peer, &expected)
2891                .await
2892                .unwrap(),
2893        );
2894
2895        assert_eq!(outcome, EditableTextSetOutcome::Verified);
2896        let state = state.lock().unwrap();
2897        assert_eq!(state.mutations, 1);
2898        assert!(state.get_text_reads >= 2);
2899        assert_eq!(state.contents, expected);
2900    }
2901
2902    #[tokio::test]
2903    async fn editable_text_readback_waits_for_async_publication() {
2904        let (_bus, peer, client, state) = start_editable_fixture(
2905            "pending",
2906            FakeEditableConfig {
2907                mutation: FakeMutation::Delayed,
2908                ..Default::default()
2909            },
2910        )
2911        .await;
2912
2913        let outcome = editable_outcome(
2914            dispatch_editable_fixture(&client, &peer, "published asynchronously")
2915                .await
2916                .unwrap(),
2917        );
2918
2919        assert_eq!(outcome, EditableTextSetOutcome::Verified);
2920        let state = state.lock().unwrap();
2921        assert_eq!(state.mutations, 1);
2922        assert!(state.character_count_reads > 1);
2923        assert_eq!(state.contents, "published asynchronously");
2924    }
2925
2926    #[tokio::test]
2927    async fn editable_text_normalization_mismatch_is_not_verified() {
2928        let (_bus, peer, client, state) = start_editable_fixture(
2929            "",
2930            FakeEditableConfig {
2931                mutation: FakeMutation::Normalize,
2932                ..Default::default()
2933            },
2934        )
2935        .await;
2936
2937        let outcome = editable_outcome(
2938            dispatch_editable_fixture(&client, &peer, "first\r\nsecond\n")
2939                .await
2940                .unwrap(),
2941        );
2942
2943        // A last poll can cross the same fixed deadline. Both outcomes mean
2944        // an unverified write; neither permits verification or replay.
2945        assert!(
2946            matches!(
2947                outcome,
2948                EditableTextSetOutcome::MismatchAfterDeadline
2949                    | EditableTextSetOutcome::ReadbackTimedOut
2950            ),
2951            "{outcome:?}"
2952        );
2953        let state = state.lock().unwrap();
2954        assert_eq!(state.mutations, 1);
2955        assert!(state.character_count_reads > 1);
2956        assert_eq!(state.contents, "first\nsecond\n");
2957    }
2958
2959    #[tokio::test]
2960    async fn password_text_is_unverifiable_even_when_requested_text_is_bullets() {
2961        let (_bus, peer, client, state) = start_editable_fixture(
2962            "",
2963            FakeEditableConfig {
2964                mutation: FakeMutation::Mask,
2965                role: atspi::Role::PasswordText,
2966                ..Default::default()
2967            },
2968        )
2969        .await;
2970
2971        let outcome = editable_outcome(
2972            dispatch_editable_fixture(&client, &peer, "••••••••")
2973                .await
2974                .unwrap(),
2975        );
2976
2977        assert_eq!(outcome, EditableTextSetOutcome::PasswordUnverifiable);
2978        let state = state.lock().unwrap();
2979        assert_eq!(state.mutations, 1);
2980        assert_eq!(state.character_count_reads, 0);
2981        assert_eq!(state.contents, "••••••••");
2982    }
2983
2984    #[tokio::test]
2985    async fn editable_text_missing_text_and_read_errors_are_structured_outcomes() {
2986        let configs = [
2987            FakeEditableConfig {
2988                has_text: false,
2989                ..Default::default()
2990            },
2991            FakeEditableConfig {
2992                text: FakeTextBehavior::CharacterCountError,
2993                ..Default::default()
2994            },
2995            FakeEditableConfig {
2996                text: FakeTextBehavior::GetTextError,
2997                ..Default::default()
2998            },
2999        ];
3000
3001        for config in configs {
3002            let (_bus, peer, client, state) = start_editable_fixture("before", config).await;
3003            let outcome = editable_outcome(
3004                dispatch_editable_fixture(&client, &peer, "readback may have changed")
3005                    .await
3006                    .unwrap(),
3007            );
3008            assert_eq!(outcome, EditableTextSetOutcome::ReadbackFailed);
3009            assert_eq!(state.lock().unwrap().mutations, 1);
3010        }
3011    }
3012
3013    #[tokio::test]
3014    async fn editable_text_negative_counts_and_stalled_readback_are_structured_outcomes() {
3015        let cases = [
3016            (
3017                FakeTextBehavior::NegativeCount,
3018                EditableTextSetOutcome::ReadbackFailed,
3019            ),
3020            (
3021                FakeTextBehavior::StallCharacterCount,
3022                EditableTextSetOutcome::ReadbackTimedOut,
3023            ),
3024        ];
3025
3026        for (text, expected) in cases {
3027            let (_bus, peer, client, state) = start_editable_fixture(
3028                "before",
3029                FakeEditableConfig {
3030                    text,
3031                    ..Default::default()
3032                },
3033            )
3034            .await;
3035            let outcome = editable_outcome(
3036                dispatch_editable_fixture(&client, &peer, "after")
3037                    .await
3038                    .unwrap(),
3039            );
3040            assert_eq!(outcome, expected);
3041            let state = state.lock().unwrap();
3042            assert_eq!(state.mutations, 1);
3043            assert_eq!(state.get_text_reads, 0);
3044        }
3045    }
3046
3047    #[tokio::test]
3048    async fn editable_text_rejection_and_mutation_errors_remain_errors() {
3049        let cases = [
3050            (FakeMutation::Reject, "rejected"),
3051            (FakeMutation::Fail, "failed to set"),
3052        ];
3053
3054        for (mutation, expected_error) in cases {
3055            let (_bus, peer, client, state) = start_editable_fixture(
3056                "before",
3057                FakeEditableConfig {
3058                    mutation,
3059                    ..Default::default()
3060                },
3061            )
3062            .await;
3063            let error = dispatch_editable_fixture(&client, &peer, "after")
3064                .await
3065                .unwrap_err();
3066            assert!(error.to_string().contains(expected_error));
3067            let state = state.lock().unwrap();
3068            assert_eq!(state.mutations, 1);
3069            assert_eq!(state.character_count_reads, 0);
3070        }
3071    }
3072
3073    #[tokio::test]
3074    async fn numeric_value_dispatch_remains_isolated_from_editable_text() {
3075        let (_bus, peer, client, state) = start_editable_fixture(
3076            "unchanged",
3077            FakeEditableConfig {
3078                has_value: true,
3079                ..Default::default()
3080            },
3081        )
3082        .await;
3083
3084        let invocation = dispatch_editable_fixture(&client, &peer, "42.5")
3085            .await
3086            .unwrap();
3087
3088        match invocation {
3089            ValueSetInvocation::Numeric { value } => assert_eq!(value, 42.5),
3090            ValueSetInvocation::EditableText { .. } => panic!("expected numeric dispatch"),
3091        }
3092        let state = state.lock().unwrap();
3093        assert_eq!(state.numeric_value, 42.5);
3094        assert_eq!(state.numeric_writes, 1);
3095        assert_eq!(state.mutations, 0);
3096        assert_eq!(state.character_count_reads, 0);
3097        assert_eq!(state.contents, "unchanged");
3098    }
3099
3100    #[tokio::test]
3101    async fn qt_action_names_do_not_depend_on_localized_names() {
3102        let bus = PrivateBus::start("cul-atspi-bus");
3103        let address = bus.address();
3104        let peer = zbus::connection::Builder::address(address)
3105            .unwrap()
3106            .serve_at("/actions/qt", FakeQtAction)
3107            .unwrap()
3108            .build()
3109            .await
3110            .unwrap();
3111        let client = zbus::connection::Builder::address(address)
3112            .unwrap()
3113            .build()
3114            .await
3115            .unwrap();
3116        let action = ActionProxy::builder(&client)
3117            .destination(peer.unique_name().unwrap().clone())
3118            .unwrap()
3119            .path("/actions/qt")
3120            .unwrap()
3121            .cache_properties(zbus::proxy::CacheProperties::No)
3122            .build()
3123            .await
3124            .unwrap();
3125
3126        let actions = timeout(Duration::from_secs(1), read_actions(&action))
3127            .await
3128            .expect("Qt action reads must not require GetLocalizedName")
3129            .unwrap();
3130
3131        assert_eq!(actions.len(), 2);
3132        assert_eq!(actions[0].name, "Press");
3133        assert_eq!(actions[0].description, "Eylemi tetikler");
3134        assert_eq!(actions[1].name, "Set Focus");
3135        assert_eq!(actions[1].description, "Odağı ayarlar");
3136        assert_eq!(select_named_action_index(&actions, "press").unwrap(), 0);
3137    }
3138
3139    #[tokio::test]
3140    async fn pid_roots_keep_all_toolkits_and_a_missing_pid_stays_empty() {
3141        let bus = PrivateBus::start("cul-atspi-roots");
3142        let root_path = "/org/a11y/atspi/accessible/root";
3143        let mut peers = Vec::new();
3144        for child_count in [0, 1] {
3145            let children = (0..child_count)
3146                .map(|_| (String::new(), "/child".try_into().unwrap()))
3147                .collect();
3148            peers.push(
3149                zbus::connection::Builder::address(bus.address())
3150                    .unwrap()
3151                    .serve_at(
3152                        root_path,
3153                        FakeAccessible {
3154                            name: "qt-app".into(),
3155                            children,
3156                            has_action: false,
3157                        },
3158                    )
3159                    .unwrap()
3160                    .build()
3161                    .await
3162                    .unwrap(),
3163            );
3164        }
3165        let conn = AccessibilityConnection::from_address(bus.address().parse().unwrap())
3166            .await
3167            .unwrap();
3168        let roots = peers
3169            .iter()
3170            .map(|peer| {
3171                object_ref_from_id(&format!("{}{root_path}", peer.unique_name().unwrap())).unwrap()
3172            })
3173            .collect::<Vec<_>>();
3174        let pid = std::process::id();
3175        let mut budget = MAX_DISCOVERY_CHILD_READS;
3176        let selected = select_roots(&conn, roots.clone(), None, Some(pid), None, &mut budget).await;
3177        assert_eq!(selected.roots, roots);
3178        assert_eq!(selected.pid, Some(pid));
3179        assert!(selected.scoped);
3180        assert!(selected.app_filter_matched);
3181
3182        let explicit = object_ref_id(&roots[1]);
3183        let selected = select_roots(
3184            &conn,
3185            roots.clone(),
3186            Some(&explicit),
3187            Some(pid),
3188            None,
3189            &mut budget,
3190        )
3191        .await;
3192        assert_eq!(selected.roots, vec![roots[1].clone()]);
3193        assert_eq!(selected.pid, Some(pid));
3194        assert!(selected.app_filter_matched);
3195
3196        let selected = select_roots(
3197            &conn,
3198            roots.clone(),
3199            None,
3200            Some(u32::MAX),
3201            None,
3202            &mut budget,
3203        )
3204        .await;
3205        assert!(selected.roots.is_empty());
3206        assert_eq!(selected.pid, None);
3207        assert!(selected.scoped);
3208        assert!(!selected.app_filter_matched);
3209
3210        // A listed window whose pid owns no root (a Flatpak app behind
3211        // xdg-dbus-proxy) finds the root by name. No pid is recorded.
3212        let selected = select_roots(
3213            &conn,
3214            roots.clone(),
3215            None,
3216            Some(u32::MAX),
3217            Some("qt-app"),
3218            &mut budget,
3219        )
3220        .await;
3221        assert_eq!(selected.roots, roots);
3222        assert_eq!(selected.pid, None);
3223        assert!(selected.scoped);
3224        assert!(selected.app_filter_matched);
3225
3226        // The name is a fallback only: the pid's own roots win.
3227        let selected = select_roots(
3228            &conn,
3229            roots.clone(),
3230            None,
3231            Some(pid),
3232            Some("no such window"),
3233            &mut budget,
3234        )
3235        .await;
3236        assert_eq!(selected.roots, roots);
3237        assert_eq!(selected.pid, Some(pid));
3238
3239        let selected = select_roots(
3240            &conn,
3241            roots,
3242            None,
3243            Some(u32::MAX),
3244            Some("no such window"),
3245            &mut budget,
3246        )
3247        .await;
3248        assert!(selected.roots.is_empty());
3249        assert!(!selected.app_filter_matched);
3250    }
3251
3252    #[tokio::test]
3253    async fn indexed_children_resolve_webkit_names_on_the_accessibility_bus() {
3254        let bus = PrivateBus::start("cul-atspi-bus");
3255        let address = bus.address();
3256
3257        let webkit_name = "org.webkit.MockWebProcess";
3258        let child_path = "/org/a11y/webkit/accessible/child";
3259        let webkit = zbus::connection::Builder::address(address)
3260            .unwrap()
3261            .name(webkit_name)
3262            .unwrap()
3263            .serve_at(
3264                child_path,
3265                FakeAccessible {
3266                    name: "WebKit page".into(),
3267                    children: Vec::new(),
3268                    has_action: true,
3269                },
3270            )
3271            .unwrap()
3272            .serve_at(child_path, FakeWebKitAction::default())
3273            .unwrap()
3274            .build()
3275            .await
3276            .unwrap();
3277        let owner = webkit.unique_name().unwrap().to_string();
3278        let gtk = zbus::connection::Builder::address(address)
3279            .unwrap()
3280            .serve_at(
3281                "/org/a11y/atspi/accessible/root",
3282                FakeAccessible {
3283                    name: "GTK socket".into(),
3284                    has_action: false,
3285                    children: vec![
3286                        (webkit_name.into(), child_path.try_into().unwrap()),
3287                        (owner.clone(), child_path.try_into().unwrap()),
3288                        (String::new(), "/org/a11y/atspi/null".try_into().unwrap()),
3289                        (
3290                            "org.webkit.UnownedWebProcess".into(),
3291                            child_path.try_into().unwrap(),
3292                        ),
3293                    ],
3294                },
3295            )
3296            .unwrap()
3297            .build()
3298            .await
3299            .unwrap();
3300        let client = zbus::connection::Builder::address(address)
3301            .unwrap()
3302            .build()
3303            .await
3304            .unwrap();
3305        let proxy = AccessibleProxy::builder(&client)
3306            .destination(gtk.unique_name().unwrap().clone())
3307            .unwrap()
3308            .path("/org/a11y/atspi/accessible/root")
3309            .unwrap()
3310            .build()
3311            .await
3312            .unwrap();
3313        let mut remaining = 4;
3314        let reply = proxy
3315            .inner()
3316            .call_method("GetChildAtIndex", &(0_i32,))
3317            .await
3318            .unwrap();
3319        assert_eq!(reply.body().signature().to_string(), "(so)");
3320        let batch = children_up_to(&proxy, 4, &mut remaining).await.unwrap();
3321
3322        assert_eq!(batch.attempted, 4);
3323        assert_eq!(remaining, 0);
3324        assert!(!batch.incomplete);
3325        assert_eq!(batch.items.len(), 3, "only the unowned name should fail");
3326        assert_eq!(batch.items[0].name_as_str(), Some(owner.as_str()));
3327        assert_eq!(batch.items[0].path_as_str(), child_path);
3328        assert_eq!(batch.items[0], batch.items[1]);
3329        assert!(batch.items[2].is_null());
3330
3331        // The canonical reference remains usable by the normal read/action path.
3332        let child = batch.items[0].as_accessible_proxy(&client).await.unwrap();
3333        assert_eq!(child.name().await.unwrap(), "WebKit page");
3334        assert_eq!(
3335            object_ref_from_id(&object_ref_id(&batch.items[0])).unwrap(),
3336            batch.items[0]
3337        );
3338        let action = child.proxies().await.unwrap().action().await.unwrap();
3339        let actions = timeout(Duration::from_secs(1), read_actions(&action))
3340            .await
3341            .expect("indexed action reads must not wait for GetActions")
3342            .unwrap();
3343        assert_eq!(actions.len(), 1);
3344        assert_eq!(actions[0].name, "click");
3345        assert_eq!(actions[0].description, "Activate the control");
3346        assert_eq!(actions[0].keybinding, "Return");
3347        assert_eq!(select_named_action_index(&actions, "click").unwrap(), 0);
3348        assert!(action.do_action(0).await.unwrap());
3349    }
3350
3351    #[tokio::test]
3352    async fn indexed_actions_bound_untrusted_counts_before_reading_indices() {
3353        let bus = PrivateBus::start("cul-atspi-bus");
3354        let address = bus.address();
3355        let name_reads = Arc::new(AtomicUsize::new(0));
3356        let counts = [
3357            -1,
3358            MAX_ACTIONS_PER_OBJECT + 1,
3359            i32::MAX,
3360            0,
3361            MAX_ACTIONS_PER_OBJECT,
3362        ];
3363        let mut builder = zbus::connection::Builder::address(address).unwrap();
3364        for (index, count) in counts.iter().enumerate() {
3365            builder = builder
3366                .serve_at(
3367                    format!("/actions/{index}"),
3368                    FakeWebKitAction {
3369                        count: *count,
3370                        name_reads: Arc::clone(&name_reads),
3371                        ..Default::default()
3372                    },
3373                )
3374                .unwrap();
3375        }
3376        let peer = builder.build().await.unwrap();
3377        let client = zbus::connection::Builder::address(address)
3378            .unwrap()
3379            .build()
3380            .await
3381            .unwrap();
3382
3383        for (index, count) in counts.iter().enumerate() {
3384            let action = ActionProxy::builder(&client)
3385                .destination(peer.unique_name().unwrap().clone())
3386                .unwrap()
3387                .path(format!("/actions/{index}"))
3388                .unwrap()
3389                .cache_properties(zbus::proxy::CacheProperties::No)
3390                .build()
3391                .await
3392                .unwrap();
3393            let result = timeout(Duration::from_secs(1), read_actions(&action))
3394                .await
3395                .expect("untrusted counts must not cause unbounded work");
3396            if (0..=MAX_ACTIONS_PER_OBJECT).contains(count) {
3397                assert_eq!(result.unwrap().len(), *count as usize);
3398            } else {
3399                let error = result.unwrap_err().to_string();
3400                assert!(error.contains("action count"), "{error}");
3401                assert_eq!(name_reads.load(Ordering::SeqCst), 0);
3402            }
3403        }
3404        assert_eq!(
3405            name_reads.load(Ordering::SeqCst),
3406            MAX_ACTIONS_PER_OBJECT as usize
3407        );
3408    }
3409
3410    #[tokio::test]
3411    async fn indexed_actions_time_out_the_entire_read() {
3412        let bus = PrivateBus::start("cul-atspi-bus");
3413        let address = bus.address();
3414        let name_reads = Arc::new(AtomicUsize::new(0));
3415        let peer = zbus::connection::Builder::address(address)
3416            .unwrap()
3417            .serve_at(
3418                "/actions/stalled",
3419                FakeWebKitAction {
3420                    stall_indexed_reads: true,
3421                    name_reads: Arc::clone(&name_reads),
3422                    ..Default::default()
3423                },
3424            )
3425            .unwrap()
3426            .build()
3427            .await
3428            .unwrap();
3429        let client = zbus::connection::Builder::address(address)
3430            .unwrap()
3431            .build()
3432            .await
3433            .unwrap();
3434        let action = ActionProxy::builder(&client)
3435            .destination(peer.unique_name().unwrap().clone())
3436            .unwrap()
3437            .path("/actions/stalled")
3438            .unwrap()
3439            .cache_properties(zbus::proxy::CacheProperties::No)
3440            .build()
3441            .await
3442            .unwrap();
3443
3444        let error = timeout(
3445            ACTION_QUERY_TIMEOUT + Duration::from_secs(1),
3446            read_actions(&action),
3447        )
3448        .await
3449        .expect("indexed reads must have an overall deadline")
3450        .unwrap_err();
3451        assert!(error.to_string().contains("5-second deadline"));
3452        assert_eq!(name_reads.load(Ordering::SeqCst), 1);
3453    }
3454
3455    fn app_children(children: &[(&str, &str)]) -> Vec<(String, Option<String>)> {
3456        children
3457            .iter()
3458            .map(|(role, name)| (role.to_string(), Some(name.to_string())))
3459            .collect()
3460    }
3461
3462    #[test]
3463    fn a_window_title_picks_one_of_several_app_windows() {
3464        let chromium = app_children(&[
3465            ("frame", "GitHub - Chromium"),
3466            ("frame", "PlanetScale - Chromium"),
3467            ("menu", ""),
3468        ]);
3469        assert_eq!(
3470            matching_window(&chromium, "PlanetScale - Chromium"),
3471            Some(1)
3472        );
3473        assert_eq!(
3474            matching_window(&chromium, "planetscale - chromium"),
3475            Some(1)
3476        );
3477        // A title substring, as a caller passes it.
3478        assert_eq!(matching_window(&chromium, "PlanetScale"), Some(1));
3479        // Matches every window: keep them all.
3480        assert_eq!(matching_window(&chromium, "Chromium"), None);
3481        assert_eq!(matching_window(&chromium, "Gmail"), None);
3482    }
3483
3484    #[test]
3485    fn a_decorated_compositor_title_picks_the_frame_it_decorates() {
3486        let chromium = app_children(&[
3487            ("frame", "GitHub - Chromium"),
3488            ("frame", "PlanetScale - Chromium"),
3489        ]);
3490        assert_eq!(
3491            matching_window(&chromium, "PlanetScale - Chromium (Profile 1)"),
3492            Some(1)
3493        );
3494        // Both names are decorated prefixes: the longest wins.
3495        let nested = app_children(&[("frame", "Docs"), ("frame", "Docs - Settings")]);
3496        assert_eq!(
3497            matching_window(&nested, "Docs - Settings (Profile 1)"),
3498            Some(1)
3499        );
3500        // A name inside the title but not its prefix is not a decoration.
3501        let generic = app_children(&[("frame", "Settings"), ("frame", "Mail")]);
3502        assert_eq!(
3503            matching_window(&generic, "Docs - Settings (Profile 1)"),
3504            None
3505        );
3506        assert_eq!(matching_window(&generic, "Mailbox"), None);
3507    }
3508
3509    #[test]
3510    fn an_exact_window_name_wins_over_a_substring_match() {
3511        let windows = app_children(&[("frame", "Docs"), ("frame", "Docs - Settings")]);
3512        assert_eq!(matching_window(&windows, "Docs"), Some(0));
3513    }
3514
3515    #[test]
3516    fn narrowing_keeps_popups_and_dialogs_beside_the_window() {
3517        let chromium = app_children(&[
3518            ("frame", "GitHub - Chromium"),
3519            ("frame", "PlanetScale - Chromium"),
3520            ("window", ""),
3521            ("dialog", "Open File"),
3522            ("menu", ""),
3523        ]);
3524        let chosen = matching_window(&chromium, "PlanetScale").unwrap();
3525        assert_eq!(
3526            kept_beside_window(&chromium, chosen),
3527            [false, true, true, true, true]
3528        );
3529    }
3530
3531    #[test]
3532    fn one_window_is_never_narrowed() {
3533        let single = app_children(&[("frame", "PlanetScale - Chromium"), ("menu", "")]);
3534        assert_eq!(matching_window(&single, "PlanetScale"), None);
3535    }
3536
3537    #[test]
3538    fn split_object_ref_id_separates_bus_name_and_path() {
3539        let (name, path) = split_object_ref_id(":1.42/org/a11y/atspi/accessible/7").unwrap();
3540
3541        assert_eq!(name, ":1.42");
3542        assert_eq!(path, "/org/a11y/atspi/accessible/7");
3543    }
3544
3545    #[test]
3546    fn select_action_index_uses_named_action() {
3547        let actions = vec![
3548            atspi::Action {
3549                name: "click".to_string(),
3550                description: "Clicks".to_string(),
3551                keybinding: String::new(),
3552            },
3553            atspi::Action {
3554                name: "show-menu".to_string(),
3555                description: "Shows menu".to_string(),
3556                keybinding: String::new(),
3557            },
3558        ];
3559
3560        assert_eq!(select_action_index(&actions, Some("show-menu")).unwrap(), 1);
3561    }
3562
3563    #[test]
3564    fn cached_action_name_is_revalidated_against_current_actions() {
3565        let mut actions = vec![atspi::Action {
3566            name: "show-menu".into(),
3567            description: "Click".into(),
3568            keybinding: String::new(),
3569        }];
3570        // Neither a stale index nor a matching description may invoke another action.
3571        assert!(select_named_action_index(&actions, "Click").is_err());
3572        actions.push(atspi::Action {
3573            name: "click".into(),
3574            description: String::new(),
3575            keybinding: String::new(),
3576        });
3577        assert_eq!(select_named_action_index(&actions, "Click").unwrap(), 1);
3578    }
3579
3580    #[test]
3581    fn select_action_index_defaults_to_secondary_when_available() {
3582        let actions = vec![
3583            atspi::Action {
3584                name: "click".to_string(),
3585                description: String::new(),
3586                keybinding: String::new(),
3587            },
3588            atspi::Action {
3589                name: "show-menu".to_string(),
3590                description: String::new(),
3591                keybinding: String::new(),
3592            },
3593        ];
3594
3595        assert_eq!(select_action_index(&actions, None).unwrap(), 1);
3596    }
3597
3598    #[test]
3599    fn state_labels_serialize_in_bit_order() {
3600        let labels = state_labels(StateSet::new(atspi::State::Focused | atspi::State::Checked));
3601
3602        assert_eq!(labels, vec!["checked".to_string(), "focused".to_string()]);
3603    }
3604
3605    #[test]
3606    fn default_snapshot_limits_cover_deep_gtk4_trees() {
3607        // Nautilus 50 places file-list cells below depth 20 and can expose
3608        // more than 850 raw nodes. Keep the defaults above that known shape.
3609        assert_eq!(snapshot_limits(None, None), (1_000, 32));
3610    }
3611
3612    #[test]
3613    fn app_name_needle_ignores_blank_values_and_normalizes_the_rest() {
3614        assert_eq!(app_name_needle(None), None);
3615        assert_eq!(app_name_needle(Some("")), None);
3616        assert_eq!(app_name_needle(Some("   ")), None);
3617        assert_eq!(
3618            app_name_needle(Some("Calculator")).as_deref(),
3619            Some("calculator")
3620        );
3621        assert_eq!(
3622            app_name_needle(Some(" :1.64/org/a11y/atspi/accessible/root ")).as_deref(),
3623            Some(":1.64/org/a11y/atspi/accessible/root")
3624        );
3625    }
3626
3627    #[test]
3628    fn requested_snapshot_limits_remain_bounded() {
3629        assert_eq!(snapshot_limits(Some(0), Some(0)), (1, 0));
3630        assert_eq!(snapshot_limits(Some(100_000), Some(128)), (10_000, 64));
3631    }
3632
3633    #[test]
3634    fn app_discovery_budget_is_independent_of_requested_tree_size() {
3635        assert_eq!(snapshot_child_read_budgets(1), (256, 2_048, 1));
3636    }
3637
3638    #[test]
3639    fn traversal_attempts_and_queue_share_one_work_budget() {
3640        let mut traversal = BoundedTraversal::new(4);
3641        traversal.enqueue([1]);
3642
3643        assert_eq!(traversal.pop(), Some(1));
3644        traversal.enqueue(2..=10_000);
3645        assert_eq!(traversal.queue, VecDeque::from([2, 3, 4]));
3646
3647        assert_eq!(traversal.pop(), Some(2));
3648        traversal.enqueue(5..=10_000);
3649        assert_eq!(traversal.queue, VecDeque::from([3, 4]));
3650        assert_eq!(traversal.pop(), Some(3));
3651        assert_eq!(traversal.pop(), Some(4));
3652        assert_eq!(traversal.pop(), None);
3653        assert_eq!(traversal.attempted, 4);
3654        assert!(
3655            traversal.truncated(),
3656            "dropped enqueue items must be reported"
3657        );
3658    }
3659
3660    #[test]
3661    fn traversal_that_drains_within_budget_is_not_truncated() {
3662        let mut traversal = BoundedTraversal::new(3);
3663        traversal.enqueue([1, 2, 3]);
3664        assert_eq!(traversal.pop(), Some(1));
3665        assert_eq!(traversal.pop(), Some(2));
3666        assert_eq!(traversal.pop(), Some(3));
3667        assert_eq!(traversal.pop(), None);
3668        assert!(
3669            !traversal.truncated(),
3670            "exactly max_items real nodes is complete"
3671        );
3672    }
3673
3674    #[test]
3675    fn enqueue_at_zero_capacity_marks_the_traversal_truncated() {
3676        let mut traversal = BoundedTraversal::new(2);
3677        traversal.enqueue([1, 2]);
3678        assert_eq!(traversal.pop(), Some(1));
3679        assert_eq!(traversal.pop(), Some(2));
3680        assert!(!traversal.truncated(), "nothing dropped yet");
3681        // Capacity is zero; the offered item is dropped, which is the only
3682        // path that can mark truncation.
3683        traversal.enqueue([3]);
3684        assert!(traversal.queue.is_empty());
3685        assert_eq!(traversal.pop(), None);
3686        assert!(traversal.truncated());
3687    }
3688
3689    #[test]
3690    fn child_count_is_clamped_before_indexed_reads() {
3691        assert_eq!(bounded_child_count(-1, 4), 0);
3692        assert_eq!(bounded_child_count(3, 4), 3);
3693        assert_eq!(bounded_child_count(i32::MAX, 4), 4);
3694    }
3695
3696    #[tokio::test]
3697    async fn indexed_child_reads_consume_attempts_even_when_one_fails() {
3698        let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3699        let mut remaining_attempts = 3;
3700        let batch = fetch_indexed_up_to(100, 10, &mut remaining_attempts, {
3701            let calls = calls.clone();
3702            move |index| {
3703                let calls = calls.clone();
3704                async move {
3705                    calls.lock().unwrap().push(index);
3706                    if index == 1 {
3707                        Err(())
3708                    } else {
3709                        Ok(index)
3710                    }
3711                }
3712            }
3713        })
3714        .await;
3715
3716        assert_eq!(batch.items, vec![0, 2]);
3717        assert_eq!(batch.attempted, 3);
3718        assert!(
3719            batch.incomplete,
3720            "100 reported children, 3 attempted: budget cut must be reported"
3721        );
3722        assert!(!batch.all_failed());
3723        assert_eq!(*calls.lock().unwrap(), vec![0, 1, 2]);
3724        assert_eq!(remaining_attempts, 0);
3725
3726        let no_children = fetch_indexed_up_to(100, 10, &mut remaining_attempts, |_| async {
3727            Ok::<_, ()>(99)
3728        })
3729        .await;
3730        assert!(no_children.items.is_empty());
3731        assert_eq!(no_children.attempted, 0);
3732        assert!(!no_children.all_failed());
3733
3734        let mut failed_attempts = 2;
3735        let all_failed =
3736            fetch_indexed_up_to(2, 2, &mut failed_attempts, |_| async { Err::<i32, _>(()) }).await;
3737        assert!(all_failed.all_failed());
3738    }
3739
3740    fn document() -> FocusedElementSummary {
3741        FocusedElementSummary {
3742            role: "document web".into(),
3743            name: Some("Synthetic editor host".into()),
3744            editable: false,
3745            states: vec!["focused".into()],
3746            is_terminal: false,
3747        }
3748    }
3749
3750    #[test]
3751    fn focused_document_does_not_end_the_search_before_contenteditable() {
3752        assert!(focus_is_container(&document()));
3753        let mut entry = document();
3754        entry.role = "entry".into();
3755        entry.states.push("editable".into());
3756        // Chromium need not implement EditableText for keyboard input.
3757        assert!(!entry.editable);
3758        assert!(!focus_is_container(&entry));
3759    }
3760
3761    #[test]
3762    fn incomplete_descendants_do_not_make_the_document_a_definitive_recipient() {
3763        assert!(matches!(
3764            finish_focus_probe(true, false, Some(document())),
3765            FocusProbe::Incomplete
3766        ));
3767        assert!(matches!(
3768            finish_focus_probe(false, false, Some(document())),
3769            FocusProbe::Found(_)
3770        ));
3771    }
3772
3773    #[test]
3774    fn opt_in_snapshot_budget_reaches_controls_after_embedded_browser_content() {
3775        let (limit, _) = snapshot_limits(Some(4_000), None);
3776        assert_eq!(limit, 4_000);
3777        let mut traversal = BoundedTraversal::new(limit);
3778        traversal.enqueue(0..3_000);
3779        let mut reached_editor = false;
3780        while let Some(node) = traversal.pop() {
3781            reached_editor |= node == 2_500;
3782        }
3783        assert!(reached_editor);
3784        assert!(!traversal.truncated());
3785        let (default, _) = snapshot_limits(None, None);
3786        assert_eq!(default, 1_000);
3787    }
3788
3789    #[test]
3790    fn narrowing_keeps_the_spent_budget_and_forgets_dropped_work() {
3791        let mut traversal = BoundedTraversal::new(4);
3792        traversal.enqueue(0..6);
3793        assert!(traversal.truncated());
3794        assert_eq!(traversal.pop(), Some(0));
3795        traversal.narrow();
3796        assert!(
3797            !traversal.truncated(),
3798            "work dropped outside the subtree no longer counts"
3799        );
3800        assert_eq!(traversal.pop(), None);
3801        traversal.enqueue([10, 11, 12, 13]);
3802        assert_eq!(traversal.pop(), Some(10));
3803        assert_eq!(traversal.pop(), Some(11));
3804        assert_eq!(traversal.pop(), Some(12));
3805        assert_eq!(
3806            traversal.pop(),
3807            None,
3808            "the budget spent before narrowing still counts"
3809        );
3810        assert!(traversal.truncated());
3811    }
3812
3813    #[tokio::test]
3814    async fn text_snapshot_reads_selections_through_get_n_selections() {
3815        let running = start_field_fixture(FieldFixture {
3816            contents: "/tmp/old",
3817            caret: 8,
3818            selection: Some((5, 8)),
3819            ..Default::default()
3820        })
3821        .await;
3822        let proxy = open_accessible(&running.conn, &running.fields[0].0)
3823            .await
3824            .unwrap();
3825        let proxies = proxy.proxies().await.unwrap();
3826        let text = text_from_proxies(Some(&proxies)).await.unwrap();
3827        assert_eq!(text.content.as_deref(), Some("/tmp/old"));
3828        assert_eq!(text.selections.len(), 1);
3829        assert_eq!(
3830            (
3831                text.selections[0].start_offset,
3832                text.selections[0].end_offset
3833            ),
3834            (5, 8)
3835        );
3836    }
3837
3838    fn verified(replaced_selection: bool) -> FocusedTextInsert {
3839        FocusedTextInsert::Verified {
3840            role: "text".to_string(),
3841            replaced_selection,
3842            caret_after_text: true,
3843        }
3844    }
3845
3846    #[tokio::test]
3847    async fn focused_field_insert_lands_at_the_caret_and_reads_back() {
3848        let (outcome, state) = insert_into_fixture(
3849            FieldFixture {
3850                contents: "/home/",
3851                caret: 6,
3852                ..Default::default()
3853            },
3854            "x/doc",
3855        )
3856        .await;
3857        assert_eq!(outcome.unwrap(), verified(false));
3858        assert_eq!(state.contents, "/home/x/doc");
3859        assert_eq!(state.calls, ["insert", "caret"]);
3860        assert_eq!(state.caret, 11, "the caret ends after the text");
3861
3862        let (outcome, state) = insert_into_fixture(
3863            FieldFixture {
3864                contents: "ab",
3865                caret: 1,
3866                ..Default::default()
3867            },
3868            "שלום",
3869        )
3870        .await;
3871        assert_eq!(outcome.unwrap(), verified(false));
3872        assert_eq!(state.contents, "aשלוםb");
3873        assert_eq!(state.caret, 5);
3874        assert_eq!(state.calls, ["insert", "caret"]);
3875
3876        // A partial edit never rewrites the field, so embedded objects stay.
3877        let (outcome, state) = insert_into_fixture(
3878            FieldFixture {
3879                contents: "\u{fffc}\u{fffc}",
3880                caret: 1,
3881                ..Default::default()
3882            },
3883            "שלום",
3884        )
3885        .await;
3886        assert_eq!(outcome.unwrap(), verified(false));
3887        assert_eq!(state.contents, "\u{fffc}שלום\u{fffc}");
3888        assert_eq!(state.calls, ["insert", "caret"]);
3889    }
3890
3891    #[tokio::test]
3892    async fn focused_field_insert_is_exact_under_qt_length_rules() {
3893        for (text, selection, expected) in [
3894            ("x/doc", None, "/home/x/doc"),
3895            ("שלום", None, "/home/שלום"),
3896            ("שלום", Some((1, 5)), "/שלום/"),
3897        ] {
3898            let (outcome, state) = insert_into_fixture(
3899                FieldFixture {
3900                    contents: "/home/",
3901                    caret: 6,
3902                    selection,
3903                    edit: FieldEdit::QtLength,
3904                    toolkit: Some("Qt"),
3905                    ..Default::default()
3906                },
3907                text,
3908            )
3909            .await;
3910            assert_eq!(outcome.unwrap(), verified(selection.is_some()), "{text}");
3911            assert_eq!(state.contents, expected);
3912        }
3913    }
3914
3915    #[tokio::test]
3916    async fn focused_field_insert_needs_the_toolkit_for_non_ascii_text() {
3917        let (outcome, state) = insert_into_fixture(
3918            FieldFixture {
3919                contents: "/home/",
3920                caret: 6,
3921                toolkit: None,
3922                ..Default::default()
3923            },
3924            "שלום",
3925        )
3926        .await;
3927        assert!(
3928            matches!(&outcome, Ok(FocusedTextInsert::NotEditable(reason)) if reason.contains("toolkit")),
3929            "{outcome:?}"
3930        );
3931        assert_eq!(state.contents, "/home/");
3932        assert!(state.calls.is_empty(), "{:?}", state.calls);
3933
3934        // ASCII text has one length in every unit.
3935        let (outcome, state) = insert_into_fixture(
3936            FieldFixture {
3937                contents: "/home/",
3938                caret: 6,
3939                toolkit: None,
3940                ..Default::default()
3941            },
3942            "x",
3943        )
3944        .await;
3945        assert_eq!(outcome.unwrap(), verified(false));
3946        assert_eq!(state.contents, "/home/x");
3947    }
3948
3949    #[tokio::test]
3950    async fn focused_field_insert_replaces_the_selection() {
3951        let (outcome, state) = insert_into_fixture(
3952            FieldFixture {
3953                contents: "/tmp/old/x",
3954                caret: 8,
3955                selection: Some((8, 5)),
3956                ..Default::default()
3957            },
3958            "new",
3959        )
3960        .await;
3961        assert_eq!(outcome.unwrap(), verified(true));
3962        assert_eq!(state.contents, "/tmp/new/x");
3963        assert_eq!(state.calls, ["delete", "insert", "caret"]);
3964
3965        // An empty or fully selected field gets its contents set, as set_value does.
3966        for (contents, selection) in [("", None), ("/old/path", Some((0, 9)))] {
3967            let (outcome, state) = insert_into_fixture(
3968                FieldFixture {
3969                    contents,
3970                    caret: contents.chars().count() as i32,
3971                    selection,
3972                    ..Default::default()
3973                },
3974                "/home/x",
3975            )
3976            .await;
3977            assert_eq!(outcome.unwrap(), verified(selection.is_some()));
3978            assert_eq!(state.contents, "/home/x");
3979            assert_eq!(state.calls, ["set", "caret"]);
3980            assert_eq!(state.caret, 7);
3981        }
3982    }
3983
3984    #[tokio::test]
3985    async fn focused_field_insert_needs_a_focused_editable_field() {
3986        let unfocused = StateSet::new(atspi::State::Editable | atspi::State::Showing);
3987        let read_only = StateSet::new(atspi::State::Focused | atspi::State::Showing);
3988        for states in [unfocused, read_only] {
3989            let (outcome, state) = insert_into_fixture(
3990                FieldFixture {
3991                    contents: "keep",
3992                    caret: 4,
3993                    states,
3994                    ..Default::default()
3995                },
3996                "x",
3997            )
3998            .await;
3999            assert!(
4000                matches!(outcome.unwrap(), FocusedTextInsert::NotEditable(_)),
4001                "{states:?}"
4002            );
4003            assert_eq!(state.contents, "keep");
4004            assert!(state.calls.is_empty(), "{:?}", state.calls);
4005        }
4006    }
4007
4008    fn entry(contents: &'static str, focused: bool) -> FieldFixture {
4009        let mut states = StateSet::new(atspi::State::Editable | atspi::State::Showing);
4010        if focused {
4011            states.insert(atspi::State::Focused);
4012        }
4013        FieldFixture {
4014            contents,
4015            caret: contents.chars().count() as i32,
4016            states,
4017            ..Default::default()
4018        }
4019    }
4020
4021    /// Chromium puts every window under one app root, and AT-SPI focus can
4022    /// lag behind the compositor: a field still marked focused in another
4023    /// window is not the target.
4024    #[tokio::test]
4025    async fn focused_field_insert_stays_in_the_target_window() {
4026        let stale_and_target = |target_focused, target_active| {
4027            vec![
4028                FixtureWindow {
4029                    name: "Inbox",
4030                    active: false,
4031                    field: entry("stale", true),
4032                },
4033                FixtureWindow {
4034                    name: "Compose",
4035                    active: target_active,
4036                    field: entry("to: ", target_focused),
4037                },
4038            ]
4039        };
4040        let running = start_windows_fixture(stale_and_target(true, true)).await;
4041        let outcome =
4042            insert_text_at_focus_on(&running.conn, std::process::id(), Some("Compose"), "x").await;
4043        assert_eq!(outcome.unwrap(), verified(false));
4044        assert_eq!(running.take_state(0).contents, "stale");
4045        assert_eq!(running.take_state(1).contents, "to: x");
4046
4047        for (target_focused, target_active, title, why) in [
4048            (false, true, Some("Compose"), "has keyboard focus"),
4049            (true, false, Some("Compose"), "not active"),
4050            (true, true, Some("Drafts"), "could not be matched"),
4051            (true, true, None, "could not be matched"),
4052        ] {
4053            let running =
4054                start_windows_fixture(stale_and_target(target_focused, target_active)).await;
4055            let outcome =
4056                insert_text_at_focus_on(&running.conn, std::process::id(), title, "x").await;
4057            assert!(
4058                matches!(&outcome, Ok(FocusedTextInsert::NotEditable(reason)) if reason.contains(why)),
4059                "{title:?} focused={target_focused} active={target_active}: {outcome:?}"
4060            );
4061            for index in 0..2 {
4062                let state = running.take_state(index);
4063                assert!(state.calls.is_empty(), "{:?}", state.calls);
4064            }
4065        }
4066    }
4067
4068    #[tokio::test]
4069    async fn focused_field_insert_checks_the_untouched_text() {
4070        // Inserting "y" at 1 gives "xyyx"; appending gives "xyxy", which has
4071        // "y" at 1 and the right length too.
4072        let (outcome, state) = insert_into_fixture(
4073            FieldFixture {
4074                contents: "xyx",
4075                caret: 1,
4076                edit: FieldEdit::AppendAtEnd,
4077                ..Default::default()
4078            },
4079            "y",
4080        )
4081        .await;
4082        assert!(
4083            matches!(outcome, Ok(FocusedTextInsert::Mismatch { .. })),
4084            "{outcome:?}"
4085        );
4086        assert_eq!(state.contents, "xyxy");
4087    }
4088
4089    #[tokio::test]
4090    async fn focused_field_insert_bounds_each_edit_call() {
4091        let (outcome, state) = timeout(
4092            ACTION_QUERY_TIMEOUT + Duration::from_secs(3),
4093            insert_into_fixture(
4094                FieldFixture {
4095                    contents: "/home/",
4096                    caret: 6,
4097                    edit: FieldEdit::StallInsert,
4098                    ..Default::default()
4099                },
4100                "x",
4101            ),
4102        )
4103        .await
4104        .expect("a stalled InsertText must not hold the call");
4105        assert!(
4106            matches!(
4107                &outcome,
4108                Ok(FocusedTextInsert::EditFailed { reason, may_have_changed: true })
4109                    if reason.contains("did not answer")
4110            ),
4111            "{outcome:?}"
4112        );
4113        assert_eq!(state.contents, "/home/");
4114    }
4115
4116    #[tokio::test]
4117    async fn focused_field_insert_skips_password_fields() {
4118        let (outcome, state) = insert_into_fixture(
4119            FieldFixture {
4120                role: atspi::Role::PasswordText,
4121                role_name: "password text",
4122                ..Default::default()
4123            },
4124            "hunter2",
4125        )
4126        .await;
4127        assert_eq!(outcome.unwrap(), FocusedTextInsert::PasswordField);
4128        assert_eq!(state.contents, "");
4129        assert!(state.calls.is_empty(), "{:?}", state.calls);
4130    }
4131
4132    #[tokio::test]
4133    async fn focused_field_insert_reports_a_readback_mismatch() {
4134        let (outcome, state) = insert_into_fixture(
4135            FieldFixture {
4136                contents: "/home/",
4137                caret: 6,
4138                edit: FieldEdit::DropLastChar,
4139                ..Default::default()
4140            },
4141            "xyz",
4142        )
4143        .await;
4144        assert_eq!(
4145            outcome.unwrap(),
4146            FocusedTextInsert::Mismatch {
4147                expected_count: 9,
4148                read_count: 8,
4149                read_text: "xy".to_string(),
4150                read_truncated: false,
4151            }
4152        );
4153        assert_eq!(state.contents, "/home/xy");
4154        assert!(!state.calls.contains(&"caret"), "{:?}", state.calls);
4155    }
4156}
4157
4158/// Private-bus AT-SPI fixture for the type_text insert route: a registry, one
4159/// app owned by this process, its windows, and one text field in each.
4160#[cfg(test)]
4161pub(crate) mod insert_fixture {
4162    use super::*;
4163    use crate::test_sockets::PrivateBus;
4164    use std::sync::{Arc, Mutex};
4165
4166    const FOCUS_ROOT_PATH: &str = "/org/a11y/atspi/accessible/root";
4167
4168    #[derive(Default)]
4169    pub(crate) struct FieldState {
4170        pub(crate) contents: String,
4171        pub(crate) caret: i32,
4172        pub(crate) selection: Option<(i32, i32)>,
4173        pub(crate) calls: Vec<&'static str>,
4174    }
4175
4176    #[derive(Clone, Copy, PartialEq)]
4177    pub(crate) enum FieldEdit {
4178        Apply,
4179        /// Qt's bridge: InsertText resizes the text to `length` UTF-16 code
4180        /// units, padding with NUL.
4181        QtLength,
4182        /// The app keeps all but the last inserted character.
4183        DropLastChar,
4184        /// The app appends inserted text at the end, ignoring the position.
4185        AppendAtEnd,
4186        /// InsertText never answers.
4187        StallInsert,
4188    }
4189
4190    struct FocusNode {
4191        name: &'static str,
4192        role: atspi::Role,
4193        role_name: &'static str,
4194        states: StateSet,
4195        children: Vec<(String, OwnedObjectPath)>,
4196        text_field: bool,
4197    }
4198
4199    #[zbus::interface(name = "org.a11y.atspi.Accessible")]
4200    impl FocusNode {
4201        #[zbus(property)]
4202        fn child_count(&self) -> i32 {
4203            self.children.len() as i32
4204        }
4205
4206        #[zbus(property)]
4207        fn name(&self) -> &str {
4208            self.name
4209        }
4210
4211        fn get_role(&self) -> atspi::Role {
4212            self.role
4213        }
4214
4215        fn get_role_name(&self) -> &str {
4216            self.role_name
4217        }
4218
4219        fn get_state(&self) -> StateSet {
4220            self.states
4221        }
4222
4223        fn get_interfaces(&self) -> Vec<&str> {
4224            let mut interfaces = vec!["org.a11y.atspi.Accessible"];
4225            if self.text_field {
4226                interfaces.extend(["org.a11y.atspi.EditableText", "org.a11y.atspi.Text"]);
4227            }
4228            interfaces
4229        }
4230
4231        fn get_child_at_index(
4232            &self,
4233            index: i32,
4234        ) -> zbus::fdo::Result<((String, OwnedObjectPath),)> {
4235            self.children
4236                .get(index as usize)
4237                .cloned()
4238                .map(|child| (child,))
4239                .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid child index".into()))
4240        }
4241    }
4242
4243    fn char_byte(contents: &str, offset: i32) -> zbus::fdo::Result<usize> {
4244        if offset < 0 {
4245            return Err(zbus::fdo::Error::InvalidArgs("invalid text offset".into()));
4246        }
4247        contents
4248            .char_indices()
4249            .map(|(byte_index, _)| byte_index)
4250            .chain(std::iter::once(contents.len()))
4251            .nth(offset as usize)
4252            .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid text offset".into()))
4253    }
4254
4255    struct FieldText(Arc<Mutex<FieldState>>);
4256
4257    #[zbus::interface(name = "org.a11y.atspi.Text")]
4258    impl FieldText {
4259        #[zbus(property)]
4260        fn character_count(&self) -> i32 {
4261            self.0.lock().unwrap().contents.chars().count() as i32
4262        }
4263
4264        #[zbus(property)]
4265        fn caret_offset(&self) -> i32 {
4266            self.0.lock().unwrap().caret
4267        }
4268
4269        #[zbus(name = "GetNSelections")]
4270        fn get_n_selections(&self) -> i32 {
4271            i32::from(self.0.lock().unwrap().selection.is_some())
4272        }
4273
4274        fn get_selection(&self, index: i32) -> zbus::fdo::Result<(i32, i32)> {
4275            let state = self.0.lock().unwrap();
4276            state
4277                .selection
4278                .filter(|_| index == 0)
4279                .ok_or_else(|| zbus::fdo::Error::InvalidArgs("no such selection".into()))
4280        }
4281
4282        fn get_text(&self, start_offset: i32, end_offset: i32) -> zbus::fdo::Result<String> {
4283            let state = self.0.lock().unwrap();
4284            let start = char_byte(&state.contents, start_offset)?;
4285            let end = char_byte(&state.contents, end_offset)?;
4286            if end < start {
4287                return Err(zbus::fdo::Error::InvalidArgs("invalid text offsets".into()));
4288            }
4289            Ok(state.contents[start..end].to_string())
4290        }
4291
4292        fn set_caret_offset(&self, offset: i32) -> bool {
4293            let mut state = self.0.lock().unwrap();
4294            state.calls.push("caret");
4295            state.caret = offset;
4296            true
4297        }
4298    }
4299
4300    struct FixtureApplication(&'static str);
4301
4302    #[zbus::interface(name = "org.a11y.atspi.Application")]
4303    impl FixtureApplication {
4304        #[zbus(property)]
4305        fn toolkit_name(&self) -> &str {
4306            self.0
4307        }
4308    }
4309
4310    struct FieldEditable {
4311        state: Arc<Mutex<FieldState>>,
4312        edit: FieldEdit,
4313    }
4314
4315    impl FieldEditable {
4316        fn apply(&self, text: &str) -> String {
4317            let mut text = text.to_string();
4318            if self.edit == FieldEdit::DropLastChar {
4319                text.pop();
4320            }
4321            text
4322        }
4323    }
4324
4325    #[zbus::interface(name = "org.a11y.atspi.EditableText")]
4326    impl FieldEditable {
4327        async fn insert_text(
4328            &self,
4329            position: i32,
4330            text: &str,
4331            length: i32,
4332        ) -> zbus::fdo::Result<bool> {
4333            if self.edit == FieldEdit::StallInsert {
4334                std::future::pending::<()>().await;
4335            }
4336            let text = if self.edit == FieldEdit::QtLength {
4337                let mut units = text.encode_utf16().collect::<Vec<_>>();
4338                units.resize(length.max(0) as usize, 0);
4339                String::from_utf16_lossy(&units)
4340            } else {
4341                // Bytes, as the AT-SPI documentation and GTK count them.
4342                let take = (length.max(0) as usize).min(text.len());
4343                self.apply(text.get(..take).unwrap_or(text))
4344            };
4345            let mut state = self.state.lock().unwrap();
4346            state.calls.push("insert");
4347            let at = if self.edit == FieldEdit::AppendAtEnd {
4348                state.contents.len()
4349            } else {
4350                char_byte(&state.contents, position)?
4351            };
4352            state.contents.insert_str(at, &text);
4353            // Like a GTK entry, an insert does not move the caret.
4354            state.selection = None;
4355            Ok(true)
4356        }
4357
4358        fn delete_text(&self, start_pos: i32, end_pos: i32) -> zbus::fdo::Result<bool> {
4359            let mut state = self.state.lock().unwrap();
4360            state.calls.push("delete");
4361            let start = char_byte(&state.contents, start_pos)?;
4362            let end = char_byte(&state.contents, end_pos)?;
4363            state.contents.replace_range(start..end, "");
4364            state.caret = start_pos;
4365            state.selection = None;
4366            Ok(true)
4367        }
4368
4369        fn set_text_contents(&self, new_contents: &str) -> bool {
4370            let text = self.apply(new_contents);
4371            let mut state = self.state.lock().unwrap();
4372            state.calls.push("set");
4373            state.contents = text;
4374            state.caret = 0;
4375            state.selection = None;
4376            true
4377        }
4378    }
4379
4380    pub(crate) struct FieldFixture {
4381        pub(crate) role: atspi::Role,
4382        pub(crate) role_name: &'static str,
4383        pub(crate) states: StateSet,
4384        pub(crate) contents: &'static str,
4385        pub(crate) caret: i32,
4386        pub(crate) selection: Option<(i32, i32)>,
4387        pub(crate) edit: FieldEdit,
4388        /// The app root's ToolkitName; `None` serves no Application interface.
4389        /// The first window's field sets it.
4390        pub(crate) toolkit: Option<&'static str>,
4391    }
4392
4393    impl Default for FieldFixture {
4394        fn default() -> Self {
4395            Self {
4396                role: atspi::Role::Text,
4397                role_name: "text",
4398                states: StateSet::new(
4399                    atspi::State::Focused
4400                        | atspi::State::Focusable
4401                        | atspi::State::Editable
4402                        | atspi::State::Showing,
4403                ),
4404                contents: "",
4405                caret: 0,
4406                selection: None,
4407                edit: FieldEdit::Apply,
4408                toolkit: Some("GTK"),
4409            }
4410        }
4411    }
4412
4413    pub(crate) struct FixtureWindow {
4414        pub(crate) name: &'static str,
4415        pub(crate) active: bool,
4416        pub(crate) field: FieldFixture,
4417    }
4418
4419    pub(crate) struct RunningFields {
4420        // Dropped last: the bus outlives the peers.
4421        _peers: [zbus::Connection; 2],
4422        pub(crate) conn: AccessibilityConnection,
4423        pub(crate) fields: Vec<(ObjectRefOwned, Arc<Mutex<FieldState>>)>,
4424        _bus: PrivateBus,
4425    }
4426
4427    impl RunningFields {
4428        pub(crate) fn take_state(&self, index: usize) -> FieldState {
4429            std::mem::take(&mut *self.fields[index].1.lock().unwrap())
4430        }
4431    }
4432
4433    pub(crate) const FIXTURE_WINDOW: &str = "Files";
4434
4435    pub(crate) async fn start_windows_fixture(windows: Vec<FixtureWindow>) -> RunningFields {
4436        let bus = PrivateBus::start("cul-atspi-insert");
4437        let app = zbus::connection::Builder::address(bus.address())
4438            .unwrap()
4439            .build()
4440            .await
4441            .unwrap();
4442        let app_name = app.unique_name().unwrap().to_string();
4443        let toolkit = windows.first().and_then(|window| window.field.toolkit);
4444        let path = |kind: &str, index: usize| -> OwnedObjectPath {
4445            format!("/{kind}{index}").try_into().unwrap()
4446        };
4447        app.object_server()
4448            .at(
4449                FOCUS_ROOT_PATH,
4450                FocusNode {
4451                    name: "fixture-app",
4452                    role: atspi::Role::Application,
4453                    role_name: "application",
4454                    states: StateSet::empty(),
4455                    children: (0..windows.len())
4456                        .map(|index| (app_name.clone(), path("frame", index)))
4457                        .collect(),
4458                    text_field: false,
4459                },
4460            )
4461            .await
4462            .unwrap();
4463        if let Some(toolkit) = toolkit {
4464            app.object_server()
4465                .at(FOCUS_ROOT_PATH, FixtureApplication(toolkit))
4466                .await
4467                .unwrap();
4468        }
4469        let mut fields = Vec::new();
4470        for (index, window) in windows.into_iter().enumerate() {
4471            let mut frame_states = StateSet::new(atspi::State::Showing);
4472            if window.active {
4473                frame_states.insert(atspi::State::Active);
4474            }
4475            app.object_server()
4476                .at(
4477                    path("frame", index),
4478                    FocusNode {
4479                        name: window.name,
4480                        role: atspi::Role::Frame,
4481                        role_name: "frame",
4482                        states: frame_states,
4483                        children: vec![(app_name.clone(), path("field", index))],
4484                        text_field: false,
4485                    },
4486                )
4487                .await
4488                .unwrap();
4489            let fixture = window.field;
4490            let state = Arc::new(Mutex::new(FieldState {
4491                contents: fixture.contents.to_string(),
4492                caret: fixture.caret,
4493                selection: fixture.selection,
4494                calls: Vec::new(),
4495            }));
4496            let field_path = path("field", index);
4497            app.object_server()
4498                .at(
4499                    field_path.clone(),
4500                    FocusNode {
4501                        name: "Location",
4502                        role: fixture.role,
4503                        role_name: fixture.role_name,
4504                        states: fixture.states,
4505                        children: Vec::new(),
4506                        text_field: true,
4507                    },
4508                )
4509                .await
4510                .unwrap();
4511            app.object_server()
4512                .at(field_path.clone(), FieldText(Arc::clone(&state)))
4513                .await
4514                .unwrap();
4515            app.object_server()
4516                .at(
4517                    field_path.clone(),
4518                    FieldEditable {
4519                        state: Arc::clone(&state),
4520                        edit: fixture.edit,
4521                    },
4522                )
4523                .await
4524                .unwrap();
4525            let field = object_ref_from_id(&format!("{app_name}{}", field_path.as_str())).unwrap();
4526            fields.push((field, state));
4527        }
4528        let registry = zbus::connection::Builder::address(bus.address())
4529            .unwrap()
4530            .name("org.a11y.atspi.Registry")
4531            .unwrap()
4532            .serve_at(
4533                FOCUS_ROOT_PATH,
4534                FocusNode {
4535                    name: "main",
4536                    role: atspi::Role::DesktopFrame,
4537                    role_name: "desktop frame",
4538                    states: StateSet::empty(),
4539                    children: vec![(app_name, FOCUS_ROOT_PATH.try_into().unwrap())],
4540                    text_field: false,
4541                },
4542            )
4543            .unwrap()
4544            .build()
4545            .await
4546            .unwrap();
4547        let conn = AccessibilityConnection::from_address(bus.address().parse().unwrap())
4548            .await
4549            .unwrap();
4550        RunningFields {
4551            _peers: [app, registry],
4552            conn,
4553            fields,
4554            _bus: bus,
4555        }
4556    }
4557
4558    /// One active window, [`FIXTURE_WINDOW`], holding `field`.
4559    pub(crate) async fn start_field_fixture(field: FieldFixture) -> RunningFields {
4560        start_windows_fixture(vec![FixtureWindow {
4561            name: FIXTURE_WINDOW,
4562            active: true,
4563            field,
4564        }])
4565        .await
4566    }
4567
4568    /// Insert into a fresh one-window fixture. Returns the outcome and the
4569    /// field's state afterwards.
4570    pub(crate) async fn insert_into_fixture(
4571        field: FieldFixture,
4572        text: &str,
4573    ) -> (Result<FocusedTextInsert>, FieldState) {
4574        let running = start_field_fixture(field).await;
4575        let outcome = insert_text_at_focus_on(
4576            &running.conn,
4577            std::process::id(),
4578            Some(FIXTURE_WINDOW),
4579            text,
4580        )
4581        .await;
4582        (outcome, running.take_state(0))
4583    }
4584}