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/signal-client/src/lib.rs

121 lines · 4186 bytes

  1//! # signal-client
  2//!
  3//! The client side of the signal bus: where the daemon's socket lives, and how
  4//! to read frames off it.
  5//!
  6//! The socket path was resolved by three copies of the same function — the
  7//! daemon and each renderer — kept honest by a table in the README and a
  8//! duplicated test in every binary. Copies drift; this crate is the one
  9//! definition.
 10//!
 11//! It holds no privacy surface of its own. Everything it yields came off the
 12//! wire and through `signal_schema`'s decode, so the `f64`-only payload rule is
 13//! already enforced upstream.
 14
 15use std::io::BufReader;
 16use std::os::unix::net::UnixStream;
 17use std::path::{Path, PathBuf};
 18
 19use signal_schema::wire;
 20
 21/// The daemon's socket: `$XDG_RUNTIME_DIR/signald.sock`, else
 22/// `$HOME/.local/state/signald/sock`.
 23///
 24/// The daemon derives its history db and terminal spool from this path's
 25/// directory, so all three move together when `--socket` is given.
 26pub fn default_socket_path() -> PathBuf {
 27    socket_path_from(
 28        std::env::var("XDG_RUNTIME_DIR").ok().as_deref(),
 29        std::env::var("HOME").ok().as_deref(),
 30    )
 31}
 32
 33/// [`default_socket_path`] with the environment passed in, so it can be tested
 34/// without mutating process state.
 35pub fn socket_path_from(xdg_runtime_dir: Option<&str>, home: Option<&str>) -> PathBuf {
 36    if let Some(dir) = xdg_runtime_dir {
 37        return PathBuf::from(dir).join("signald.sock");
 38    }
 39    PathBuf::from(home.unwrap_or(".")).join(".local/state/signald/sock")
 40}
 41
 42/// Connect to the daemon and iterate the frames it publishes.
 43///
 44/// The iterator ends at a clean EOF and on an unframeable stream. Frames this
 45/// build cannot decode are skipped rather than ending the stream, so a renderer
 46/// built before a metric was appended keeps working against a newer daemon.
 47pub struct Frames {
 48    reader: BufReader<UnixStream>,
 49    done: bool,
 50}
 51
 52impl Frames {
 53    /// Connect to the daemon listening at `socket`.
 54    pub fn connect(socket: &Path) -> std::io::Result<Frames> {
 55        Ok(Frames {
 56            reader: BufReader::new(UnixStream::connect(socket)?),
 57            done: false,
 58        })
 59    }
 60}
 61
 62impl Iterator for Frames {
 63    /// `Err` is an unframeable stream. A skipped frame is not surfaced: the
 64    /// iterator swallows it and reads on, which is the whole point of
 65    /// [`wire::Frame::Skipped`].
 66    type Item = std::io::Result<signal_schema::Signal>;
 67
 68    fn next(&mut self) -> Option<Self::Item> {
 69        while !self.done {
 70            match wire::read_frame(&mut self.reader) {
 71                Ok(wire::Frame::Signal(s)) => return Some(Ok(s)),
 72                Ok(wire::Frame::Skipped) => continue,
 73                Ok(wire::Frame::Eof) => {
 74                    self.done = true;
 75                    return None;
 76                }
 77                Err(e) => {
 78                    self.done = true;
 79                    return Some(Err(e));
 80                }
 81            }
 82        }
 83        None
 84    }
 85}
 86
 87#[cfg(test)]
 88mod tests {
 89    use super::*;
 90
 91    /// The one place the socket default is pinned. It used to be asserted
 92    /// separately in each binary against a README table.
 93    #[test]
 94    fn socket_default_follows_xdg_then_home() {
 95        assert_eq!(
 96            socket_path_from(Some("/run/user/501"), Some("/Users/x")),
 97            PathBuf::from("/run/user/501/signald.sock")
 98        );
 99        assert_eq!(
100            socket_path_from(None, Some("/Users/x")),
101            PathBuf::from("/Users/x/.local/state/signald/sock")
102        );
103    }
104
105    /// The db and the spool are derived from the socket's directory, so all
106    /// three move together when --socket is given.
107    #[test]
108    fn db_and_spool_sit_beside_the_socket() {
109        let socket = socket_path_from(None, Some("/Users/x"));
110        let base = socket.parent().unwrap();
111        assert_eq!(
112            base.join("signald.sqlite"),
113            PathBuf::from("/Users/x/.local/state/signald/signald.sqlite")
114        );
115        assert_eq!(
116            base.join("terminal.spool"),
117            PathBuf::from("/Users/x/.local/state/signald/terminal.spool"),
118            "must equal SIGNALD_SPOOL's default in shell-hooks/signald-hooks.zsh"
119        );
120    }
121}