Skip to main content

ashlar_debug/
session.rs

1//! Sessions: which app successive commands talk to, kept in a small file between invocations; the
2//! control-key discovery that lets `connect` and `--app` find a running app; and `open`, which
3//! launches one.
4//!
5//! Layout under the state folder ([`Env::home`]):
6//!
7//! ```text
8//! sessions/<name>.json        {port, key_dir, pid?, exe?, log?, launched, trace?}
9//! sessions/<name>/key/        the key folder handed to a launched app (0700)
10//! sessions/<name>/app.log     a launched app's stdout and stderr
11//! screenshots/                default place for `screenshot` without a path
12//! ```
13
14use std::path::{Path, PathBuf};
15use std::process::{Command, Stdio};
16use std::time::{Duration, Instant, SystemTime};
17
18use ashlar_control::Client;
19use serde::{Deserialize, Serialize};
20use serde_json::json;
21
22/// The environment variable a launched app reads to know where to write its control key.
23pub use ashlar_control::key::KEY_DIR_ENV;
24
25/// Where state lives and where apps keep their settings. [`Env::from_os`] for the real thing; tests
26/// point both somewhere private.
27#[derive(Debug, Clone)]
28pub struct Env {
29    /// State folder: sessions and default screenshots.
30    pub home: PathBuf,
31    /// Folders searched (a few levels deep) for `control-key.local-<port>` files.
32    pub config_roots: Vec<PathBuf>,
33    /// The session used when `--session` is not given.
34    pub session: String,
35    /// `ASHLAR_CONTROL_KEY_DIR`, if set: the key folder for `connect` / `--port` without `--key-dir`.
36    pub key_dir: Option<PathBuf>,
37    /// `ASHLAR_DEBUG_NO_RAISE`: screenshots never raise a covered window (automated runs must not
38    /// steal focus from the person at the machine).
39    pub no_raise: bool,
40}
41
42impl Env {
43    /// `ASHLAR_DEBUG_HOME` (or the user cache folder + `ashlar-debug`), the OS settings folders,
44    /// `ASHLAR_DEBUG_SESSION` (or `default`), `ASHLAR_DEBUG_NO_RAISE`.
45    pub fn from_os() -> Self {
46        let var = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty());
47        let base = directories::BaseDirs::new();
48        let home = var("ASHLAR_DEBUG_HOME").map(PathBuf::from).unwrap_or_else(|| {
49            base.as_ref().map(|b| b.cache_dir().join("ashlar-debug")).unwrap_or_else(|| std::env::temp_dir().join("ashlar-debug"))
50        });
51        let mut config_roots: Vec<PathBuf> = Vec::new();
52        if let Some(b) = &base {
53            for d in [b.config_dir(), b.config_local_dir(), b.preference_dir(), b.data_dir()] {
54                if !config_roots.iter().any(|r| r == d) {
55                    config_roots.push(d.to_path_buf());
56                }
57            }
58        }
59        let session = var("ASHLAR_DEBUG_SESSION").and_then(|s| s.into_string().ok()).unwrap_or_else(|| "default".into());
60        let no_raise = var("ASHLAR_DEBUG_NO_RAISE").is_some_and(|v| truthy(&v.to_string_lossy()));
61        Self { home, config_roots, session, key_dir: var(KEY_DIR_ENV).map(PathBuf::from), no_raise }
62    }
63
64    /// The folders of `from_os` under `root` instead (tests): state in `<root>/home`, settings
65    /// searched in `<root>/config`, nothing taken from the environment.
66    pub fn under(root: &Path) -> Self {
67        Self { home: root.join("home"), config_roots: vec![root.join("config")], session: "default".into(), key_dir: None, no_raise: false }
68    }
69
70    pub fn sessions_dir(&self) -> PathBuf {
71        self.home.join("sessions")
72    }
73
74    pub fn session_file(&self, name: &str) -> PathBuf {
75        self.sessions_dir().join(format!("{name}.json"))
76    }
77
78    /// The folder holding a launched app's key folder and log.
79    pub fn session_dir(&self, name: &str) -> PathBuf {
80        self.sessions_dir().join(name)
81    }
82}
83
84/// An environment flag's value: anything but empty, `0`, `false`, `no` or `off` turns it on.
85pub fn truthy(v: &str) -> bool {
86    !matches!(v.trim().to_ascii_lowercase().as_str(), "" | "0" | "false" | "no" | "off")
87}
88
89/// A trace being recorded: a screenshot and a snapshot after every action.
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct Trace {
92    pub dir: PathBuf,
93    /// Steps recorded so far.
94    pub step: u32,
95}
96
97/// What a session remembers.
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99pub struct Session {
100    pub port: u16,
101    pub key_dir: PathBuf,
102    /// The app's process, when `open` started it.
103    #[serde(default)]
104    pub pid: Option<u32>,
105    #[serde(default)]
106    pub exe: Option<String>,
107    /// Where a launched app's output goes.
108    #[serde(default)]
109    pub log: Option<PathBuf>,
110    /// Started by `open` (so `close` quits it), not found by `connect`.
111    #[serde(default)]
112    pub launched: bool,
113    #[serde(default)]
114    pub trace: Option<Trace>,
115    /// Screenshots in this session never raise the window (`open --no-raise`).
116    #[serde(default)]
117    pub no_raise: bool,
118}
119
120/// A session name: `[A-Za-z0-9_-]{1,64}` (it becomes a file name).
121pub fn check_name(name: &str) -> Result<(), String> {
122    if name.is_empty() || name.len() > 64 || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
123        return Err(format!("session name `{name}`: use 1–64 letters, digits, `_` or `-`"));
124    }
125    Ok(())
126}
127
128/// The session `name`, if one is recorded.
129pub fn load(env: &Env, name: &str) -> Result<Option<Session>, String> {
130    check_name(name)?;
131    let p = env.session_file(name);
132    let text = match std::fs::read(&p) {
133        Ok(t) => t,
134        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
135        Err(e) => return Err(format!("cannot read {}: {e}", p.display())),
136    };
137    if text.len() > 1024 * 1024 {
138        return Err(format!("{} is not a session file (too large)", p.display()));
139    }
140    serde_json::from_slice(&text).map(Some).map_err(|e| format!("{} is damaged ({e}); `close` it or delete the file", p.display()))
141}
142
143/// Record a session (written to a temporary file, then renamed, so a reader never sees half).
144pub fn save(env: &Env, name: &str, s: &Session) -> Result<(), String> {
145    check_name(name)?;
146    let dir = env.sessions_dir();
147    std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
148    let p = env.session_file(name);
149    let tmp = dir.join(format!(".{name}.json.{}", std::process::id()));
150    let text = serde_json::to_vec_pretty(s).map_err(|e| e.to_string())?;
151    std::fs::write(&tmp, text).map_err(|e| format!("cannot write {}: {e}", tmp.display()))?;
152    std::fs::rename(&tmp, &p).map_err(|e| {
153        let _ = std::fs::remove_file(&tmp);
154        format!("cannot write {}: {e}", p.display())
155    })
156}
157
158/// Forget a session: its file, and the folder `open` made for it.
159pub fn remove(env: &Env, name: &str) -> Result<(), String> {
160    check_name(name)?;
161    let _ = std::fs::remove_dir_all(env.session_dir(name));
162    match std::fs::remove_file(env.session_file(name)) {
163        Ok(()) => Ok(()),
164        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
165        Err(e) => Err(format!("cannot remove the session file: {e}")),
166    }
167}
168
169/// Every recorded session name, sorted.
170pub fn list(env: &Env) -> Vec<String> {
171    let Ok(rd) = std::fs::read_dir(env.sessions_dir()) else { return Vec::new() };
172    let mut names: Vec<String> = rd
173        .filter_map(Result::ok)
174        .filter_map(|e| e.file_name().to_str().and_then(|n| n.strip_suffix(".json")).map(String::from))
175        .filter(|n| check_name(n).is_ok())
176        .collect();
177    names.sort();
178    names
179}
180
181/// A control key file found on disk.
182#[derive(Debug, Clone, PartialEq)]
183pub struct FoundKey {
184    pub port: u16,
185    /// The folder holding it (what [`Client::connect`] takes).
186    pub dir: PathBuf,
187    pub modified: SystemTime,
188}
189
190const KEY_PREFIX: &str = "control-key.local-";
191/// How deep under a settings root a key may sit (`<root>/<org>/<app>/config/control-key…`).
192const SCAN_DEPTH: usize = 4;
193/// Most directory entries looked at in one scan (a huge settings folder cannot stall a command).
194const SCAN_BUDGET: usize = 50_000;
195
196/// Key files under `roots`, newest first: only for `port` when given, only in folders whose path
197/// contains `app` (case-insensitive) when given.
198pub fn find_keys(roots: &[PathBuf], port: Option<u16>, app: Option<&str>) -> Vec<FoundKey> {
199    let mut found = Vec::new();
200    let mut budget = SCAN_BUDGET;
201    for r in roots {
202        scan(r, SCAN_DEPTH, &mut budget, &mut found);
203    }
204    let app = app.map(str::to_lowercase);
205    found.retain(|k| port.is_none_or(|p| p == k.port) && app.as_deref().is_none_or(|a| k.dir.to_string_lossy().to_lowercase().contains(a)));
206    found.sort_by_key(|k| std::cmp::Reverse(k.modified));
207    found.dedup_by(|a, b| a.port == b.port && a.dir == b.dir);
208    found
209}
210
211fn scan(dir: &Path, depth: usize, budget: &mut usize, out: &mut Vec<FoundKey>) {
212    let Ok(rd) = std::fs::read_dir(dir) else { return };
213    for e in rd.filter_map(Result::ok) {
214        if *budget == 0 {
215            return;
216        }
217        *budget = budget.saturating_sub(1);
218        let Ok(ft) = e.file_type() else { continue };
219        let name = e.file_name();
220        let name = name.to_string_lossy();
221        if ft.is_file() {
222            if let Some(port) = name.strip_prefix(KEY_PREFIX).and_then(|p| p.parse::<u16>().ok()) {
223                let modified = e.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
224                out.push(FoundKey { port, dir: dir.to_path_buf(), modified });
225            }
226        } else if ft.is_dir() && depth > 0 && !name.starts_with('.') {
227            scan(&e.path(), depth.saturating_sub(1), budget, out);
228        }
229    }
230}
231
232/// Connect and check the app answers `app.ping`.
233pub fn ping(port: u16, key_dir: &Path, timeout: Duration) -> Result<Client, String> {
234    let mut c = Client::connect(port, key_dir).map_err(|e| e.to_string())?;
235    c.set_timeout(timeout);
236    c.call("app.ping", json!({})).map_err(|e| e.to_string())?;
237    Ok(c)
238}
239
240/// A port nobody listens on right now (the OS picks it).
241pub fn free_port() -> Result<u16, String> {
242    let l = std::net::TcpListener::bind(("127.0.0.1", 0)).map_err(|e| format!("cannot find a free port: {e}"))?;
243    l.local_addr().map(|a| a.port()).map_err(|e| e.to_string())
244}
245
246/// The last `n` lines of a file (at most the last 64 KiB are read).
247pub fn tail(p: &Path, n: usize) -> String {
248    use std::io::{Read, Seek, SeekFrom};
249    let Ok(mut f) = std::fs::File::open(p) else { return String::new() };
250    let len = f.metadata().map(|m| m.len()).unwrap_or(0);
251    let _ = f.seek(SeekFrom::Start(len.saturating_sub(64 * 1024)));
252    let mut buf = Vec::new();
253    let _ = f.take(64 * 1024).read_to_end(&mut buf);
254    let text = String::from_utf8_lossy(&buf);
255    let lines: Vec<&str> = text.lines().collect();
256    lines.get(lines.len().saturating_sub(n)..).unwrap_or(&[]).join("\n")
257}
258
259fn private_dir(p: &Path) -> Result<(), String> {
260    let mut b = std::fs::DirBuilder::new();
261    b.recursive(true);
262    #[cfg(unix)]
263    std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
264    b.create(p).map_err(|e| format!("cannot create {}: {e}", p.display()))
265}
266
267/// Start `exe args… --control <port>` with its key folder in the session folder and its output in
268/// the session log, and wait until it answers `app.ping` (or exits, or `wait` passes). An app that
269/// ignores `ASHLAR_CONTROL_KEY_DIR` is still found: its key is looked for in the settings folders.
270pub fn launch(env: &Env, name: &str, exe: &str, args: &[String], wait: Duration) -> Result<(Session, Client), String> {
271    check_name(name)?;
272    let dir = env.session_dir(name);
273    let _ = std::fs::remove_dir_all(&dir);
274    let key_dir = dir.join("key");
275    private_dir(&key_dir)?;
276    let log_path = dir.join("app.log");
277    let log = std::fs::File::create(&log_path).map_err(|e| format!("cannot create {}: {e}", log_path.display()))?;
278    let log2 = log.try_clone().map_err(|e| e.to_string())?;
279    let port = free_port()?;
280    let mut cmd = Command::new(exe);
281    cmd.args(args).arg("--control").arg(port.to_string()).env(KEY_DIR_ENV, &key_dir).stdin(Stdio::null()).stdout(log).stderr(log2);
282    // Its own process group: Ctrl-C in this terminal does not reach the app we leave running.
283    #[cfg(unix)]
284    std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
285    let mut child = cmd.spawn().map_err(|e| format!("cannot start {exe}: {e}"))?;
286    let started = Instant::now();
287    let mut last = String::from("no control key yet");
288    loop {
289        if let Ok(Some(status)) = child.try_wait() {
290            let out = tail(&log_path, 20);
291            let out = if out.is_empty() { String::new() } else { format!("; its last output:\n{out}") };
292            return Err(format!("{exe} exited ({status}) before answering on its control port{out}"));
293        }
294        let mut dirs = vec![key_dir.clone()];
295        if !ashlar_control::key::key_path(&key_dir, port).exists() {
296            dirs.extend(find_keys(&env.config_roots, Some(port), None).into_iter().map(|k| k.dir));
297        }
298        for d in &dirs {
299            if !ashlar_control::key::key_path(d, port).exists() {
300                continue;
301            }
302            match ping(port, d, Duration::from_secs(5)) {
303                Ok(c) => {
304                    let s = Session {
305                        port,
306                        key_dir: d.clone(),
307                        pid: Some(child.id()),
308                        exe: Some(exe.to_string()),
309                        log: Some(log_path),
310                        launched: true,
311                        trace: None,
312                        no_raise: false,
313                    };
314                    return Ok((s, c));
315                }
316                Err(e) => last = e,
317            }
318        }
319        if started.elapsed() >= wait {
320            let _ = child.kill();
321            let _ = child.wait();
322            let out = tail(&log_path, 20);
323            let out = if out.is_empty() { String::new() } else { format!("\nits last output:\n{out}") };
324            return Err(format!(
325                "{exe} did not answer on 127.0.0.1:{port} within {} ms ({last}); was it built with a control port (`--control <port>`)?{out}",
326                wait.as_millis()
327            ));
328        }
329        std::thread::sleep(Duration::from_millis(40));
330    }
331}
332
333/// Stop a process we started, when asking it to quit did not work.
334pub fn kill(pid: u32) {
335    #[cfg(unix)]
336    let _ = Command::new("kill").arg(pid.to_string()).stdout(Stdio::null()).stderr(Stdio::null()).status();
337    #[cfg(windows)]
338    let _ = Command::new("taskkill").args(["/PID", &pid.to_string(), "/F"]).stdout(Stdio::null()).stderr(Stdio::null()).status();
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    fn temp(tag: &str) -> tempfile::TempDir {
346        tempfile::Builder::new().prefix(&format!("ashlar-debug-session-{tag}-")).tempdir().unwrap()
347    }
348
349    fn env(home: &Path) -> Env {
350        Env { home: home.to_path_buf(), config_roots: vec![home.join("config")], session: "default".into(), key_dir: None, no_raise: false }
351    }
352
353    #[test]
354    fn sessions_round_trip_and_are_listed() {
355        let tmp = temp("store");
356        let home = tmp.path();
357        let env = env(home);
358        assert_eq!(load(&env, "default").unwrap(), None);
359        let s = Session { port: 7, key_dir: home.join("k"), pid: Some(3), exe: None, log: None, launched: true, trace: None, no_raise: false };
360        save(&env, "default", &s).unwrap();
361        save(&env, "other", &s).unwrap();
362        assert_eq!(load(&env, "default").unwrap(), Some(s));
363        assert_eq!(list(&env), ["default", "other"]);
364        remove(&env, "default").unwrap();
365        remove(&env, "default").unwrap();
366        assert_eq!(list(&env), ["other"]);
367        std::fs::write(env.session_file("bad"), "{").unwrap();
368        assert!(load(&env, "bad").unwrap_err().contains("damaged"));
369        for bad in ["", "../x", "a b", &"x".repeat(65)] {
370            assert!(load(&env, bad).is_err(), "{bad}");
371        }
372    }
373
374    #[test]
375    fn keys_are_found_by_port_and_app() {
376        let tmp = temp("keys");
377        let home = tmp.path();
378        let notes = home.join("config/org.example.Notes/config");
379        let other = home.join("config/Paint");
380        std::fs::create_dir_all(&notes).unwrap();
381        std::fs::create_dir_all(&other).unwrap();
382        std::fs::write(notes.join("control-key.local-7001"), "k").unwrap();
383        std::fs::write(other.join("control-key.local-7002"), "k").unwrap();
384        std::fs::write(other.join("control-key.local-nope"), "k").unwrap();
385        let roots = [home.join("config")];
386        let ports = |p, a| find_keys(&roots, p, a).into_iter().map(|k| k.port).collect::<Vec<_>>();
387        let mut all = ports(None, None);
388        all.sort();
389        assert_eq!(all, [7001, 7002]);
390        assert_eq!(ports(Some(7002), None), [7002]);
391        assert_eq!(ports(None, Some("notes")), [7001]);
392        assert_eq!(ports(Some(7001), Some("paint")), Vec::<u16>::new());
393        assert_eq!(find_keys(&roots, Some(7001), None)[0].dir, notes);
394        // Too deep: not found.
395        let deep = home.join("config/a/b/c/d/e");
396        std::fs::create_dir_all(&deep).unwrap();
397        std::fs::write(deep.join("control-key.local-7003"), "k").unwrap();
398        assert_eq!(ports(Some(7003), None), Vec::<u16>::new());
399    }
400
401    #[test]
402    fn flags_from_the_environment() {
403        for on in ["1", "true", "YES", "on"] {
404            assert!(truthy(on), "{on}");
405        }
406        for off in ["", "0", "false", "No", " off "] {
407            assert!(!truthy(off), "{off:?}");
408        }
409    }
410
411    #[test]
412    fn tail_keeps_the_last_lines() {
413        let tmp = temp("tail");
414        let home = tmp.path();
415        let p = home.join("log");
416        std::fs::write(&p, "a\nb\nc\n").unwrap();
417        assert_eq!(tail(&p, 2), "b\nc");
418        assert_eq!(tail(&p, 9), "a\nb\nc");
419        assert_eq!(tail(&home.join("missing"), 2), "");
420    }
421}