cosh_tools/computer/surface.rs
1//! Shell-surface component — resolution of an OS shell surface (menu bar,
2//! Dock/taskbar/panel, tray status items, desktop, flyouts) as a targeting
3//! root.
4//!
5//! This is a COMPONENT, not a tool: it knows how to turn a
6//! [`SurfaceKind`] into a [`ShellSurface`] handle (whose `locator` /
7//! `as_element` slot into the exact same selector flow apps use) and how
8//! to enforce the `name | pid | surface` targeting exclusivity, and
9//! nothing else. The observation tools (`computer_snapshot`,
10//! `computer_screenshot`) and the semantic pipeline (`computer_act`)
11//! compose it; a future tool that needs shell targeting can reuse it
12//! without carrying any of them along.
13//!
14//! Enumeration is read-only by xa11y's contract: listing a surface never
15//! opens, closes, focuses or presses anything.
16use std::sync::Arc;
17use std::time::Duration;
18
19use xa11y::{Provider, ShellSurface, ShellSurfaceKind};
20
21use super::types::{ComputerAct, ComputerScreenshot, ComputerSnapshot, SurfaceKind};
22
23/// Map the wire enum onto xa11y's kind vocabulary — total, so a missed arm
24/// fails compilation rather than silently targeting the wrong surface.
25pub(crate) fn kind(kind: SurfaceKind) -> ShellSurfaceKind {
26 match kind {
27 SurfaceKind::MenuBar => ShellSurfaceKind::MenuBar,
28 SurfaceKind::StatusItems => ShellSurfaceKind::StatusItems,
29 SurfaceKind::Taskbar => ShellSurfaceKind::Taskbar,
30 SurfaceKind::Panel => ShellSurfaceKind::Panel,
31 SurfaceKind::Dock => ShellSurfaceKind::Dock,
32 SurfaceKind::Desktop => ShellSurfaceKind::Desktop,
33 SurfaceKind::Flyout => ShellSurfaceKind::Flyout,
34 SurfaceKind::Unknown => ShellSurfaceKind::Unknown,
35 }
36}
37
38/// Shared targeting validation for `name | pid | surface` (exactly one).
39///
40/// `tool` names the calling tool in the error messages; `app_field` is
41/// `"name"` (snapshot/act) or `"app"` (screenshot), matching each tool's
42/// wire shape.
43///
44/// # Errors
45///
46/// Returns `Err` when two or zero targets are given.
47fn validate_targeting(
48 tool: &str,
49 app_field: &str,
50 app: Option<&str>,
51 pid: Option<u32>,
52 surface: Option<SurfaceKind>,
53 allow_rootless: bool,
54) -> Result<(), String> {
55 // A blank/whitespace app name is ABSENT: a model padding a name with
56 // spaces must get the clean "provide" error here, not a dispatch
57 // panic downstream (the dispatch matches trim the name the same way).
58 let app = app.map(str::trim).filter(|s| !s.is_empty());
59 let has_app = app.is_some();
60 let has_surface = surface.is_some();
61 let given = usize::from(has_app) + usize::from(pid.is_some()) + usize::from(has_surface);
62 if given == 0 {
63 // `computer_screenshot` has legal ROOTLESS forms (full display,
64 // `region`) — the caller's later clauses police those; every other
65 // consumer requires exactly one root.
66 return if allow_rootless {
67 Ok(())
68 } else {
69 Err(format!("{tool}: provide {app_field}, `pid` or `surface`"))
70 };
71 }
72 if given > 1 {
73 return Err(if has_app && pid.is_some() {
74 format!("{tool}: provide {app_field} or `pid`, not both")
75 } else {
76 format!(
77 "{tool}: target ONE of {app_field}/`pid`/`surface` — an app and a shell \
78 surface cannot share a call"
79 )
80 });
81 }
82 Ok(())
83}
84
85/// Targeting check for `computer_snapshot` (`name`/`pid`/`surface`).
86pub fn validate_snapshot(input: &ComputerSnapshot) -> Result<(), String> {
87 validate_targeting(
88 "computer_snapshot",
89 "`name`",
90 input.name.as_deref(),
91 input.pid,
92 input.surface,
93 false,
94 )
95}
96
97/// Targeting check for `computer_act` (`name`/`pid`/`surface` on a
98/// semantic step).
99pub fn validate_act(step: &ComputerAct) -> Result<(), String> {
100 validate_targeting(
101 "computer_act",
102 "`name`",
103 step.name.as_deref(),
104 step.pid,
105 step.surface,
106 false,
107 )
108}
109
110/// Targeting check for `computer_screenshot` (`app`/`pid`/`surface`).
111///
112/// Rootless calls are LEGAL here (full display and `region` captures) —
113/// this check only rejects MIXED roots; the tool's own clauses police
114/// "root without selector" and "annotate without root".
115pub fn validate_screenshot(input: &ComputerScreenshot) -> Result<(), String> {
116 validate_targeting(
117 "computer_screenshot",
118 "`app`",
119 input.app.as_deref(),
120 input.pid,
121 input.surface,
122 true,
123 )
124}
125
126/// Resolve a surface kind against an EXPLICIT provider — the seam the
127/// mock-provider tests use (they supply the mock instead of the live
128/// desktop session).
129///
130/// # Errors
131///
132/// Returns `Err` when the platform accessibility API is unreachable or
133/// the platform has no surface of this kind right now (e.g. a flyout that
134/// is not open — that is honest scope, not a failure).
135pub fn resolve_with(
136 provider: Arc<dyn Provider>,
137 surface: SurfaceKind,
138 timeout: Duration,
139) -> Result<ShellSurface, String> {
140 ShellSurface::by_kind_with(provider.clone(), kind(surface), timeout).map_err(|e| {
141 // Field-test finding (X11/i3 desktop): a miss is only honest
142 // scope when the platform DOES classify surfaces. Enrich the error
143 // with a live census so an empty classification (WM whose bar
144 // never registers an AT-SPI dock frame — i3bar, polybar — or a
145 // compositor the backend cannot see through) is diagnosable from
146 // the error alone, instead of reading as a missing flyout.
147 let census = ShellSurface::list_with(Arc::clone(&provider))
148 .map(|surfaces| {
149 if surfaces.is_empty() {
150 "census: 0 shell surfaces enumerated — this backend classifies \
151 AT-SPI Frames with `window-type: dock` only; WMs that draw \
152 their bar without one (i3bar, polybar, swaybar without a \
153 tray bridge) legitimately enumerate nothing"
154 .to_string()
155 } else {
156 format!(
157 "census: {} shell surface(s) present: {}",
158 surfaces.len(),
159 surfaces
160 .iter()
161 .map(|s| s.kind.to_snake_case())
162 .collect::<Vec<_>>()
163 .join(", ")
164 )
165 }
166 })
167 .unwrap_or_else(|e| format!("census unavailable: {e}"));
168 format!(
169 "computer surface `{}`: {e}; {census}",
170 surface_label(surface)
171 )
172 })
173}
174
175/// Resolve a surface kind against the global singleton provider — the
176/// production path (same singleton every other tool reaches apps through).
177///
178/// # Errors
179///
180/// Same as [`resolve_with`]; the singleton itself failing to construct is
181/// reported verbatim (missing macOS Accessibility permission, no AT-SPI2
182/// bus on Linux, …).
183pub fn resolve(surface: SurfaceKind, timeout: Duration) -> Result<ShellSurface, String> {
184 let provider = xa11y::provider().map_err(|e| {
185 super::errors::render(
186 "computer",
187 &format!(
188 "initialize the accessibility provider (for surface `{}`)",
189 surface_label(surface)
190 ),
191 &e,
192 )
193 })?;
194 resolve_with(provider, surface, timeout)
195}
196
197/// Wire-facing label of a surface kind (matches the snake_case serde
198/// renaming), for error and output messages.
199pub fn surface_label(surface: SurfaceKind) -> &'static str {
200 match surface {
201 SurfaceKind::MenuBar => "menu_bar",
202 SurfaceKind::StatusItems => "status_items",
203 SurfaceKind::Taskbar => "taskbar",
204 SurfaceKind::Panel => "panel",
205 SurfaceKind::Dock => "dock",
206 SurfaceKind::Desktop => "desktop",
207 SurfaceKind::Flyout => "flyout",
208 SurfaceKind::Unknown => "unknown",
209 }
210}