Skip to main content

ashlar_debug/
verbs.rs

1//! The commands: each parses its arguments, makes one or two control calls, and formats the result.
2
3use 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
17/// What `get` reads (`ui.get {what}`).
18const GET_WHAT: &[&str] = &["text", "value", "name", "role", "rect", "enabled", "visible", "focused", "checked", "selected", "element"];
19
20/// Verbs that change the UI: a trace records after each.
21const ACTIONS: &[&str] = &["click", "dblclick", "hover", "focus", "fill", "type", "press", "scroll", "scrollintoview", "drag", "exec", "menu"];
22/// Verbs about sessions, processes and traces: nothing to do on an in-process target.
23const 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
29/// Read a JSON argument: inline text, `-` for stdin, absent for `{}`.
30fn 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
46/// A value for humans: strings as they are, everything else as compact JSON.
47fn 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
55/// The outline of a snapshot: the app's `tree`, or one built from `elements`.
56pub(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
99/// `--mods Shift+Mod` as the protocol's modifier object.
100fn 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    /// Run one verb with its arguments; in a traced session, record after an action. `shown` is
117    /// the command as written (for the trace).
118    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    /// Run one verb given as words (batch lines).
173    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    // ------------------------------------------------------------------ connection
179
180    /// The connection, made on first use.
181    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    /// Call one method; the app's error becomes a [`Fail`] with a hint.
239    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    /// Call with a longer reply timeout (waits).
247    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    /// Let `ms` pass: virtual time on an in-process transport, the wall clock otherwise.
262    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    /// `{}`-returning action with a confirmation line.
270    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    /// Whether screenshots must not raise the window: `ASHLAR_DEBUG_NO_RAISE`, or a session opened
276    /// with `--no-raise`.
277    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    // ------------------------------------------------------------------ sessions
283
284    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        // The key folder that worked, for the next invocations.
317        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            // The app may close the connection as it quits: that is success too.
345            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    // ------------------------------------------------------------------ reading the UI
412
413    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    // ------------------------------------------------------------------ acting
449
450    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    /// `hover`, `focus`, `scrollintoview`: a target and nothing else.
482    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        // `wait 500`: just sleep.
603        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    // ------------------------------------------------------------------ the app
668
669    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            // An app without `app.logs`: what `open` captured of its output.
731            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        // An app ignores arguments it does not know; so does the demo (it is launched like one).
805        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    // ------------------------------------------------------------------ trace
812
813    /// `--port`/`--app`: no session file, so a trace lives in the running batch.
814    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    /// After an action while a trace runs (in the session, or in this batch with `--port`/`--app`):
862    /// `NNN-verb.png`, `NNN-verb.txt` (the snapshot) and a line in `steps.jsonl`. Problems are
863    /// reported on stderr; the action's outcome stands.
864    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
921/// Decode a `ui.screenshot` reply's PNG and write it.
922/// Write the PNG in a `ui.screenshot` reply (`{"png": base64}`) to `path`, making its folder.
923pub 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
932/// A menu tree as indented lines (any `[{label|title, items|children, shortcut?, enabled?, id?}]`).
933fn 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}