Skip to main content

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}