cosh_tools/computer/control.rs
1//! `computer_control` — pointer AND keyboard as one pipeline tool.
2//!
3//! Collapses the former `computer_pointer` + `computer_keyboard` pair: one
4//! step is EITHER a pointer action (`action` + target, default `click`) OR a
5//! keyboard action (`key`/`text`), and steps chain via `then` with shell
6//! `&&` semantics — each step runs only if the previous succeeded, the
7//! first failure aborts the chain reporting the exact failing step and
8//! everything that completed before it.
9//!
10//! This file is ONLY the pipeline: chain walking, wait steps and step
11//! classification. The step KINDS live in their components — pointer
12//! dispatch in [`super::mouse`], keyboard dispatch in [`super::keyboard`]
13//! — so each can be reused by other tools without carrying this pipeline
14//! along.
15//!
16//! Targeting is PER STEP (`app`/`pid`/`surface` + `selector` on every
17//! step), which is
18//! what dissolves the focus problem the two old tools wrestled with: a real
19//! click on an element is the one mechanism that moves OS keyboard focus on
20//! every toolkit, so the canonical pipeline is click the field → then type.
21//! No focus synthesis, no gates — the click that selects IS the focus.
22//! Keystrokes go through the shared input simulator (see
23//! [`super::shared_input_sim`]); every call is blocking, so it runs on
24//! tokio's blocking pool.
25use super::keyboard::{self, MAX_WAIT_MS};
26use super::mouse;
27use super::types::{ComputerControl, ControlOutput};
28
29/// Maximum number of steps in a `then` pipeline (the root step included);
30/// 8 covers every realistic sequence (click → type → enter, drag → key, …).
31pub const CONTROL_CHAIN_MAX_DEPTH: usize = 8;
32
33/// Run a desktop-control pipeline: pointer and keyboard steps chained via
34/// `then`, executed sequentially in ONE call with `&&` semantics — each
35/// step runs only if the previous succeeded, and the first failure aborts
36/// with the exact failing step and the report of what completed.
37///
38/// # Errors
39///
40/// Returns `Err` (before any side effect) for an invalid step anywhere in
41/// the chain: a step with both or neither action kind, pointer fields on a
42/// keyboard step, `key` with `text`, `text` with `held`, unknown key or
43/// modifier names, an invalid pointer target combination, or a chain
44/// deeper than [`CONTROL_CHAIN_MAX_DEPTH`]. Returns `Err` mid-chain
45/// (earlier steps stay applied — they were real events) when an
46/// app/selector does not resolve or the platform input backend is
47/// unavailable.
48pub async fn control(input: &ComputerControl) -> Result<ControlOutput, String> {
49 validate_chain(input, CONTROL_CHAIN_MAX_DEPTH)?;
50 // `spawn_blocking` needs 'static — clone the (small) input struct in.
51 let owned = input.clone();
52 tokio::task::spawn_blocking(move || control_blocking(&owned))
53 .await
54 .map_err(|e| format!("computer_control: blocking task failed: {e}"))?
55}
56
57/// Validate the WHOLE chain up front (shape + per-step rules) before any
58/// synthetic event: a malformed step 3 must not leave steps 1-2 applied
59/// with an error that reads like an execution failure.
60pub(crate) fn validate_chain(step: &ComputerControl, remaining: usize) -> Result<(), String> {
61 if remaining == 0 {
62 return Err(format!(
63 "computer_control: `then` chain exceeds {CONTROL_CHAIN_MAX_DEPTH} steps"
64 ));
65 }
66 validate_step(step)?;
67 if let Some(next) = &step.then {
68 validate_chain(next, remaining - 1)?;
69 }
70 Ok(())
71}
72
73/// Validate ONE step. A step is a KEYBOARD step when `key` or `text` is
74/// present, otherwise a POINTER step — the two field groups are disjoint,
75/// and mixing them in one step is a mistake the model should hear about.
76/// The kind-specific rules live in the components.
77fn validate_step(step: &ComputerControl) -> Result<(), String> {
78 // Wait step: STANDALONE — it carries no action, so any other field next
79 // to it is ambiguous about what the wait applies to. Cap the duration:
80 // the pipeline stays one tool call, and a wait is a pause for the app
81 // to catch up, not a sleep primitive.
82 if let Some(ms) = step.wait {
83 let extra: Vec<&str> = [
84 (step.action.is_some(), "`action`"),
85 (step.x.is_some(), "`x`"),
86 (step.y.is_some(), "`y`"),
87 (step.button.is_some(), "`button`"),
88 (step.dx.is_some(), "`dx`"),
89 (step.dy.is_some(), "`dy`"),
90 (step.app.is_some(), "`app`"),
91 (step.pid.is_some(), "`pid`"),
92 (step.surface.is_some(), "`surface`"),
93 (step.selector.is_some(), "`selector`"),
94 (step.nth.is_some(), "`nth`"),
95 (step.anchor.is_some(), "`anchor`"),
96 (step.count.is_some(), "`count`"),
97 (step.held.is_some(), "`held`"),
98 (step.x2.is_some(), "`x2`"),
99 (step.y2.is_some(), "`y2`"),
100 (step.to_selector.is_some(), "`to_selector`"),
101 (step.duration_ms.is_some(), "`duration_ms`"),
102 (step.key.is_some(), "`key`"),
103 (step.text.is_some(), "`text`"),
104 ]
105 .iter()
106 .filter(|(present, _)| *present)
107 .map(|(_, name)| *name)
108 .collect();
109 if !extra.is_empty() {
110 return Err(format!(
111 "computer_control: `wait` is a standalone step — it takes no {}",
112 extra.join(", ")
113 ));
114 }
115 if ms == 0 {
116 return Err("computer_control: `wait` must be >= 1 ms".into());
117 }
118 if ms > MAX_WAIT_MS {
119 return Err(format!(
120 "computer_control: `wait` is capped at {MAX_WAIT_MS} ms per step — \
121 chain several wait steps for longer pauses"
122 ));
123 }
124 return Ok(());
125 }
126
127 let key = step.key.as_deref().map(str::trim).filter(|s| !s.is_empty());
128 let text = step.text.as_deref().filter(|t| !t.is_empty());
129
130 if key.is_some() && text.is_some() {
131 return Err("computer_control: provide `key` or `text`, not both".into());
132 }
133 if key.is_some() || text.is_some() {
134 // Keyboard step: typing goes into the element an EARLIER step's
135 // element click focused — targeting here has no meaning and would
136 // silently disagree with it.
137 for (present, name) in [
138 (step.action.is_some(), "`action`"),
139 (step.x.is_some(), "`x`"),
140 (step.y.is_some(), "`y`"),
141 (step.button.is_some(), "`button`"),
142 (step.dx.is_some(), "`dx`"),
143 (step.dy.is_some(), "`dy`"),
144 (step.anchor.is_some(), "`anchor`"),
145 (step.count.is_some(), "`count`"),
146 (step.x2.is_some(), "`x2`"),
147 (step.y2.is_some(), "`y2`"),
148 (step.to_selector.is_some(), "`to_selector`"),
149 (step.duration_ms.is_some(), "`duration_ms`"),
150 (step.app.is_some(), "`app`"),
151 (step.pid.is_some(), "`pid`"),
152 (step.surface.is_some(), "`surface`"),
153 (step.selector.is_some(), "`selector`"),
154 (step.nth.is_some(), "`nth`"),
155 ] {
156 if present {
157 return Err(format!(
158 "computer_control: {name} belongs to a pointer step — a keyboard step \
159 types into the focused element; click the target element in an \
160 earlier step (or coordinates), then chain the typing via `then`"
161 ));
162 }
163 }
164 if let Some(text) = text {
165 if step.held.is_some() {
166 return Err(format!(
167 "computer_control: `held` is rejected with `text` ({text:?}) — backends \
168 synthesize case/shift; use `key` with `held` for chords"
169 ));
170 }
171 } else {
172 keyboard::parse_key(key.unwrap_or_default(), "computer_control")?;
173 }
174 keyboard::parse_keys(step.held.as_deref().unwrap_or_default(), "computer_control")?;
175 return Ok(());
176 }
177
178 // Pointer step: the kind-specific rules live in the mouse component.
179 mouse::validate(step, "computer_control")
180}
181
182fn control_blocking(input: &ComputerControl) -> Result<ControlOutput, String> {
183 let sim =
184 keyboard::shared_sim().map_err(|e| format!("computer_control: input backend: {e}"))?;
185 // Walk the chain iteratively (&&-semantics): one step at a time, each
186 // only reached if the previous succeeded; the first failure aborts with
187 // the exact step and everything that completed.
188 let mut report = String::new();
189 let mut step = input;
190 let mut index = 1usize;
191 loop {
192 let sent = run_step(&sim, step).map_err(|e| {
193 if report.is_empty() {
194 format!("computer_control: step {index}: {e}")
195 } else {
196 format!("computer_control: step {index}: {e} — completed: {report}")
197 }
198 })?;
199 if !report.is_empty() {
200 report.push_str(" → ");
201 }
202 report.push_str(&sent);
203 match &step.then {
204 Some(next) => {
205 step = next;
206 index += 1;
207 }
208 None => return Ok(ControlOutput { sent: report }),
209 }
210 }
211}
212
213/// Execute ONE validated step and return its human-facing report.
214fn run_step(sim: &xa11y::InputSim, step: &ComputerControl) -> Result<String, String> {
215 // Wait step: a plain pause (validated standalone) — gives the app time
216 // to open a dialog or create an ephemeral field before the next step.
217 if let Some(ms) = step.wait {
218 return Ok(keyboard::run_wait_step(ms));
219 }
220 // Classify EXACTLY like validate_step via the SHARED engine helpers, so
221 // validation and execution can never disagree about the step's kind.
222 let key = step.key.as_deref();
223 let text = step.text.as_deref();
224 if keyboard::is_keyboard_step(key, text) {
225 keyboard::run_keyboard_step(
226 sim,
227 &keyboard::KeyboardStep {
228 key,
229 text,
230 held: step.held.as_deref(),
231 },
232 "computer_control",
233 )
234 } else {
235 mouse::run_step(sim, step, "computer_control")
236 }
237}