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}