Skip to main content

zwire_host/
hostlog.rs

1//! Shared request/response log. Chrome spawns a SEPARATE native-messaging host
2//! process per `sendNativeMessage` (and one per persistent `connectNative`
3//! port), so no single process sees every command. To let the HUD "HOST" tab
4//! show ALL tx/rx to zwire-host regardless of which client/process handled it,
5//! every process appends a compact JSON line to one shared ring-capped file
6//! (`~/.zwire/hostlog.jsonl`). The HOST tab reads it back via the `hostlog`
7//! command. Streaming frames (sysinfo/pty/job output) are pushed with
8//! `out.send` directly rather than `respond`, so only real commands + their
9//! replies land here — not the high-frequency stream noise.
10
11use serde_json::{json, Value};
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::time::{SystemTime, UNIX_EPOCH};
15
16const MAX_BYTES: u64 = 768 * 1024; // trim the ring file past this
17const TRIM_KEEP: usize = 2000; // lines to keep when trimming
18
19/// High-frequency internal plumbing that would drown the real commands: the
20/// theme-sync `get` poll (every ~1.5s per HUD page) and bus (un)subscribe
21/// churn. These aren't "commands the user runs", so keep them out of the log.
22/// sysinfo frames ARE kept (the statusbar stream the user asked to see) — the
23/// HOST tab hides them behind a toggle instead.
24const NOISE_CMDS: [&str; 3] = ["get", "sub", "unsub"];
25
26fn log_path() -> PathBuf {
27    crate::store::theme_dir().join("hostlog.jsonl")
28}
29
30fn now_ms() -> u64 {
31    SystemTime::now()
32        .duration_since(UNIX_EPOCH)
33        .map(|d| d.as_millis() as u64)
34        .unwrap_or(0)
35}
36
37/// The command name of a request, incl. the legacy commandless `{scheme}`/`{ui}`.
38fn cmd_of(req: &Value) -> &str {
39    if let Some(c) = req.get("cmd").and_then(|c| c.as_str()) {
40        return c;
41    }
42    if !req["scheme"].is_null() {
43        "scheme"
44    } else if !req["ui"].is_null() {
45        "ui"
46    } else {
47        "?"
48    }
49}
50
51/// Append one tx/rx entry to the shared log. `dir` is "tx" (request) or "rx"
52/// (response). `req` is always the originating request (for the cmd + id);
53/// `data` is what to summarise (the request for tx, the response for rx).
54pub fn record(dir: &str, req: &Value, data: &Value) {
55    let cmd = cmd_of(req);
56    // Never log the log-reader itself, the high-frequency plumbing polls, or
57    // anything explicitly opted out.
58    if cmd == "hostlog"
59        || NOISE_CMDS.contains(&cmd)
60        || req.get("_nolog").and_then(|b| b.as_bool()).unwrap_or(false)
61    {
62        return;
63    }
64    let mut summary = data.to_string();
65    if summary.len() > 400 {
66        summary.truncate(400);
67        summary.push('…');
68    }
69    let entry = json!({
70        "t": now_ms(),
71        "dir": dir,
72        "cmd": cmd,
73        "pid": std::process::id(),
74        "msg": summary,
75    });
76    let path = log_path();
77    if let Ok(mut f) = std::fs::OpenOptions::new()
78        .create(true)
79        .append(true) // O_APPEND: per-line atomic across processes
80        .open(&path)
81    {
82        let _ = writeln!(f, "{entry}");
83    }
84    if std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0) > MAX_BYTES {
85        trim(&path);
86    }
87}
88
89/// Keep only the newest `TRIM_KEEP` lines. Best-effort (tmp+rename); a rare race
90/// with a concurrent append can drop a line, which is fine for a monitor log.
91fn trim(path: &Path) {
92    let Ok(data) = std::fs::read_to_string(path) else {
93        return;
94    };
95    let lines: Vec<&str> = data.lines().collect();
96    if lines.len() <= TRIM_KEEP {
97        return;
98    }
99    let tail = lines[lines.len() - TRIM_KEEP..].join("\n");
100    let tmp = path.with_extension("jsonl.tmp");
101    if std::fs::write(&tmp, format!("{tail}\n")).is_ok() {
102        let _ = std::fs::rename(&tmp, path);
103    }
104}
105
106/// Return the newest `n` entries (oldest→newest) for the HOST tab.
107pub fn read_tail(n: usize) -> Vec<Value> {
108    let Ok(data) = std::fs::read_to_string(log_path()) else {
109        return Vec::new();
110    };
111    let lines: Vec<&str> = data.lines().collect();
112    let start = lines.len().saturating_sub(n);
113    lines[start..]
114        .iter()
115        .filter_map(|l| serde_json::from_str::<Value>(l).ok())
116        .collect()
117}