Skip to main content

cosh_tools/computer/
apps.rs

1//! `computer_apps` — list running desktop applications, or report the
2//! foreground application and its keyboard-focused element.
3//!
4//! Thin adapter over [`xa11y::AppExt`]; every xa11y call is blocking
5//! (platform accessibility APIs are synchronous) so it runs on tokio's
6//! blocking pool.
7use std::time::Duration;
8
9use serde::Serialize;
10use xa11y::{App, AppExt};
11
12use super::types::{AppInfo, AppsOutput, AppsTarget, ComputerApps, FocusedElement, FocusedOutput};
13
14/// Default auto-wait for a foreground application to exist.
15const DEFAULT_TIMEOUT_MS: u64 = 3000;
16
17/// Result of `computer_apps`, by [`AppsTarget`].
18///
19/// Untagged: the model sees the plain `AppsOutput` or `FocusedOutput` JSON
20/// shape, never the enum wrapper.
21#[derive(Debug, Clone, Serialize)]
22#[serde(untagged)]
23pub enum AppsResult {
24    /// `target: "all"` (default) — every running application.
25    All(AppsOutput),
26    /// `target: "focused"` — the foreground app and its focused element.
27    Focused(FocusedOutput),
28}
29
30/// Report running desktop applications, or the focused one.
31///
32/// With `target: "all"` (the default) the result is a point-in-time snapshot
33/// in platform enumeration order; the entry actually holding the system
34/// foreground is flagged.
35///
36/// With `target: "focused"` only the foreground application is resolved, plus
37/// the element inside it holding keyboard focus — the cheap "where do my
38/// keystrokes go?" check before `computer_keyboard`.
39///
40/// # Errors
41///
42/// Returns `Err` when the platform accessibility API is unreachable
43/// (missing macOS Accessibility permission, no AT-SPI2 bus on Linux, …), or —
44/// for `target: "focused"` — when no application holds the foreground within
45/// the timeout (e.g. focus is on the shell or a lock screen).
46pub async fn apps(input: &ComputerApps) -> Result<AppsResult, String> {
47    let input = input.clone();
48    tokio::task::spawn_blocking(move || apps_blocking(&input))
49        .await
50        .map_err(|e| format!("computer_apps: blocking task failed: {e}"))?
51}
52
53fn apps_blocking(input: &ComputerApps) -> Result<AppsResult, String> {
54    match input.target.unwrap_or_default() {
55        AppsTarget::All => list_all().map(AppsResult::All),
56        AppsTarget::Focused => focused_blocking(input).map(AppsResult::Focused),
57    }
58}
59
60fn list_all() -> Result<AppsOutput, String> {
61    let list =
62        App::list().map_err(|e| super::errors::render("computer_apps", "list applications", &e))?;
63    let apps: Vec<AppInfo> = list
64        .iter()
65        .map(|app| AppInfo {
66            name: app.name.clone(),
67            pid: app.pid,
68            foreground: app.is_foreground(),
69        })
70        .collect();
71    let count = apps.len();
72    Ok(AppsOutput { apps, count })
73}
74
75/// Resolve the foreground application and the element inside it holding
76/// keyboard focus.
77///
78/// The focused element is found by a bounded depth-first walk looking for
79/// `states.focused` — the same state the snapshot reports, so what this
80/// answers is exactly what `computer_keyboard` will type into. A miss (focus
81/// on the window itself, or the platform not reporting element focus)
82/// reports `focused_element: null` rather than failing, because the app
83/// answer is still correct and useful.
84fn focused_blocking(input: &ComputerApps) -> Result<FocusedOutput, String> {
85    let timeout = Duration::from_millis(input.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS));
86    let app = App::foreground(timeout).map_err(|e| {
87        super::errors::render("computer_apps", "resolve foreground application", &e)
88    })?;
89    let focused_element =
90        find_focused_below(&app.as_element()).map(|(path, leaf)| FocusedElement {
91            role: leaf.role.to_snake_case().to_string(),
92            name: leaf.name.clone(),
93            value: leaf.value.clone(),
94            path,
95        });
96    Ok(FocusedOutput {
97        app: app.name.clone(),
98        pid: app.pid,
99        focused_element,
100    })
101}
102
103/// Depth bound for the focused-element walk: deep enough to reach fields in
104/// real dialogs, shallow enough to stay cheap. A tree deeper than this does
105/// not fail the query — focus that far down is reported as absent, and a
106/// snapshot answers it precisely.
107pub(crate) const FOCUSED_WALK_MAX_DEPTH: usize = 15;
108
109/// Depth-first search for the focused element **below** `root`, with
110/// `root`'s own `states.focused` flag ignored.
111///
112/// Skipping the root's flag is essential: xa11y's `foreground_with` stamps
113/// `states.focused = true` on the application root it returns (so
114/// `App::is_foreground()` agrees with `list`/`find`), and that stamp is the
115/// *foreground-app* marker, not element-level keyboard focus. Matching it
116/// would short-circuit the walk and always report the app root as the
117/// focused element.
118///
119/// Returns the leaf's data plus the role path from the root down to it,
120/// inclusive.
121pub(crate) fn find_focused_below(
122    root: &xa11y::Element,
123) -> Option<(Vec<String>, xa11y::ElementData)> {
124    let root_role = root.data().role.to_snake_case().to_string();
125    for child in root.children().ok()? {
126        if let Some((path, leaf)) = find_focused(&child, 1) {
127            let mut full = Vec::with_capacity(path.len() + 1);
128            full.push(root_role);
129            full.extend(path);
130            return Some((full, leaf));
131        }
132    }
133    None
134}
135
136/// Depth-first search for the element whose state reports keyboard focus,
137/// starting at `depth`. Returns the leaf's data plus the role path from (and
138/// including) this element down to it.
139pub(crate) fn find_focused(
140    element: &xa11y::Element,
141    depth: usize,
142) -> Option<(Vec<String>, xa11y::ElementData)> {
143    if depth > FOCUSED_WALK_MAX_DEPTH {
144        return None;
145    }
146    let data = element.data();
147    let role = data.role.to_snake_case().to_string();
148    if data.states.focused {
149        return Some((vec![role], data.clone()));
150    }
151    // Cap reached: report absence instead of fetching another level —
152    // bounded depth means bounded provider work.
153    if depth == FOCUSED_WALK_MAX_DEPTH {
154        return None;
155    }
156    for child in element.children().ok()? {
157        if let Some((mut path, leaf)) = find_focused(&child, depth + 1) {
158            path.insert(0, role.clone());
159            return Some((path, leaf));
160        }
161    }
162    None
163}