1use 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
11pub const STANDARD_METHODS: &[&str] = &["app.ping", "app.version", "engine.commands", "engine.execute", "engine.batch"];
13
14pub 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
19pub 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
36pub const MAX_BATCH: usize = 10_000;
38
39#[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
52pub trait UiHandler {
55 fn methods(&self) -> Vec<String>;
58 fn handle(&mut self, req: Request);
61}
62
63#[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
81pub 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
90pub 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
123pub 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
128fn params_of(p: &Value) -> Value {
130 match p.get("params") {
131 None | Some(Value::Null) => json!({}),
132 Some(v) => v.clone(),
133 }
134}
135
136fn commands(dispatch: &mut dyn Dispatch) -> Vec<CommandInfo> {
138 catch_unwind(AssertUnwindSafe(|| dispatch.commands())).unwrap_or_default()
139}
140
141fn 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
146fn 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 #[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 assert!(!answer("ui.screenshot", json!({}), &mut d, &mut ui, &app).is_ok());
314 }
315}