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