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

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