//! # signald //! //! The one long-lived, user-level daemon that owns every collector and //! publishes a signal stream. Renderers are thin subscribers over a local Unix //! socket — no renderer ever touches a sensor (spec §1.3). //! //! ```text //! zsh hooks ───▶ terminal collector ─┐ (aggregate counts from the spool) //! git/fsevents ▶ git collector ──────┼─▶ hub: last-value cache + fan-out //! IOKit/AppKit ▶ system+hw collector ┘ (stub, phase 3) //! ├─▶ publish: Unix socket (live) //! └─▶ SQLite WAL history //! ``` //! //! v0.2: the git and terminal collectors are live; the hub caches the latest //! value of every metric and streams updates to subscribers; every signal is //! persisted to a SQLite (WAL) history store. Collector internals live in the //! `signald` library crate; this binary is argument wiring, the producer loop, //! and self-attestation. //! //! Usage: //! ```text //! signald [--socket ] [--db ] [--spool ] //! [--interval-ms ] [ ...] //! ``` //! With no repo paths, the current directory is watched. The socket defaults to //! `$XDG_RUNTIME_DIR/signald.sock` (fallback `~/.local/state/signald/sock`); the //! history db and terminal spool default alongside it. use std::path::PathBuf; use std::thread; use std::time::Duration; use signal_schema::Source; use signald::collectors; use signald::history::History; use signald::hub::Hub; use signald::publish; struct Config { socket: PathBuf, db: PathBuf, spool: PathBuf, interval: Duration, repos: Vec, } fn main() { print_self_attestation(); let cfg = parse_args(); let history = match History::open(&cfg.db) { Ok(h) => { eprintln!("signald: history at {}", cfg.db.display()); h } Err(e) => { eprintln!("signald: fatal: cannot open history db {}: {e}", cfg.db.display()); std::process::exit(1); } }; let hub = Hub::with_history(history); // Producer: poll the collectors on a tick and publish into the hub. Runs // for the life of the daemon, independent of any subscriber. let producer = hub.clone(); let repos = cfg.repos.clone(); let spool = cfg.spool.clone(); let interval = cfg.interval; thread::spawn(move || loop { for repo in &repos { for sig in collectors::git::collect(repo) { producer.publish(sig); } } for sig in collectors::terminal::collect(&spool) { producer.publish(sig); } thread::sleep(interval); }); eprintln!("signald: watching {} repo(s), spool {}", cfg.repos.len(), cfg.spool.display()); if let Err(e) = publish::serve(&cfg.socket, hub) { eprintln!("signald: fatal: {e}"); std::process::exit(1); } } fn parse_args() -> Config { let mut socket: Option = None; let mut db: Option = None; let mut spool: Option = None; let mut interval_ms: u64 = 2000; let mut repos: Vec = Vec::new(); let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { match arg.as_str() { "--socket" => socket = args.next().map(PathBuf::from), "--db" => db = args.next().map(PathBuf::from), "--spool" => spool = args.next().map(PathBuf::from), "--interval-ms" => { interval_ms = args.next().and_then(|s| s.parse().ok()).unwrap_or(interval_ms) } _ => repos.push(PathBuf::from(arg)), } } if repos.is_empty() { repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); } let socket = socket.unwrap_or_else(default_socket_path); let base = socket.parent().map(PathBuf::from).unwrap_or_default(); Config { db: db.unwrap_or_else(|| base.join("signald.sqlite")), spool: spool.unwrap_or_else(|| base.join("terminal.spool")), interval: Duration::from_millis(interval_ms), repos, socket, } } /// `$XDG_RUNTIME_DIR/signald.sock`, else `~/.local/state/signald/sock`. fn default_socket_path() -> PathBuf { if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { return PathBuf::from(dir).join("signald.sock"); } let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); PathBuf::from(home).join(".local/state/signald/sock") } /// Log enabled collectors and assert none holds an input-tap capability. A real /// keylogger would need one of the forbidden APIs; their absence is the point, /// and this is the runtime half of that guarantee (spec §1.5). fn print_self_attestation() { eprintln!("signald {} — self-attestation", env!("CARGO_PKG_VERSION")); eprintln!(" transport: unix socket (length-prefixed frames), live pub/sub"); eprintln!(" history: sqlite (WAL), aggregate scalars only"); for source in [Source::Terminal, Source::Git, Source::Macos, Source::Hardware] { let state = match source { Source::Git => "ENABLED (aggregate scalars only)", Source::Terminal => "ENABLED (aggregate counts from zsh spool; no input tap)", Source::Macos | Source::Hardware => "stub (phase 3)", }; eprintln!(" collector {source:?}: input-tap capability = NONE — {state}"); } }