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

97 lines · 3689 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
91fn default_socket_path() -> PathBuf {
92    if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
93        return PathBuf::from(dir).join("signald.sock");
94    }
95    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
96    PathBuf::from(home).join(".local/state/signald/sock")
97}