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

155 lines · 5722 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 Frames {
 63    /// Read the last-value cache the daemon sends on connect, and return.
 64    ///
 65    /// There is no end-of-snapshot marker on the wire, so this reads one frame
 66    /// blocking and then drains whatever else is already buffered, stopping as
 67    /// soon as the socket would block. The cache arrives in a single burst, so
 68    /// that returns immediately rather than waiting out a timeout — which is
 69    /// what makes a one-shot reader cheap enough to run on every shell prompt.
 70    ///
 71    /// Consumes the `Frames`: draining can abandon a partially-arrived frame,
 72    /// which leaves the stream out of sync. That is harmless for a reader that
 73    /// exits, and unusable for one that keeps going.
 74    pub fn snapshot(mut self) -> std::io::Result<Vec<signal_schema::Signal>> {
 75        let mut out = Vec::new();
 76        // The first frame blocks: the daemon always has something cached, and
 77        // an empty read here means the socket died rather than went quiet.
 78        match self.next() {
 79            Some(Ok(s)) => out.push(s),
 80            Some(Err(e)) => return Err(e),
 81            None => return Ok(out),
 82        }
 83        self.reader.get_ref().set_nonblocking(true)?;
 84        for frame in &mut self {
 85            match frame {
 86                Ok(s) => out.push(s),
 87                // Nothing more is buffered: the cache has been drained.
 88                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
 89                Err(e) => return Err(e),
 90            }
 91        }
 92        Ok(out)
 93    }
 94}
 95
 96impl Iterator for Frames {
 97    /// `Err` is an unframeable stream. A skipped frame is not surfaced: the
 98    /// iterator swallows it and reads on, which is the whole point of
 99    /// [`wire::Frame::Skipped`].
100    type Item = std::io::Result<signal_schema::Signal>;
101
102    fn next(&mut self) -> Option<Self::Item> {
103        while !self.done {
104            match wire::read_frame(&mut self.reader) {
105                Ok(wire::Frame::Signal(s)) => return Some(Ok(s)),
106                Ok(wire::Frame::Skipped) => continue,
107                Ok(wire::Frame::Eof) => {
108                    self.done = true;
109                    return None;
110                }
111                Err(e) => {
112                    self.done = true;
113                    return Some(Err(e));
114                }
115            }
116        }
117        None
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    /// The one place the socket default is pinned. It used to be asserted
126    /// separately in each binary against a README table.
127    #[test]
128    fn socket_default_follows_xdg_then_home() {
129        assert_eq!(
130            socket_path_from(Some("/run/user/501"), Some("/Users/x")),
131            PathBuf::from("/run/user/501/signald.sock")
132        );
133        assert_eq!(
134            socket_path_from(None, Some("/Users/x")),
135            PathBuf::from("/Users/x/.local/state/signald/sock")
136        );
137    }
138
139    /// The db and the spool are derived from the socket's directory, so all
140    /// three move together when --socket is given.
141    #[test]
142    fn db_and_spool_sit_beside_the_socket() {
143        let socket = socket_path_from(None, Some("/Users/x"));
144        let base = socket.parent().unwrap();
145        assert_eq!(
146            base.join("signald.sqlite"),
147            PathBuf::from("/Users/x/.local/state/signald/signald.sqlite")
148        );
149        assert_eq!(
150            base.join("terminal.spool"),
151            PathBuf::from("/Users/x/.local/state/signald/terminal.spool"),
152            "must equal SIGNALD_SPOOL's default in shell-hooks/signald-hooks.zsh"
153        );
154    }
155}