Ambient system companions over one privacy-preserving signal daemon (aggregate-only, no keystroke content): a git-driven terminal garden and IOKit hardware collectors. ambient daemon macos privacy terminal

crates/terminal-garden/src/main.rs

149 lines · 5425 bytes

  1//! # terminal-garden
  2//!
  3//! The first face of the bus: a renderer, not a collector.
  4//! Git is the cleanest signal in the suite — discrete, unambiguous, no privacy
  5//! questions — so the garden proves the bus before anything reads the shell.
  6//!
  7//! Like every renderer, the garden is thin: connect to the socket → read the
  8//! git aggregates → fold them into per-repo plots → render. It never touches a
  9//! sensor. The mapping (commits → growth, staleness → wilt) lives in the
 10//! `terminal-garden` library so it can be unit-tested; this binary is just the
 11//! subscriber loop.
 12//!
 13//! Usage:
 14//! ```text
 15//!   terminal-garden [--socket <path>]
 16//!   terminal-garden --help | --version
 17//! ```
 18//! Socket defaults to `$XDG_RUNTIME_DIR/signald.sock`, falling back to
 19//! `~/.local/state/signald/sock`.
 20
 21use std::collections::BTreeMap;
 22use std::io::BufReader;
 23use std::os::unix::net::UnixStream;
 24use std::path::PathBuf;
 25
 26use signal_schema::{wire, Signal, SignalName};
 27use terminal_garden::{plots_from_signals, render};
 28
 29/// The `name`s the garden cares about: the git aggregates.
 30const SUBSCRIBE: &[SignalName] = &[
 31    SignalName::CommitsWindow,
 32    SignalName::CommitsToday,
 33    SignalName::BranchCount,
 34    SignalName::DaysSinceLastCommit,
 35];
 36
 37fn main() {
 38    let socket = parse_socket();
 39    if let Err(e) = run(&socket) {
 40        eprintln!("terminal-garden: could not read signald at {}: {e}", socket.display());
 41        eprintln!("terminal-garden: is signald running? (signald --socket {})", socket.display());
 42        std::process::exit(1);
 43    }
 44}
 45
 46/// Connect and render live: the daemon replays the last-value cache on connect,
 47/// then streams updates. We keep the latest value of each metric (keyed by name
 48/// and repo tag) and re-render the garden on every frame. `Ok(())` is a clean EOF
 49/// (the daemon closed the stream). Frames this build cannot decode are skipped,
 50/// so an older renderer keeps working against a newer daemon.
 51fn run(socket: &PathBuf) -> std::io::Result<()> {
 52    let stream = UnixStream::connect(socket)?;
 53    let mut reader = BufReader::new(stream);
 54
 55    // Keyed by (metric name, repo tag) so per-repo signals coexist and updates
 56    // replace prior values rather than accumulating.
 57    let mut latest: BTreeMap<(u8, Option<String>), Signal> = BTreeMap::new();
 58    loop {
 59        let sig = match wire::read_frame(&mut reader)? {
 60            wire::Frame::Signal(sig) => sig,
 61            // A record this build does not understand: a newer daemon, or a
 62            // metric appended after this renderer was built. Keep rendering.
 63            wire::Frame::Skipped => continue,
 64            wire::Frame::Eof => break,
 65        };
 66        if !SUBSCRIBE.contains(&sig.name) {
 67            continue;
 68        }
 69        let key = (sig.name.to_u8(), sig.tag.as_ref().map(|t| t.as_str().to_string()));
 70        latest.insert(key, sig);
 71
 72        let signals: Vec<Signal> = latest.values().cloned().collect();
 73        let plots = plots_from_signals(&signals);
 74        // Clear + home so the garden redraws in place as signals change.
 75        print!("\x1b[2J\x1b[H{}", render(&plots));
 76    }
 77    Ok(())
 78}
 79
 80const USAGE: &str = "\
 81Usage: terminal-garden [--socket <path>]
 82
 83  --socket <path>   signald's socket (default $XDG_RUNTIME_DIR/signald.sock,
 84                    else ~/.local/state/signald/sock)
 85  -h, --help        print this and exit
 86  -V, --version     print the version and exit
 87";
 88
 89fn parse_socket() -> PathBuf {
 90    let mut args = std::env::args().skip(1);
 91    while let Some(arg) = args.next() {
 92        match arg.as_str() {
 93            "--help" | "-h" => {
 94                print!("{USAGE}");
 95                std::process::exit(0);
 96            }
 97            "--version" | "-V" => {
 98                println!("terminal-garden {}", env!("CARGO_PKG_VERSION"));
 99                std::process::exit(0);
100            }
101            _ => {}
102        }
103        if arg == "--socket" {
104            if let Some(path) = args.next() {
105                return PathBuf::from(path);
106            }
107        }
108    }
109    default_socket_path()
110}
111
112/// `$XDG_RUNTIME_DIR/signald.sock`, else `$HOME/.local/state/signald/sock`.
113///
114/// Split from the environment so it can be tested without mutating it. The
115/// same resolution is duplicated in the other binary and in
116/// `shell-hooks/signald-hooks.zsh`; the README's "Paths" table is the one
117/// place they are written down, and the tests below pin them to it.
118fn socket_path_from(xdg_runtime_dir: Option<&str>, home: Option<&str>) -> PathBuf {
119    if let Some(dir) = xdg_runtime_dir {
120        return PathBuf::from(dir).join("signald.sock");
121    }
122    PathBuf::from(home.unwrap_or(".")).join(".local/state/signald/sock")
123}
124
125fn default_socket_path() -> PathBuf {
126    socket_path_from(
127        std::env::var("XDG_RUNTIME_DIR").ok().as_deref(),
128        std::env::var("HOME").ok().as_deref(),
129    )
130}
131
132#[cfg(test)]
133mod path_tests {
134    use super::*;
135
136    /// Pinned to the README "Paths" table. If this changes, the table and
137    /// `shell-hooks/signald-hooks.zsh` change with it.
138    #[test]
139    fn socket_default_follows_xdg_then_home() {
140        assert_eq!(
141            socket_path_from(Some("/run/user/501"), Some("/Users/x")),
142            PathBuf::from("/run/user/501/signald.sock")
143        );
144        assert_eq!(
145            socket_path_from(None, Some("/Users/x")),
146            PathBuf::from("/Users/x/.local/state/signald/sock")
147        );
148    }
149}