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}