crates/signald/src/main.rs
147 lines · 5518 bytes
1//! # signald
2//!
3//! The one long-lived, user-level daemon that owns every collector and
4//! publishes a signal stream. Renderers are thin subscribers over a local Unix
5//! socket — no renderer ever touches a sensor (spec §1.3).
6//!
7//! ```text
8//! zsh hooks ───▶ terminal collector ─┐ (aggregate counts from the spool)
9//! git/fsevents ▶ git collector ──────┼─▶ hub: last-value cache + fan-out
10//! IOKit/AppKit ▶ system+hw collector ┘ (stub, phase 3)
11//! ├─▶ publish: Unix socket (live)
12//! └─▶ SQLite WAL history
13//! ```
14//!
15//! v0.2: the git and terminal collectors are live; the hub caches the latest
16//! value of every metric and streams updates to subscribers; every signal is
17//! persisted to a SQLite (WAL) history store. Collector internals live in the
18//! `signald` library crate; this binary is argument wiring, the producer loop,
19//! and self-attestation.
20//!
21//! Usage:
22//! ```text
23//! signald [--socket <path>] [--db <path>] [--spool <path>]
24//! [--interval-ms <n>] [<repo-path> ...]
25//! ```
26//! With no repo paths, the current directory is watched. The socket defaults to
27//! `$XDG_RUNTIME_DIR/signald.sock` (fallback `~/.local/state/signald/sock`); the
28//! history db and terminal spool default alongside it.
29
30use std::path::PathBuf;
31use std::thread;
32use std::time::Duration;
33
34use signal_schema::Source;
35use signald::collectors;
36use signald::history::History;
37use signald::hub::Hub;
38use signald::publish;
39
40struct Config {
41 socket: PathBuf,
42 db: PathBuf,
43 spool: PathBuf,
44 interval: Duration,
45 repos: Vec<PathBuf>,
46}
47
48fn main() {
49 print_self_attestation();
50 let cfg = parse_args();
51
52 let history = match History::open(&cfg.db) {
53 Ok(h) => {
54 eprintln!("signald: history at {}", cfg.db.display());
55 h
56 }
57 Err(e) => {
58 eprintln!("signald: fatal: cannot open history db {}: {e}", cfg.db.display());
59 std::process::exit(1);
60 }
61 };
62 let hub = Hub::with_history(history);
63
64 // Producer: poll the collectors on a tick and publish into the hub. Runs
65 // for the life of the daemon, independent of any subscriber.
66 let producer = hub.clone();
67 let repos = cfg.repos.clone();
68 let spool = cfg.spool.clone();
69 let interval = cfg.interval;
70 thread::spawn(move || loop {
71 for repo in &repos {
72 for sig in collectors::git::collect(repo) {
73 producer.publish(sig);
74 }
75 }
76 for sig in collectors::terminal::collect(&spool) {
77 producer.publish(sig);
78 }
79 thread::sleep(interval);
80 });
81
82 eprintln!("signald: watching {} repo(s), spool {}", cfg.repos.len(), cfg.spool.display());
83 if let Err(e) = publish::serve(&cfg.socket, hub) {
84 eprintln!("signald: fatal: {e}");
85 std::process::exit(1);
86 }
87}
88
89fn parse_args() -> Config {
90 let mut socket: Option<PathBuf> = None;
91 let mut db: Option<PathBuf> = None;
92 let mut spool: Option<PathBuf> = None;
93 let mut interval_ms: u64 = 2000;
94 let mut repos: Vec<PathBuf> = Vec::new();
95
96 let mut args = std::env::args().skip(1);
97 while let Some(arg) = args.next() {
98 match arg.as_str() {
99 "--socket" => socket = args.next().map(PathBuf::from),
100 "--db" => db = args.next().map(PathBuf::from),
101 "--spool" => spool = args.next().map(PathBuf::from),
102 "--interval-ms" => {
103 interval_ms = args.next().and_then(|s| s.parse().ok()).unwrap_or(interval_ms)
104 }
105 _ => repos.push(PathBuf::from(arg)),
106 }
107 }
108
109 if repos.is_empty() {
110 repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
111 }
112 let socket = socket.unwrap_or_else(default_socket_path);
113 let base = socket.parent().map(PathBuf::from).unwrap_or_default();
114 Config {
115 db: db.unwrap_or_else(|| base.join("signald.sqlite")),
116 spool: spool.unwrap_or_else(|| base.join("terminal.spool")),
117 interval: Duration::from_millis(interval_ms),
118 repos,
119 socket,
120 }
121}
122
123/// `$XDG_RUNTIME_DIR/signald.sock`, else `~/.local/state/signald/sock`.
124fn default_socket_path() -> PathBuf {
125 if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
126 return PathBuf::from(dir).join("signald.sock");
127 }
128 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
129 PathBuf::from(home).join(".local/state/signald/sock")
130}
131
132/// Log enabled collectors and assert none holds an input-tap capability. A real
133/// keylogger would need one of the forbidden APIs; their absence is the point,
134/// and this is the runtime half of that guarantee (spec §1.5).
135fn print_self_attestation() {
136 eprintln!("signald {} — self-attestation", env!("CARGO_PKG_VERSION"));
137 eprintln!(" transport: unix socket (length-prefixed frames), live pub/sub");
138 eprintln!(" history: sqlite (WAL), aggregate scalars only");
139 for source in [Source::Terminal, Source::Git, Source::Macos, Source::Hardware] {
140 let state = match source {
141 Source::Git => "ENABLED (aggregate scalars only)",
142 Source::Terminal => "ENABLED (aggregate counts from zsh spool; no input tap)",
143 Source::Macos | Source::Hardware => "stub (phase 3)",
144 };
145 eprintln!(" collector {source:?}: input-tap capability = NONE — {state}");
146 }
147}