//! # 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. //! //! ```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 ┘ (macos-collector child, frames on stdout) //! ├─▶ publish: Unix socket (live) //! └─▶ SQLite WAL history //! ``` //! //! The git and terminal collectors run in-process on a tick; the hardware //! collector is the sibling `macos-collector` binary, spawned as a child whose //! stdout frames are ingested into the same hub. 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 ] //! [--collector ] [--interval-ms ] [--retention-days ] //! [ ...] //! signald --help | --version //! ``` //! With no repo paths, `$XDG_CONFIG_HOME/signald/repos` (else //! `~/.config/signald/repos`) is read — one path per line, `#` comments //! ignored — and failing that 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. `--collector` names the //! `macos-collector` binary; by default it is looked up on `PATH` and skipped, //! with a log line, when absent. History older than `--retention-days` //! (default 7) is pruned. use std::path::PathBuf; use std::thread; use std::time::Duration; use signal_schema::Source; use signald::collectors; use signald::history::History; use signald::supervisor; use signald::hub::Hub; use signald::publish; struct Config { socket: PathBuf, db: PathBuf, spool: PathBuf, collector: Option, interval: Duration, retention: Duration, repos: Vec, } fn main() { let cfg = parse_args(); print_self_attestation(&cfg); let history = match History::open_with_retention(&cfg.db, cfg.retention) { Ok(h) => { eprintln!( "signald: history at {} (retention {} days)", cfg.db.display(), cfg.retention.as_secs() / 86_400 ); h } Err(e) => { eprintln!("signald: fatal: cannot open history db {}: {e}", cfg.db.display()); std::process::exit(1); } }; let hub = Hub::with_history(history); // Every collector reports through one Health, so a state change reaches the // bus once and only when it is a change. let health = supervisor::Health::new(); // Hardware: keep the out-of-process collector running. Not having one // (Linux, or a dev build not on PATH) is not fatal and is not retried. match &cfg.collector { Some(collector) => { eprintln!("signald: hardware collector {}", collector.display()); let (hub, health) = (hub.clone(), health.clone()); let collector = collector.clone(); let interval_ms = cfg.interval.as_millis() as u64; thread::spawn(move || supervisor::supervise_hardware(hub, health, collector, interval_ms)); } None => supervisor::report_unconfigured(&hub, &health, collectors::hardware::PROGRAM), } // Producer: poll the git and terminal collectors on a tick and publish into // the hub. Runs for the life of the daemon, independent of any subscriber. { let (hub, health) = (hub.clone(), health.clone()); let repos = cfg.repos.clone(); let spool = cfg.spool.clone(); let interval = cfg.interval; thread::spawn(move || supervisor::run_producer(hub, health, repos, spool, 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); } } const USAGE: &str = "\ Usage: signald [options] [ ...] --socket listen here (default $XDG_RUNTIME_DIR/signald.sock, else ~/.local/state/signald/sock) --db sqlite history (default: beside the socket) --spool terminal spool (default: beside the socket) --collector macos-collector binary (default: found on PATH) --interval-ms collector tick (default 2000) --retention-days history retention (default 7) -h, --help print this and exit -V, --version print the version and exit With no , $XDG_CONFIG_HOME/signald/repos (else ~/.config/signald/repos) is read, one path per line, # comments ignored. Failing that, the working directory is watched. "; fn parse_args() -> Config { let mut socket: Option = None; let mut db: Option = None; let mut spool: Option = None; let mut collector: Option = None; let mut interval_ms: u64 = 2000; let mut retention_days: u64 = 7; 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), "--collector" => collector = args.next().map(PathBuf::from), "--interval-ms" => { interval_ms = args.next().and_then(|s| s.parse().ok()).unwrap_or(interval_ms) } "--retention-days" => { retention_days = args.next().and_then(|s| s.parse().ok()).unwrap_or(retention_days) } "--help" | "-h" => { print!("{USAGE}"); std::process::exit(0); } "--version" | "-V" => { println!("signald {}", env!("CARGO_PKG_VERSION")); std::process::exit(0); } // Without this an unknown flag becomes a repository path and the // daemon starts anyway, watching a directory that does not exist. _ if arg.starts_with('-') => { eprintln!("signald: unknown option {arg}\n"); eprint!("{USAGE}"); std::process::exit(2); } _ => repos.push(PathBuf::from(arg)), } } if repos.is_empty() { repos = repos_from_config_file(); } if repos.is_empty() { repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); } let socket = socket.unwrap_or_else(signal_client::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")), collector: collector.or_else(collectors::hardware::find_on_path), interval: Duration::from_millis(interval_ms), retention: Duration::from_secs(retention_days * 86_400), repos, socket, } } /// `$XDG_CONFIG_HOME/signald/repos`, else `$HOME/.config/signald/repos`. fn repos_config_path(xdg_config_home: Option<&str>, home: Option<&str>) -> PathBuf { match xdg_config_home { Some(dir) => PathBuf::from(dir).join("signald/repos"), None => PathBuf::from(home.unwrap_or(".")).join(".config/signald/repos"), } } /// One repository path per line. Blank lines and `#` comments are ignored. fn parse_repos_file(contents: &str) -> Vec { contents .lines() .map(|l| l.trim()) .filter(|l| !l.is_empty() && !l.starts_with('#')) .map(PathBuf::from) .collect() } /// Repositories to watch when none were given on the command line. /// /// A login agent has no useful working directory — launchd starts it in `/` — /// so falling straight through to the cwd would leave `brew services start /// signald` collecting no git signals at all. A missing file is not an error; /// the cwd fallback still applies. fn repos_from_config_file() -> Vec { let path = repos_config_path( std::env::var("XDG_CONFIG_HOME").ok().as_deref(), std::env::var("HOME").ok().as_deref(), ); match std::fs::read_to_string(&path) { Ok(contents) => { let repos = parse_repos_file(&contents); if !repos.is_empty() { eprintln!("signald: watching {} repo(s) from {}", repos.len(), path.display()); } repos } Err(_) => Vec::new(), } } /// 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. fn print_self_attestation(cfg: &Config) { 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"); let hardware = match &cfg.collector { Some(p) => format!("ENABLED (out-of-process {}; IOKit only, no root)", p.display()), None => format!( "DISABLED ({} not on PATH; pass --collector )", collectors::hardware::PROGRAM ), }; 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 => hardware.as_str(), }; eprintln!(" collector {source:?}: input-tap capability = NONE — {state}"); } } #[cfg(test)] mod path_tests { use super::*; #[test] fn repos_file_skips_blanks_and_comments() { let repos = parse_repos_file( "# what to watch\n\n/Users/x/git/one\n /Users/x/git/two \n\n# trailing\n", ); assert_eq!( repos, vec![ PathBuf::from("/Users/x/git/one"), PathBuf::from("/Users/x/git/two") ] ); } #[test] fn repos_config_follows_xdg_then_home() { assert_eq!( repos_config_path(Some("/Users/x/.config"), Some("/Users/x")), PathBuf::from("/Users/x/.config/signald/repos") ); assert_eq!( repos_config_path(None, Some("/Users/x")), PathBuf::from("/Users/x/.config/signald/repos") ); } }