Skip to main content

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}