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}