1use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use ashlar_command::CommandInfo;
8use ashlar_control::Client;
9use base64::Engine;
10use serde_json::{Value, json};
11
12use crate::session::{self, Session, Trace};
13use crate::transport::MAX_PAUSE_MS;
14use crate::words::{Args, quote};
15use crate::{Ctx, Done, Fail, Res, target, usage_of};
16
17const GET_WHAT: &[&str] = &["text", "value", "name", "role", "rect", "enabled", "visible", "focused", "checked", "selected", "element"];
19
20const ACTIONS: &[&str] = &["click", "dblclick", "hover", "focus", "fill", "type", "press", "scroll", "scrollintoview", "drag", "exec", "menu"];
22const INPROC_REFUSED: &[&str] = &["open", "connect", "close", "sessions", "trace", "demo"];
24
25fn unix_ms() -> u128 {
26 SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0)
27}
28
29fn json_arg(raw: Option<String>, input: &mut dyn std::io::BufRead) -> Result<Value, String> {
31 let text = match raw.as_deref() {
32 None => return Ok(json!({})),
33 Some("-") => {
34 let mut s = String::new();
35 input.read_to_string(&mut s).map_err(|e| format!("cannot read stdin: {e}"))?;
36 s
37 }
38 Some(t) => t.to_string(),
39 };
40 if text.trim().is_empty() {
41 return Ok(json!({}));
42 }
43 serde_json::from_str(&text).map_err(|e| format!("not valid JSON: {e}"))
44}
45
46fn plain(v: &Value) -> String {
48 match v {
49 Value::String(s) => s.clone(),
50 Value::Null => String::new(),
51 other => other.to_string(),
52 }
53}
54
55pub(crate) fn outline(v: &Value) -> String {
57 if let Some(t) = v.get("tree").and_then(Value::as_str).filter(|t| !t.trim().is_empty()) {
58 return t.trim_end().to_string();
59 }
60 let els = v.get("elements").and_then(Value::as_array).cloned().unwrap_or_default();
61 let mut lines = Vec::new();
62 for e in &els {
63 let s = |k: &str| e.get(k).and_then(Value::as_str).unwrap_or("");
64 let mut depth = 0usize;
65 let mut parent = e.get("parent").and_then(Value::as_str).map(String::from);
66 while let Some(p) = parent.take() {
67 depth = depth.saturating_add(1);
68 if depth > 64 {
69 break;
70 }
71 parent = els
72 .iter()
73 .find(|x| x.get("ref").and_then(Value::as_str) == Some(&p))
74 .and_then(|x| x.get("parent"))
75 .and_then(Value::as_str)
76 .map(String::from);
77 }
78 let mut line = format!("{}- {} \"{}\" [ref={}]", " ".repeat(depth), s("role"), s("name"), s("ref"));
79 if e.get("enabled") == Some(&json!(false)) {
80 line.push_str(" [disabled]");
81 }
82 lines.push(line);
83 }
84 lines.join("\n")
85}
86
87fn need<T>(v: Option<T>, what: &str, verb: &str) -> Result<T, Fail> {
88 v.ok_or_else(|| Fail::Usage(format!("{verb} needs {what}"), usage_of(verb)))
89}
90
91fn usage(verb: &str) -> impl Fn(String) -> Fail + '_ {
92 move |e| Fail::Usage(e, usage_of(verb))
93}
94
95fn parse_target(s: &str, verb: &str) -> Result<Value, Fail> {
96 target::parse(s).map_err(usage(verb))
97}
98
99fn modifiers(s: &str) -> Result<Value, String> {
101 let mut m = serde_json::Map::new();
102 for part in s.split('+').map(str::trim).filter(|p| !p.is_empty()) {
103 let key = match part.to_ascii_lowercase().as_str() {
104 "mod" | "cmd" | "command" => "command",
105 "shift" => "shift",
106 "alt" | "option" | "opt" => "alt",
107 "ctrl" | "control" => "ctrl",
108 other => return Err(format!("unknown modifier `{other}` (Mod, Shift, Alt, Ctrl)")),
109 };
110 m.insert(key.into(), json!(true));
111 }
112 Ok(Value::Object(m))
113}
114
115impl Ctx<'_, '_, '_> {
116 pub(crate) fn run_verb(&mut self, verb: &str, a: Args, shown: &[String]) -> Res {
119 if self.inproc.is_some() && INPROC_REFUSED.contains(&verb) {
120 return Err(Fail::Failed {
121 error: format!("`{verb}` is not available in an in-process script"),
122 detail: None,
123 hint: Some("the script already talks to its app; sessions and traces belong to `ashlar-debug`".into()),
124 });
125 }
126 let r = self.dispatch(verb, a);
127 if ACTIONS.contains(&verb) && self.inproc.is_none() {
128 self.record(verb, shown, &r);
129 }
130 r
131 }
132
133 fn dispatch(&mut self, verb: &str, mut a: Args) -> Res {
134 if a.flag(&["--help", "-h"]) {
135 return Ok(Done::new(json!({"usage": usage_of(verb)}), format!("usage: {}", usage_of(verb))));
136 }
137 match verb {
138 "open" => self.open(a),
139 "connect" => self.connect(a),
140 "close" => self.close(a),
141 "status" => self.status(a),
142 "sessions" => self.sessions(a),
143 "snapshot" => self.snapshot(a),
144 "click" | "dblclick" => self.click(verb, a),
145 "hover" | "focus" | "scrollintoview" | "scroll-into-view" => self.simple(verb, a),
146 "fill" => self.fill(a),
147 "type" => self.type_text(a),
148 "press" | "key" => self.press(a),
149 "scroll" => self.scroll(a),
150 "drag" => self.drag(a),
151 "get" => self.get(a),
152 "wait" => self.wait(a),
153 "screenshot" => self.screenshot(a),
154 "highlight" => self.highlight(a),
155 "exec" => self.exec(a),
156 "commands" => self.commands(a),
157 "menu" => self.menu(a),
158 "logs" => self.logs(a),
159 "stats" => self.stats(a),
160 "clock" => self.clock(a),
161 "devtools" => self.devtools(a),
162 "call" => self.raw_call(a),
163 "batch" => self.batch(a),
164 "trace" => self.trace(a),
165 "demo" => self.demo(a),
166 "version" => Ok(Done::new(json!({"version": env!("CARGO_PKG_VERSION")}), format!("ashlar-debug {}", env!("CARGO_PKG_VERSION")))),
167 "help" => Ok(Done::new(json!({}), "see `ashlar-debug --help`")),
168 other => Err(Fail::Usage(format!("unknown command `{other}`"), "ashlar-debug --help".into())),
169 }
170 }
171
172 pub(crate) fn run_words(&mut self, words: &[String]) -> Res {
174 let Some((verb, rest)) = words.split_first() else { return Ok(Done::new(Value::Null, "")) };
175 self.run_verb(verb, Args::new(rest.iter().cloned()), words)
176 }
177
178 pub(crate) fn client(&mut self) -> Result<&mut Client, Fail> {
182 if self.conn.is_none() {
183 let mut c = self.connect_target()?;
184 c.set_timeout(self.timeout);
185 self.conn = Some(c);
186 }
187 self.conn.as_mut().ok_or_else(|| Fail::msg("not connected"))
188 }
189
190 fn connect_target(&mut self) -> Result<Client, Fail> {
191 if let Some(port) = self.port {
192 let dir = match (&self.key_dir, &self.env.key_dir) {
193 (Some(d), _) | (None, Some(d)) => d.clone(),
194 (None, None) => match session::load(self.env, &self.session).ok().flatten().filter(|s| s.port == port) {
195 Some(s) => s.key_dir,
196 None => {
197 session::find_keys(&self.env.config_roots, Some(port), self.app.as_deref()).into_iter().next().map(|k| k.dir).ok_or_else(
198 || Fail::Failed {
199 error: format!("no control key for port {port} in the usual settings folders"),
200 detail: None,
201 hint: Some("pass --key-dir <the app's settings folder> (where it writes control-key.local-<port>)".into()),
202 },
203 )?
204 }
205 },
206 };
207 return Client::connect(port, &dir).map_err(|e| Fail::control("app.ping", e));
208 }
209 if let Some(app) = self.app.clone() {
210 let keys = session::find_keys(&self.env.config_roots, None, Some(&app));
211 for k in &keys {
212 if let Ok(c) = session::ping(k.port, &k.dir, Duration::from_secs(3)) {
213 return Ok(c);
214 }
215 }
216 return Err(Fail::Failed {
217 error: format!("no running app matching `{app}` answers ({} key file(s) found)", keys.len()),
218 detail: None,
219 hint: Some("start it with `--control <port>`, or `ashlar-debug open <exe>`".into()),
220 });
221 }
222 let s = self.load_session()?;
223 Client::connect(s.port, &s.key_dir).map_err(|e| Fail::Failed {
224 error: format!("session `{}` (127.0.0.1:{}) does not answer: {e}", self.session, s.port),
225 detail: None,
226 hint: Some("the app quit? `ashlar-debug close` forgets the session; `open` starts a new one".into()),
227 })
228 }
229
230 fn load_session(&self) -> Result<Session, Fail> {
231 session::load(self.env, &self.session).map_err(Fail::msg)?.ok_or_else(|| Fail::Failed {
232 error: format!("no session `{}`", self.session),
233 detail: None,
234 hint: Some("start one with `ashlar-debug open <exe>` or `ashlar-debug connect <port>`".into()),
235 })
236 }
237
238 pub(crate) fn call(&mut self, method: &str, params: Value) -> Result<Value, Fail> {
240 if let Some(t) = self.inproc.as_mut() {
241 return t.call(method, params).map_err(|e| Fail::control(method, e));
242 }
243 self.client()?.call(method, params).map_err(|e| Fail::control(method, e))
244 }
245
246 fn call_for(&mut self, method: &str, params: Value, timeout: Duration) -> Result<Value, Fail> {
248 if self.inproc.is_some() {
249 return self.call(method, params);
250 }
251 let base = self.timeout;
252 let c = self.client()?;
253 c.set_timeout(timeout.max(base));
254 let r = c.call(method, params).map_err(|e| Fail::control(method, e));
255 if let Some(c) = self.conn.as_mut() {
256 c.set_timeout(base);
257 }
258 r
259 }
260
261 pub(crate) fn pause(&mut self, ms: u64) {
263 match self.inproc.as_mut() {
264 Some(t) => t.pause(ms.min(MAX_PAUSE_MS)),
265 None => std::thread::sleep(Duration::from_millis(ms.min(MAX_PAUSE_MS))),
266 }
267 }
268
269 fn act(&mut self, method: &str, params: Value, said: String) -> Res {
271 let r = self.call(method, params)?;
272 Ok(Done::new(r, said))
273 }
274
275 fn no_raise(&self) -> bool {
278 self.env.no_raise
279 || (self.port.is_none() && self.app.is_none() && session::load(self.env, &self.session).ok().flatten().is_some_and(|s| s.no_raise))
280 }
281
282 fn open(&mut self, mut a: Args) -> Res {
285 let wait = a.number::<u64>(&["--wait"]).map_err(usage("open"))?.unwrap_or(30_000);
286 let no_raise = a.flag(&["--no-raise"]);
287 let exe = need(a.positional(), "the app to start", "open")?;
288 let rest = a.rest();
289 a.finish().map_err(usage("open"))?;
290 if let Some(old) = session::load(self.env, &self.session).map_err(Fail::msg)?
291 && session::ping(old.port, &old.key_dir, Duration::from_secs(2)).is_ok()
292 {
293 return Err(Fail::Failed {
294 error: format!("session `{}` is open (127.0.0.1:{})", self.session, old.port),
295 detail: None,
296 hint: Some("`ashlar-debug close` it first, or pick another name with --session".into()),
297 });
298 }
299 let (mut s, c) = session::launch(self.env, &self.session, &exe, &rest, Duration::from_millis(wait)).map_err(Fail::msg)?;
300 s.no_raise = no_raise;
301 session::save(self.env, &self.session, &s).map_err(Fail::msg)?;
302 self.conn = Some(c);
303 let text = format!("opened {exe} (pid {}) on 127.0.0.1:{} as session `{}`", s.pid.unwrap_or(0), s.port, self.session);
304 Ok(Done::new(json!({"session": self.session, "port": s.port, "pid": s.pid, "key_dir": s.key_dir, "log": s.log}), text))
305 }
306
307 fn connect(&mut self, mut a: Args) -> Res {
308 let no_raise = a.flag(&["--no-raise"]);
309 let port = need(a.positional(), "a port", "connect")?;
310 a.finish().map_err(usage("connect"))?;
311 let port: u16 = port.parse().map_err(|_| Fail::Usage(format!("`{port}` is not a port"), usage_of("connect")))?;
312 self.port = Some(port);
313 self.conn = None;
314 let mut c = self.connect_target()?;
315 let info = c.call("app.version", json!({})).map_err(|e| Fail::control("app.version", e))?;
316 let key_dir = match (&self.key_dir, &self.env.key_dir) {
318 (Some(d), _) | (None, Some(d)) => d.clone(),
319 (None, None) => session::load(self.env, &self.session)
320 .ok()
321 .flatten()
322 .filter(|s| s.port == port)
323 .map(|s| s.key_dir)
324 .or_else(|| session::find_keys(&self.env.config_roots, Some(port), self.app.as_deref()).into_iter().next().map(|k| k.dir))
325 .unwrap_or_default(),
326 };
327 let s = Session { port, key_dir, pid: None, exe: None, log: None, launched: false, trace: None, no_raise };
328 session::save(self.env, &self.session, &s).map_err(Fail::msg)?;
329 self.conn = Some(c);
330 let name = info.get("name").and_then(Value::as_str).unwrap_or("app");
331 let version = info.get("version").and_then(Value::as_str).unwrap_or("");
332 Ok(Done::new(
333 json!({"session": self.session, "port": port, "app": info}),
334 format!("connected to {name} {version} on 127.0.0.1:{port} as session `{}`", self.session),
335 ))
336 }
337
338 fn close(&mut self, mut a: Args) -> Res {
339 let force_quit = a.flag(&["--quit"]);
340 a.finish().map_err(usage("close"))?;
341 let s = self.load_session()?;
342 let mut quit = false;
343 if s.launched || force_quit {
344 if let Ok(mut c) = Client::connect(s.port, &s.key_dir) {
346 c.set_timeout(Duration::from_secs(5));
347 let _ = c.call("app.quit", json!({"discard": true}));
348 quit = true;
349 }
350 let deadline = std::time::Instant::now() + Duration::from_secs(5);
351 let mut gone = false;
352 while std::time::Instant::now() < deadline {
353 if session::ping(s.port, &s.key_dir, Duration::from_millis(500)).is_err() {
354 gone = true;
355 break;
356 }
357 std::thread::sleep(Duration::from_millis(50));
358 }
359 if !gone && let Some(pid) = s.pid.filter(|_| s.launched) {
360 session::kill(pid);
361 }
362 }
363 self.conn = None;
364 session::remove(self.env, &self.session).map_err(Fail::msg)?;
365 let text = if quit { format!("closed session `{}` (the app quit)", self.session) } else { format!("forgot session `{}`", self.session) };
366 Ok(Done::new(json!({"session": self.session, "quit": quit}), text))
367 }
368
369 fn status(&mut self, a: Args) -> Res {
370 a.finish().map_err(usage("status"))?;
371 let s = if self.inproc.is_some() { None } else { session::load(self.env, &self.session).map_err(Fail::msg)? };
372 let info = self.call("app.version", json!({}))?;
373 let name = info.get("name").and_then(Value::as_str).unwrap_or("app");
374 let version = info.get("version").and_then(Value::as_str).unwrap_or("");
375 let methods: Vec<&str> =
376 info.get("methods").and_then(Value::as_array).map(|m| m.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
377 let port = self.inproc.as_ref().and_then(|t| t.port()).or_else(|| self.conn.as_ref().map(Client::port)).unwrap_or(0);
378 let mut text = format!("{name} {version} on 127.0.0.1:{port}");
379 if let Some(s) = &s
380 && self.port.is_none()
381 && self.app.is_none()
382 {
383 text.push_str(&format!("\nsession `{}`", self.session));
384 if let Some(pid) = s.pid {
385 text.push_str(&format!(", pid {pid}"));
386 }
387 if let Some(t) = &s.trace {
388 text.push_str(&format!(", tracing to {} ({} steps)", t.dir.display(), t.step));
389 }
390 }
391 text.push_str(&format!("\nmethods: {}", methods.join(" ")));
392 Ok(Done::new(json!({"session": self.session, "app": info, "record": s}), text))
393 }
394
395 fn sessions(&mut self, a: Args) -> Res {
396 a.finish().map_err(usage("sessions"))?;
397 let mut out = Vec::new();
398 let mut text = String::new();
399 for name in session::list(self.env) {
400 let Ok(Some(s)) = session::load(self.env, &name) else { continue };
401 let alive = session::ping(s.port, &s.key_dir, Duration::from_secs(2)).is_ok();
402 text.push_str(&format!("{name:<16} 127.0.0.1:{:<6} {}\n", s.port, if alive { "running" } else { "not answering" }));
403 out.push(json!({"name": name, "port": s.port, "pid": s.pid, "alive": alive}));
404 }
405 if text.is_empty() {
406 text = "no sessions".into();
407 }
408 Ok(Done::new(json!(out), text))
409 }
410
411 fn snapshot(&mut self, mut a: Args) -> Res {
414 let interactive = a.flag(&["-i", "--interactive"]);
415 let compact = a.flag(&["-c", "--compact"]);
416 let scope = a.value(&["--scope", "-s"]).map_err(usage("snapshot"))?;
417 let depth = a.number::<u32>(&["--depth", "-d"]).map_err(usage("snapshot"))?;
418 a.finish().map_err(usage("snapshot"))?;
419 let mut p = json!({"interactive": interactive});
420 if compact {
421 p["compact"] = json!(true);
422 }
423 if let Some(s) = scope {
424 p["scope"] = parse_target(&s, "snapshot")?;
425 }
426 if let Some(d) = depth {
427 p["depth"] = json!(d);
428 }
429 let v = self.call("ui.snapshot", p)?;
430 let text = outline(&v);
431 Ok(Done::new(v, text))
432 }
433
434 fn get(&mut self, mut a: Args) -> Res {
435 let what =
436 need(a.positional(), "what to read (text, value, name, role, rect, enabled, visible, focused, checked, selected, element)", "get")?;
437 let t = need(a.positional(), "a target", "get")?;
438 a.finish().map_err(usage("get"))?;
439 if !GET_WHAT.contains(&what.as_str()) {
440 return Err(Fail::Usage(format!("cannot get `{what}`"), usage_of("get")));
441 }
442 let v = self.call("ui.get", json!({"target": parse_target(&t, "get")?, "what": what}))?;
443 let value = v.get("value").cloned().unwrap_or(Value::Null);
444 let text = plain(&value);
445 Ok(Done::new(value, text))
446 }
447
448 fn click(&mut self, verb: &str, mut a: Args) -> Res {
451 let button = a.value(&["--button"]).map_err(usage(verb))?;
452 let count = a.number::<u32>(&["--count"]).map_err(usage(verb))?;
453 let mods = a.value(&["--mods", "--modifiers"]).map_err(usage(verb))?;
454 let t = need(a.positional(), "a target", verb)?;
455 a.finish().map_err(usage(verb))?;
456 let t = parse_target(&t, verb)?;
457 let mut p = json!({"target": t});
458 if let Some(b) = button {
459 let b = match b.as_str() {
460 "left" | "primary" => "primary",
461 "right" | "secondary" => "secondary",
462 "middle" => "middle",
463 other => return Err(Fail::Usage(format!("unknown button `{other}`"), usage_of(verb))),
464 };
465 p["button"] = json!(b);
466 }
467 let count = if verb == "dblclick" { Some(2) } else { count };
468 if let Some(n) = count {
469 if !(1..=3).contains(&n) {
470 return Err(Fail::Usage("--count is 1, 2 or 3".into(), usage_of(verb)));
471 }
472 p["count"] = json!(n);
473 }
474 if let Some(m) = mods {
475 p["modifiers"] = modifiers(&m).map_err(usage(verb))?;
476 }
477 let said = format!("{}ed {}", if verb == "dblclick" { "double-click" } else { "click" }, target::show(&t));
478 self.act("ui.click", p, said)
479 }
480
481 fn simple(&mut self, verb: &str, mut a: Args) -> Res {
483 let verb = if verb == "scroll-into-view" { "scrollintoview" } else { verb };
484 let t = need(a.positional(), "a target", verb)?;
485 a.finish().map_err(usage(verb))?;
486 let t = parse_target(&t, verb)?;
487 let (method, past) = match verb {
488 "hover" => ("ui.hover", "hovered"),
489 "focus" => ("ui.focus", "focused"),
490 _ => ("ui.scroll_into_view", "scrolled to"),
491 };
492 let said = format!("{past} {}", target::show(&t));
493 self.act(method, json!({"target": t}), said)
494 }
495
496 fn fill(&mut self, mut a: Args) -> Res {
497 let submit = a.flag(&["--submit"]);
498 let t = need(a.positional(), "a target", "fill")?;
499 let text = need(a.positional(), "the text", "fill")?;
500 a.finish().map_err(usage("fill"))?;
501 let t = parse_target(&t, "fill")?;
502 let said = format!("filled {}", target::show(&t));
503 self.act("ui.fill", json!({"target": t, "text": text, "submit": submit}), said)
504 }
505
506 fn type_text(&mut self, mut a: Args) -> Res {
507 let text = need(a.positional(), "the text", "type")?;
508 a.finish().map_err(usage("type"))?;
509 let said = format!("typed {} characters", text.chars().count());
510 self.act("ui.type", json!({"text": text}), said)
511 }
512
513 fn press(&mut self, mut a: Args) -> Res {
514 let keys = a.rest().join(" ");
515 a.finish().map_err(usage("press"))?;
516 if keys.trim().is_empty() {
517 return Err(Fail::Usage("press needs keys, like Mod+S".into(), usage_of("press")));
518 }
519 let said = format!("pressed {keys}");
520 self.act("ui.press", json!({"keys": keys}), said)
521 }
522
523 fn scroll(&mut self, mut a: Args) -> Res {
524 let dx = a.number::<f64>(&["--dx"]).map_err(usage("scroll"))?;
525 let dy = a.number::<f64>(&["--dy"]).map_err(usage("scroll"))?;
526 let mut words = a.rest();
527 a.finish().map_err(usage("scroll"))?;
528 let dirs = ["up", "down", "left", "right"];
529 let t = match words.first() {
530 Some(w) if !dirs.contains(&w.as_str()) && w.parse::<f64>().is_err() => Some(parse_target(&words.remove(0), "scroll")?),
531 _ => None,
532 };
533 let (mut x, mut y) = (dx.unwrap_or(0.0), dy.unwrap_or(0.0));
534 match words.as_slice() {
535 [] => {
536 if dx.is_none() && dy.is_none() {
537 y = 100.0;
538 }
539 }
540 [d] | [d, _] if dirs.contains(&d.as_str()) => {
541 let px = match words.get(1) {
542 Some(n) => n
543 .parse::<f64>()
544 .ok()
545 .filter(|n| n.is_finite())
546 .ok_or_else(|| Fail::Usage(format!("`{n}` is not a distance"), usage_of("scroll")))?,
547 None => 100.0,
548 };
549 match d.as_str() {
550 "up" => y = -px,
551 "down" => y = px,
552 "left" => x = -px,
553 _ => x = px,
554 }
555 }
556 _ => return Err(Fail::Usage(format!("unexpected `{}`", words.join(" ")), usage_of("scroll"))),
557 }
558 let mut p = json!({"dx": x, "dy": y});
559 if let Some(t) = &t {
560 p["target"] = t.clone();
561 }
562 let said = format!("scrolled by {x},{y}{}", t.as_ref().map(|t| format!(" over {}", target::show(t))).unwrap_or_default());
563 self.act("ui.scroll", p, said)
564 }
565
566 fn drag(&mut self, mut a: Args) -> Res {
567 let steps = a.number::<u32>(&["--steps"]).map_err(usage("drag"))?;
568 let from = need(a.positional(), "where to start", "drag")?;
569 let to = need(a.positional(), "where to drop", "drag")?;
570 a.finish().map_err(usage("drag"))?;
571 let (from, to) = (parse_target(&from, "drag")?, parse_target(&to, "drag")?);
572 let mut p = json!({"from": from, "to": to});
573 if let Some(s) = steps {
574 p["steps"] = json!(s.clamp(1, 1000));
575 }
576 let said = format!("dragged {} to {}", target::show(&from), target::show(&to));
577 self.act("ui.drag", p, said)
578 }
579
580 fn highlight(&mut self, mut a: Args) -> Res {
581 let ms = a.number::<u64>(&["--ms"]).map_err(usage("highlight"))?;
582 let t = need(a.positional(), "a target", "highlight")?;
583 a.finish().map_err(usage("highlight"))?;
584 let t = parse_target(&t, "highlight")?;
585 let mut p = json!({"target": t});
586 if let Some(ms) = ms {
587 p["ms"] = json!(ms);
588 }
589 let said = format!("highlighted {}", target::show(&t));
590 self.act("ui.highlight", p, said)
591 }
592
593 fn wait(&mut self, mut a: Args) -> Res {
594 let timeout = a.number::<u64>(&["--timeout", "-t"]).map_err(usage("wait"))?.unwrap_or(5000);
595 let text = a.value(&["--text"]).map_err(usage("wait"))?;
596 let gone = a.value(&["--gone"]).map_err(usage("wait"))?;
597 let enabled = a.value(&["--enabled"]).map_err(usage("wait"))?;
598 let element = a.value(&["--ref", "--element"]).map_err(usage("wait"))?;
599 let idle = a.flag(&["--idle"]);
600 let pos = a.positional();
601 a.finish().map_err(usage("wait"))?;
602 if let Some(ms) = pos.as_deref().and_then(|p| p.parse::<u64>().ok()) {
604 if text.is_some() || gone.is_some() || enabled.is_some() || element.is_some() || idle {
605 return Err(Fail::Usage("wait takes a duration or a condition, not both".into(), usage_of("wait")));
606 }
607 self.pause(ms);
608 return Ok(Done::new(json!({"elapsed_ms": ms}), format!("waited {ms} ms")));
609 }
610 let element = element.or(pos);
611 let mut p = match (text, gone, enabled, element, idle) {
612 (Some(t), None, None, None, false) => json!({"for": "text", "text": t}),
613 (None, Some(g), None, None, false) => json!({"for": "gone", "target": parse_target(&g, "wait")?}),
614 (None, None, Some(e), None, false) => json!({"for": "enabled", "target": parse_target(&e, "wait")?}),
615 (None, None, None, Some(e), false) => json!({"for": "element", "target": parse_target(&e, "wait")?}),
616 (None, None, None, None, true) => json!({"for": "idle"}),
617 (None, None, None, None, false) => return Err(Fail::Usage("wait for what?".into(), usage_of("wait"))),
618 _ => return Err(Fail::Usage("wait for one condition at a time".into(), usage_of("wait"))),
619 };
620 p["timeout_ms"] = json!(timeout);
621 let v = self.call_for("ui.wait", p, Duration::from_millis(timeout.saturating_add(10_000)))?;
622 let ms = v.get("elapsed_ms").and_then(Value::as_u64).unwrap_or(0);
623 Ok(Done::new(v, format!("waited {ms} ms")))
624 }
625
626 fn screenshot(&mut self, mut a: Args) -> Res {
627 let annotate = a.flag(&["--annotate", "-a"]);
628 let no_raise = a.flag(&["--no-raise"]);
629 let tgt = a.value(&["--target", "--ref"]).map_err(usage("screenshot"))?;
630 let highlight = a.value(&["--highlight"]).map_err(usage("screenshot"))?;
631 let pad = a.number::<f64>(&["--pad"]).map_err(usage("screenshot"))?;
632 let path = a.positional();
633 a.finish().map_err(usage("screenshot"))?;
634 let mut p = json!({});
635 if annotate {
636 p["annotate"] = json!(true);
637 }
638 if no_raise || self.no_raise() {
639 p["raise"] = json!(false);
640 }
641 if let Some(t) = tgt {
642 p["target"] = parse_target(&t, "screenshot")?;
643 }
644 if let Some(h) = highlight {
645 p["highlight"] = parse_target(&h, "screenshot")?;
646 }
647 if let Some(pad) = pad {
648 p["pad"] = json!(pad);
649 }
650 let v = self.call("ui.screenshot", p)?;
651 let path = path.map(PathBuf::from).unwrap_or_else(|| self.env.home.join("screenshots").join(format!("shot-{}.png", unix_ms())));
652 save_png(&v, &path).map_err(Fail::msg)?;
653 let mut text = path.display().to_string();
654 let annotations = v.get("annotations").cloned().unwrap_or(Value::Null);
655 for an in annotations.as_array().into_iter().flatten() {
656 let r = an.get("ref").and_then(Value::as_str).unwrap_or("?");
657 let rect = an.get("rect").map(|r| r.to_string()).unwrap_or_default();
658 text.push_str(&format!("\n@{r} {rect}"));
659 }
660 let mut result = json!({"path": path, "width": v.get("width"), "height": v.get("height")});
661 if !annotations.is_null() {
662 result["annotations"] = annotations;
663 }
664 Ok(Done::new(result, text))
665 }
666
667 fn exec(&mut self, mut a: Args) -> Res {
670 let id = need(a.positional(), "a command id", "exec")?;
671 let raw = a.positional();
672 a.finish().map_err(usage("exec"))?;
673 let params = json_arg(raw, self.io.input).map_err(usage("exec"))?;
674 let v = self.call("engine.execute", json!({"command": id, "params": params}))?;
675 let text = plain(&v);
676 Ok(Done::new(v, text))
677 }
678
679 fn commands(&mut self, mut a: Args) -> Res {
680 let q = a.rest().join(" ");
681 a.finish().map_err(usage("commands"))?;
682 let v = self.call("engine.commands", json!({"query": q}))?;
683 let list: Vec<CommandInfo> = serde_json::from_value(v.clone()).unwrap_or_default();
684 let width = list.iter().map(|c| c.id.chars().count()).max().unwrap_or(0);
685 let mut text = String::new();
686 for c in &list {
687 let mut line = format!("{:<width$} {}", c.id, c.label);
688 if let Some(s) = &c.shortcut {
689 line.push_str(&format!(" [{s}]"));
690 }
691 if !c.enabled {
692 line.push_str(&format!(" (disabled: {})", c.disabled_reason.as_deref().unwrap_or("not available")));
693 }
694 text.push_str(line.trim_end());
695 text.push('\n');
696 }
697 Ok(Done::new(v, text))
698 }
699
700 fn menu(&mut self, mut a: Args) -> Res {
701 let id = a.positional();
702 a.finish().map_err(usage("menu"))?;
703 if let Some(id) = id {
704 let v = self.call("ui.menu.invoke", json!({"id": id}))?;
705 return Ok(Done::new(v, format!("invoked {id}")));
706 }
707 let v = self.call("ui.menu.list", json!({}))?;
708 let mut text = String::new();
709 menu_lines(&v, 0, &mut text);
710 Ok(Done::new(v, text))
711 }
712
713 fn logs(&mut self, mut a: Args) -> Res {
714 let lines = a.number::<u64>(&["--lines", "-n"]).map_err(usage("logs"))?.unwrap_or(50);
715 let level = a.value(&["--level"]).map_err(usage("logs"))?;
716 a.finish().map_err(usage("logs"))?;
717 let mut p = json!({"limit": lines});
718 if let Some(l) = &level {
719 p["level"] = json!(l);
720 }
721 match self.call("app.logs", p) {
722 Ok(v) => {
723 let mut text = String::new();
724 for l in v.get("lines").and_then(Value::as_array).into_iter().flatten() {
725 let s = |k: &str| l.get(k).and_then(Value::as_str).unwrap_or("");
726 text.push_str(&format!("{:<5} {}: {}\n", s("level"), s("target"), s("message")));
727 }
728 Ok(Done::new(v, text))
729 }
730 Err(Fail::Failed { error, .. }) if error.starts_with("unknown method") => {
732 let log = session::load(self.env, &self.session).ok().flatten().and_then(|s| s.log);
733 match log {
734 Some(p) => {
735 let t = session::tail(&p, lines.min(10_000) as usize);
736 Ok(Done::new(json!({"file": p, "text": t}), t))
737 }
738 None => Err(Fail::msg("this app has no `app.logs`, and its output was not captured (it was not started with `open`)")),
739 }
740 }
741 Err(e) => Err(e),
742 }
743 }
744
745 fn stats(&mut self, mut a: Args) -> Res {
746 let reset = a.flag(&["--reset"]);
747 a.finish().map_err(usage("stats"))?;
748 let v = self.call("app.stats", if reset { json!({"reset": true}) } else { json!({}) })?;
749 let text = serde_json::to_string_pretty(&v).unwrap_or_default();
750 Ok(Done::new(v, text))
751 }
752
753 fn clock(&mut self, mut a: Args) -> Res {
754 let what = a.positional();
755 let params = match what.as_deref() {
756 None => json!({}),
757 Some("pause") => json!({"pause": true}),
758 Some("resume") => json!({"resume": true}),
759 Some("step") => {
760 let ms = a.positional().ok_or_else(|| Fail::Usage("clock step needs milliseconds".into(), usage_of("clock")))?;
761 let ms: f64 = ms.parse().map_err(|_| Fail::Usage(format!("clock step {ms}: not a number of milliseconds"), usage_of("clock")))?;
762 json!({"step_ms": ms})
763 }
764 Some(other) => return Err(Fail::Usage(format!("clock {other}?"), usage_of("clock"))),
765 };
766 a.finish().map_err(usage("clock"))?;
767 let v = self.call(ashlar_control::APP_CLOCK, params)?;
768 let paused = v.get("paused").and_then(Value::as_bool).unwrap_or(false);
769 let time = v.get("time").and_then(Value::as_f64).unwrap_or(0.0);
770 let text = format!("{} at {time:.3}s", if paused { "paused" } else { "running" });
771 Ok(Done::new(v, text))
772 }
773
774 fn devtools(&mut self, mut a: Args) -> Res {
775 let what = a.positional().unwrap_or_else(|| "toggle".into());
776 a.finish().map_err(usage("devtools"))?;
777 let v = match what.as_str() {
778 "open" => self.call("devtools.toggle", json!({"open": true}))?,
779 "close" => self.call("devtools.toggle", json!({"open": false}))?,
780 "toggle" => self.call("devtools.toggle", json!({}))?,
781 "state" => self.call("devtools.state", json!({}))?,
782 other => return Err(Fail::Usage(format!("devtools {other}?"), usage_of("devtools"))),
783 };
784 let text = match v.get("open").and_then(Value::as_bool) {
785 Some(true) => "devtools open".to_string(),
786 Some(false) => "devtools closed".to_string(),
787 None => v.to_string(),
788 };
789 Ok(Done::new(v, text))
790 }
791
792 fn raw_call(&mut self, mut a: Args) -> Res {
793 let method = need(a.positional(), "a method", "call")?;
794 let raw = a.positional();
795 a.finish().map_err(usage("call"))?;
796 let params = json_arg(raw, self.io.input).map_err(usage("call"))?;
797 let v = self.call(&method, params)?;
798 let text = serde_json::to_string_pretty(&v).unwrap_or_default();
799 Ok(Done::new(v, text))
800 }
801
802 fn demo(&mut self, mut a: Args) -> Res {
803 let port = a.number::<u16>(&["--control"]).map_err(usage("demo"))?;
804 let _ = a.rest();
806 let key_dir = self.key_dir.clone().or_else(|| self.env.key_dir.clone());
807 crate::demo::run(port, key_dir.as_deref(), self.io.out).map_err(Fail::msg)?;
808 Ok(Done::new(json!({}), ""))
809 }
810
811 fn direct(&self) -> bool {
815 self.port.is_some() || self.app.is_some()
816 }
817
818 fn trace(&mut self, mut a: Args) -> Res {
819 let what = need(a.positional(), "start <dir> or stop", "trace")?;
820 let dir = a.positional();
821 a.finish().map_err(usage("trace"))?;
822 if self.direct() && !self.in_batch {
823 return Err(Fail::Usage(
824 "with --port or --app a trace runs only inside `batch` (put `trace start <dir>` at the top of the script), \
825 or start a session with `connect <port>`"
826 .into(),
827 usage_of("trace"),
828 ));
829 }
830 let mut s = if self.direct() { None } else { Some(self.load_session()?) };
831 match (what.as_str(), dir) {
832 ("start", Some(d)) => {
833 let d = PathBuf::from(d);
834 std::fs::create_dir_all(&d).map_err(|e| Fail::msg(format!("cannot create {}: {e}", d.display())))?;
835 let d = d.canonicalize().unwrap_or(d);
836 let t = Trace { dir: d.clone(), step: 0 };
837 match s.as_mut() {
838 Some(s) => {
839 s.trace = Some(t);
840 session::save(self.env, &self.session, s).map_err(Fail::msg)?;
841 }
842 None => self.batch_trace = Some(t),
843 }
844 Ok(Done::new(json!({"dir": d}), format!("tracing to {}", d.display())))
845 }
846 ("stop", None) => {
847 let running = match s.as_mut() {
848 Some(s) => s.trace.take(),
849 None => self.batch_trace.take(),
850 };
851 let t = running.ok_or_else(|| Fail::msg("no trace is running"))?;
852 if let Some(s) = &s {
853 session::save(self.env, &self.session, s).map_err(Fail::msg)?;
854 }
855 Ok(Done::new(json!({"dir": t.dir, "steps": t.step}), format!("{} steps in {}", t.step, t.dir.display())))
856 }
857 _ => Err(Fail::Usage("trace start <dir> | trace stop".into(), usage_of("trace"))),
858 }
859 }
860
861 pub(crate) fn record(&mut self, verb: &str, words: &[String], outcome: &Res) {
865 let mut problems = Vec::new();
866 if self.direct() {
867 let Some(mut t) = self.batch_trace.take() else { return };
868 self.record_step(&mut t, verb, words, outcome, &mut problems);
869 self.batch_trace = Some(t);
870 } else {
871 let Ok(Some(mut s)) = session::load(self.env, &self.session) else { return };
872 let Some(mut t) = s.trace.take() else { return };
873 self.record_step(&mut t, verb, words, outcome, &mut problems);
874 s.trace = Some(t);
875 if let Err(e) = session::save(self.env, &self.session, &s) {
876 problems.push(e);
877 }
878 }
879 for p in problems {
880 let _ = writeln!(self.io.err, "trace: {p}");
881 }
882 }
883
884 fn record_step(&mut self, t: &mut Trace, verb: &str, words: &[String], outcome: &Res, problems: &mut Vec<String>) {
885 t.step = t.step.saturating_add(1);
886 let (n, dir) = (t.step, t.dir.clone());
887 let base = format!("{n:03}-{verb}");
888 let png = dir.join(format!("{base}.png"));
889 let shot = if self.no_raise() { json!({"raise": false}) } else { json!({}) };
890 match self.call("ui.screenshot", shot).map_err(|e| e.text()).and_then(|v| save_png(&v, &png)) {
891 Ok(()) => {}
892 Err(e) => problems.push(format!("screenshot: {e}")),
893 }
894 let txt = dir.join(format!("{base}.txt"));
895 match self.call("ui.snapshot", json!({})).map_err(|e| e.text()) {
896 Ok(v) => {
897 if let Err(e) = std::fs::write(&txt, outline(&v) + "\n") {
898 problems.push(format!("snapshot: {e}"));
899 }
900 }
901 Err(e) => problems.push(format!("snapshot: {e}")),
902 }
903 let mut line = json!({
904 "step": n,
905 "command": words.iter().map(|w| quote(w)).collect::<Vec<_>>().join(" "),
906 "ok": outcome.is_ok(),
907 "png": png.file_name().map(|f| f.to_string_lossy().to_string()),
908 "snapshot": txt.file_name().map(|f| f.to_string_lossy().to_string()),
909 "unix_ms": unix_ms() as u64,
910 });
911 if let Err(e) = outcome {
912 line["error"] = json!(e.text());
913 }
914 let appended = std::fs::OpenOptions::new().create(true).append(true).open(dir.join("steps.jsonl")).and_then(|mut f| writeln!(f, "{line}"));
915 if let Err(e) = appended {
916 problems.push(format!("steps.jsonl: {e}"));
917 }
918 }
919}
920
921pub fn save_png(v: &Value, path: &Path) -> Result<(), String> {
924 let b64 = v.get("png").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or("the reply carried no picture")?;
925 let bytes = base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| format!("the picture is not base64: {e}"))?;
926 if let Some(d) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
927 std::fs::create_dir_all(d).map_err(|e| format!("cannot create {}: {e}", d.display()))?;
928 }
929 std::fs::write(path, bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))
930}
931
932fn menu_lines(v: &Value, depth: usize, out: &mut String) {
934 let items = match v {
935 Value::Array(a) => a.clone(),
936 Value::Object(o) => o.get("menus").or(o.get("items")).and_then(Value::as_array).cloned().unwrap_or_default(),
937 _ => Vec::new(),
938 };
939 for it in items.iter().take(10_000) {
940 let s = |k: &str| it.get(k).and_then(Value::as_str);
941 let label = s("label").or(s("title")).or(s("name")).unwrap_or("?");
942 let mut line = format!("{}{label}", " ".repeat(depth));
943 if let Some(id) = s("id") {
944 line.push_str(&format!(" ({id})"));
945 }
946 if let Some(sc) = s("shortcut") {
947 line.push_str(&format!(" [{sc}]"));
948 }
949 if it.get("enabled") == Some(&json!(false)) {
950 line.push_str(" (disabled)");
951 }
952 out.push_str(&line);
953 out.push('\n');
954 if let Some(children) = it.get("items").or(it.get("children")).filter(|c| c.is_array())
955 && depth < 16
956 {
957 menu_lines(children, depth.saturating_add(1), out);
958 }
959 }
960}
961
962#[cfg(test)]
963mod tests {
964 use super::*;
965
966 #[test]
967 fn modifier_names() {
968 assert_eq!(modifiers("Shift+Mod").unwrap(), json!({"shift": true, "command": true}));
969 assert_eq!(modifiers("alt+ctrl").unwrap(), json!({"alt": true, "ctrl": true}));
970 assert!(modifiers("Hyper").is_err());
971 }
972
973 #[test]
974 fn outline_from_elements_when_there_is_no_tree() {
975 let v = json!({"elements": [
976 {"ref": "e1", "role": "window", "name": "W"},
977 {"ref": "e2", "role": "button", "name": "Save", "parent": "e1", "enabled": false}
978 ]});
979 assert_eq!(outline(&v), "- window \"W\" [ref=e1]\n - button \"Save\" [ref=e2] [disabled]");
980 assert_eq!(outline(&json!({"tree": "- x\n"})), "- x");
981 }
982
983 #[test]
984 fn menus_print_as_a_tree() {
985 let mut s = String::new();
986 menu_lines(&json!([{"label": "File", "items": [{"label": "Save", "id": "file.save", "shortcut": "Mod+S", "enabled": false}]}]), 0, &mut s);
987 assert_eq!(s, "File\n Save (file.save) [Mod+S] (disabled)\n");
988 }
989}