Skip to main content

ashlar_control/
methods.rs

1//! The standard method set, answered from any [`Dispatch`], and the routing of `ui.*` methods to
2//! the app's [`UiHandler`].
3
4use std::panic::{AssertUnwindSafe, catch_unwind};
5
6use ashlar_command::{CommandError, CommandInfo, Dispatch};
7use serde_json::{Value, json};
8
9use crate::protocol::{PROTOCOL_VERSION, Reply, Request};
10
11/// Methods every app answers, headless or not (`auth` is handled by the transport).
12pub const STANDARD_METHODS: &[&str] = &["app.ping", "app.version", "engine.commands", "engine.execute", "engine.batch"];
13
14/// The canonical UI vocabulary. A [`UiHandler`] answers the ones it supports (and may add more
15/// `ui.*` methods). See `docs/control-protocol.md` for the parameters.
16pub const UI_METHODS: &[&str] =
17    &["ui.inspect", "ui.click", "ui.drag", "ui.scroll", "ui.key", "ui.type", "ui.screenshot", "ui.menu.list", "ui.menu.invoke", "ui.set", "app.quit"];
18
19/// The debugger vocabulary (element snapshots with refs, actions on targets, waits; see
20/// `docs/control-protocol.md`). Not part of [`UI_METHODS`]: a [`UiHandler`] reports each one only
21/// once it answers it, so clients never list a tool the app cannot serve.
22pub const DEBUG_METHODS: &[&str] = &[
23    "ui.snapshot",
24    "ui.hover",
25    "ui.focus",
26    "ui.fill",
27    "ui.press",
28    "ui.scroll_into_view",
29    "ui.get",
30    "ui.wait",
31    "ui.highlight",
32    "devtools.toggle",
33    "devtools.state",
34];
35
36/// Most steps one `engine.batch` may hold.
37pub const MAX_BATCH: usize = 10_000;
38
39/// Who is answering: reported by `app.version`.
40#[derive(Debug, Clone, PartialEq)]
41pub struct AppInfo {
42    pub name: String,
43    pub version: String,
44}
45
46impl AppInfo {
47    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
48        Self { name: name.into(), version: version.into() }
49    }
50}
51
52/// The app's answer to everything outside [`STANDARD_METHODS`]: `ui.*`, `app.quit` and any
53/// app-specific methods. Implemented by the UI runtime.
54pub trait UiHandler {
55    /// The methods this handler answers (reported by `app.version`, so clients such as the MCP
56    /// server know whether screenshots and input work).
57    fn methods(&self) -> Vec<String>;
58    /// Answer `req` now, or keep it and answer on a later frame (input must be processed first, a
59    /// screenshot needs the next paint). Unknown methods: [`Request::unknown`].
60    fn handle(&mut self, req: Request);
61}
62
63/// No UI: a headless session. `ui.*` methods are refused with a hint.
64#[derive(Debug, Clone, Copy, Default)]
65pub struct NoUi;
66
67impl UiHandler for NoUi {
68    fn methods(&self) -> Vec<String> {
69        Vec::new()
70    }
71    fn handle(&mut self, req: Request) {
72        if req.method().starts_with("ui.") || req.method() == "app.quit" || req.method() == crate::APP_CLOCK {
73            let m = req.method().to_string();
74            req.fail(format!("`{m}` needs the app window; this is a headless session (connect to the running app instead)"));
75        } else {
76            req.unknown();
77        }
78    }
79}
80
81/// Answer one request on the app thread: standard methods from `dispatch`, everything else from
82/// `ui` (which may keep the request for a later frame).
83pub fn handle(req: Request, dispatch: &mut dyn Dispatch, ui: &mut dyn UiHandler, app: &AppInfo) {
84    match standard(req.method(), req.params(), dispatch, ui, app) {
85        Some(r) => req.reply(r),
86        None => ui.handle(req),
87    }
88}
89
90/// Answer one request synchronously (headless serving, tests). A request the UI handler keeps for
91/// later is answered with an error.
92pub fn answer(method: &str, params: Value, dispatch: &mut dyn Dispatch, ui: &mut dyn UiHandler, app: &AppInfo) -> Reply {
93    let (req, rx) = Request::new(method, params);
94    handle(req, dispatch, ui, app);
95    rx.try_recv().unwrap_or_else(|_| Reply::err(format!("`{method}` cannot be answered synchronously here")))
96}
97
98fn standard(method: &str, p: &Value, dispatch: &mut dyn Dispatch, ui: &dyn UiHandler, app: &AppInfo) -> Option<Reply> {
99    Some(match method {
100        "app.ping" => Reply::ok(json!("pong")),
101        "app.version" => {
102            let mut methods: Vec<String> = STANDARD_METHODS.iter().map(|m| m.to_string()).collect();
103            methods.extend(ui.methods());
104            Reply::ok(json!({"name": app.name, "version": app.version, "protocol": PROTOCOL_VERSION, "methods": methods}))
105        }
106        "engine.commands" => {
107            let q = p.get("query").and_then(Value::as_str).unwrap_or("").to_lowercase();
108            let list: Vec<CommandInfo> = commands(dispatch).into_iter().filter(|c| matches_query(c, &q)).collect();
109            Reply::ok(serde_json::to_value(list).unwrap_or_default())
110        }
111        "engine.execute" => match p.get("command").and_then(Value::as_str) {
112            Some(id) => match execute(dispatch, id, params_of(p)) {
113                Ok(v) => Reply::Ok(v),
114                Err(e) => crate::explain::explain(&e, &commands(dispatch)),
115            },
116            None => Reply::err("engine.execute needs a string `command`"),
117        },
118        "engine.batch" => batch(dispatch, p),
119        _ => return None,
120    })
121}
122
123/// Whether a command matches a lower-case filter on its id, label or menu path.
124pub fn matches_query(c: &CommandInfo, q: &str) -> bool {
125    q.is_empty() || c.id.to_lowercase().contains(q) || c.label.to_lowercase().contains(q) || c.menu.iter().any(|m| m.to_lowercase().contains(q))
126}
127
128/// `params` of a request, `{}` when absent or null.
129fn params_of(p: &Value) -> Value {
130    match p.get("params") {
131        None | Some(Value::Null) => json!({}),
132        Some(v) => v.clone(),
133    }
134}
135
136/// `dispatch.commands()`; a panicking implementation lists nothing.
137fn commands(dispatch: &mut dyn Dispatch) -> Vec<CommandInfo> {
138    catch_unwind(AssertUnwindSafe(|| dispatch.commands())).unwrap_or_default()
139}
140
141/// `dispatch.execute()`; a panic becomes [`CommandError::Panicked`].
142fn execute(dispatch: &mut dyn Dispatch, id: &str, params: Value) -> Result<Value, CommandError> {
143    catch_unwind(AssertUnwindSafe(|| dispatch.execute(id, params))).unwrap_or_else(|_| Err(CommandError::Panicked { id: id.to_string() }))
144}
145
146/// `engine.batch {commands: [{command, params?}], keepGoing?}`: runs in order, stops at the first
147/// error unless `keepGoing`. All ok: the step results. Otherwise an error whose `detail.steps`
148/// holds every step that ran.
149fn batch(dispatch: &mut dyn Dispatch, p: &Value) -> Reply {
150    let Some(steps) = p.get("commands").and_then(Value::as_array) else {
151        return Reply::err("engine.batch needs a `commands` array of {command, params}");
152    };
153    if steps.len() > MAX_BATCH {
154        return Reply::err(format!("engine.batch takes at most {MAX_BATCH} commands, got {}", steps.len()));
155    }
156    let keep_going = p.get("keepGoing").and_then(Value::as_bool).unwrap_or(false);
157    let mut out = Vec::with_capacity(steps.len());
158    let mut first_failure: Option<String> = None;
159    let mut failed = 0usize;
160    for (i, step) in steps.iter().enumerate() {
161        let Some(id) = step.get("command").and_then(Value::as_str) else {
162            let e = format!("step {} has no string `command`", i.saturating_add(1));
163            out.push(json!({"ok": false, "error": e}));
164            first_failure.get_or_insert(e);
165            failed = failed.saturating_add(1);
166            if keep_going {
167                continue;
168            }
169            break;
170        };
171        match execute(dispatch, id, params_of(step)) {
172            Ok(v) => out.push(json!({"command": id, "ok": true, "result": v})),
173            Err(e) => {
174                let (msg, detail) = match crate::explain::explain(&e, &commands(dispatch)) {
175                    Reply::Err { error, detail } => (error, detail.unwrap_or(Value::Null)),
176                    Reply::Ok(_) => (e.to_string(), serde_json::to_value(&e).unwrap_or(Value::Null)),
177                };
178                out.push(json!({"command": id, "ok": false, "error": msg, "detail": detail}));
179                first_failure.get_or_insert(format!("step {} (`{id}`) failed: {msg}", i.saturating_add(1)));
180                failed = failed.saturating_add(1);
181                if !keep_going {
182                    break;
183                }
184            }
185        }
186    }
187    match first_failure {
188        None => Reply::ok(Value::Array(out)),
189        Some(first) if keep_going => Reply::err_with(format!("{failed} of {} steps failed; first: {first}", steps.len()), json!({"steps": out})),
190        Some(first) => Reply::err_with(first, json!({"steps": out})),
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    /// A counter with one mutating command, one failing, one panicking.
199    #[derive(Default)]
200    struct Counter(i64);
201
202    impl Dispatch for Counter {
203        fn commands(&self) -> Vec<CommandInfo> {
204            ["count.add", "count.fail", "count.boom"]
205                .iter()
206                .map(|id| CommandInfo {
207                    id: id.to_string(),
208                    label: id.to_string(),
209                    menu: vec!["Edit".into()],
210                    shortcut: None,
211                    params_schema: Value::Null,
212                    params_text: None,
213                    enabled: true,
214                    disabled_reason: None,
215                    mutates: true,
216                })
217                .collect()
218        }
219        fn execute(&mut self, id: &str, params: Value) -> Result<Value, CommandError> {
220            match id {
221                "count.add" => {
222                    self.0 += params.get("by").and_then(Value::as_i64).unwrap_or(1);
223                    Ok(json!(self.0))
224                }
225                "count.fail" => Err(CommandError::Failed { id: id.into(), msg: "no".into() }),
226                "count.boom" => panic!("boom"),
227                _ => Err(CommandError::Unknown { id: id.into() }),
228            }
229        }
230    }
231
232    fn call(d: &mut Counter, m: &str, p: Value) -> Reply {
233        answer(m, p, d, &mut NoUi, &AppInfo::new("test", "1.2.3"))
234    }
235
236    #[test]
237    fn standard_methods() {
238        let mut d = Counter::default();
239        assert_eq!(call(&mut d, "app.ping", json!({})), Reply::ok(json!("pong")));
240        let Reply::Ok(v) = call(&mut d, "app.version", json!({})) else { panic!() };
241        assert_eq!((v["name"].clone(), v["version"].clone(), v["protocol"].clone()), (json!("test"), json!("1.2.3"), json!(1)));
242        assert_eq!(v["methods"].as_array().unwrap().len(), STANDARD_METHODS.len());
243        let Reply::Ok(v) = call(&mut d, "engine.commands", json!({"query": "FAIL"})) else { panic!() };
244        assert_eq!(v.as_array().unwrap().len(), 1);
245        assert_eq!(call(&mut d, "engine.execute", json!({"command": "count.add", "params": {"by": 5}})), Reply::ok(json!(5)));
246        assert_eq!(call(&mut d, "engine.execute", json!({"command": "count.add"})), Reply::ok(json!(6)));
247        let Reply::Err { error, detail } = call(&mut d, "engine.execute", json!({"command": "nope"})) else { panic!() };
248        assert_eq!(detail, Some(json!({"kind": "unknown", "id": "nope", "suggestions": []})));
249        assert!(error.starts_with("unknown command `nope`;"), "{error}");
250        let Reply::Err { error, detail } = call(&mut d, "engine.execute", json!({"command": "count.ad"})) else { panic!() };
251        assert!(error.contains("did you mean `count.add`"), "{error}");
252        assert_eq!(detail.unwrap()["suggestions"][0]["id"], "count.add");
253        assert!(!call(&mut d, "engine.execute", json!({"command": 3})).is_ok());
254    }
255
256    #[test]
257    fn a_panicking_command_is_an_error() {
258        let mut d = Counter::default();
259        let Reply::Err { detail, .. } = call(&mut d, "engine.execute", json!({"command": "count.boom"})) else { panic!() };
260        assert_eq!(detail, Some(json!({"kind": "panicked", "id": "count.boom"})));
261    }
262
263    #[test]
264    fn batches_stop_at_the_first_error_unless_keep_going() {
265        let mut d = Counter::default();
266        let steps = json!([{"command": "count.add"}, {"command": "count.fail"}, {"command": "count.add"}]);
267        let Reply::Err { error, detail } = call(&mut d, "engine.batch", json!({"commands": steps})) else { panic!() };
268        assert!(error.starts_with("step 2 (`count.fail`) failed"), "{error}");
269        assert_eq!(detail.unwrap()["steps"].as_array().unwrap().len(), 2);
270        assert_eq!(d.0, 1);
271        let Reply::Err { error, detail } = call(&mut d, "engine.batch", json!({"commands": steps, "keepGoing": true})) else { panic!() };
272        assert!(error.starts_with("1 of 3 steps failed"), "{error}");
273        assert_eq!(detail.unwrap()["steps"].as_array().unwrap().len(), 3);
274        assert_eq!(d.0, 3);
275        assert_eq!(
276            call(&mut d, "engine.batch", json!({"commands": [{"command": "count.add"}]})),
277            Reply::ok(json!([{"command": "count.add", "ok": true, "result": 4}]))
278        );
279        assert!(!call(&mut d, "engine.batch", json!({})).is_ok());
280        assert!(!call(&mut d, "engine.batch", json!({"commands": [5]})).is_ok());
281        let huge: Vec<Value> = (0..=MAX_BATCH).map(|_| json!({"command": "count.add"})).collect();
282        assert!(!call(&mut d, "engine.batch", json!({"commands": huge})).is_ok());
283        assert_eq!(d.0, 4, "an oversize batch runs nothing");
284    }
285
286    #[test]
287    fn ui_methods_go_to_the_handler() {
288        let mut d = Counter::default();
289        let Reply::Err { error, .. } = call(&mut d, "ui.click", json!({})) else { panic!() };
290        assert!(error.contains("needs the app window"), "{error}");
291        let Reply::Err { error, .. } = call(&mut d, "frobnicate", json!({})) else { panic!() };
292        assert!(error.contains("unknown method"), "{error}");
293
294        struct Later(Vec<Request>);
295        impl UiHandler for Later {
296            fn methods(&self) -> Vec<String> {
297                vec!["ui.screenshot".into()]
298            }
299            fn handle(&mut self, req: Request) {
300                self.0.push(req);
301            }
302        }
303        let mut ui = Later(Vec::new());
304        let app = AppInfo::new("t", "1");
305        let (req, rx) = Request::new("ui.screenshot", json!({}));
306        handle(req, &mut d, &mut ui, &app);
307        assert!(rx.try_recv().is_err(), "kept for a later frame");
308        ui.0.pop().unwrap().ok(json!({"png": ""}));
309        assert!(rx.try_recv().unwrap().is_ok());
310        let Reply::Ok(v) = answer("app.version", json!({}), &mut d, &mut ui, &app) else { panic!() };
311        assert!(v["methods"].as_array().unwrap().contains(&json!("ui.screenshot")));
312        // A kept request cannot be answered synchronously.
313        assert!(!answer("ui.screenshot", json!({}), &mut d, &mut ui, &app).is_ok());
314    }
315}