Commit 52a0b45f52
Verified · cmc
Cargo.lock +9
| @@ -169,6 +169,13 @@ version = "2.0.1" | ||
| 169 | 169 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 170 | 170 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" |
| 171 | 171 | |
| 172 | [[package]] | |
| 173 | name = "signal-client" | |
| 174 | version = "0.6.1" | |
| 175 | dependencies = [ | |
| 176 | "signal-schema", | |
| 177 | ] | |
| 178 | ||
| 172 | 179 | [[package]] |
| 173 | 180 | name = "signal-schema" |
| 174 | 181 | version = "0.6.1" |
| @@ -178,6 +185,7 @@ name = "signald" | ||
| 178 | 185 | version = "0.6.1" |
| 179 | 186 | dependencies = [ |
| 180 | 187 | "rusqlite", |
| 188 | "signal-client", | |
| 181 | 189 | "signal-schema", |
| 182 | 190 | ] |
| 183 | 191 | |
| @@ -225,6 +233,7 @@ dependencies = [ | ||
| 225 | 233 | name = "terminal-garden" |
| 226 | 234 | version = "0.6.1" |
| 227 | 235 | dependencies = [ |
| 236 | "signal-client", | |
| 228 | 237 | "signal-schema", |
| 229 | 238 | ] |
| 230 | 239 | |
Cargo.toml +1
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | resolver = "2" |
| 3 | 3 | members = [ |
| 4 | 4 | "crates/signal-schema", |
| 5 | "crates/signal-client", | |
| 5 | 6 | "crates/signald", |
| 6 | 7 | "crates/terminal-garden", |
| 7 | 8 | ] |
README.md +3 −3
| @@ -262,9 +262,9 @@ that starts it. | ||
| 262 | 262 | |
| 263 | 263 | ### Paths |
| 264 | 264 | |
| 265 | Defaults, resolved the same way by `signald`, `terminal-garden`, and | |
| 266 | `shell-hooks/signald-hooks.zsh`. This table is the single place they are | |
| 267 | written down; unit tests in both binaries pin the code to it. | |
| 265 | Defaults. `crates/signal-client` resolves the socket path once for the daemon | |
| 266 | and every renderer, and its test pins the values below; | |
| 267 | `shell-hooks/signald-hooks.zsh` derives the spool the same way in shell. | |
| 268 | 268 | |
| 269 | 269 | | | `$XDG_RUNTIME_DIR` set | otherwise | |
| 270 | 270 | |---|---|---| |
crates/signal-client/Cargo.toml added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | [package] | |
| 2 | name = "signal-client" | |
| 3 | version.workspace = true | |
| 4 | edition.workspace = true | |
| 5 | license.workspace = true | |
| 6 | description = "Client side of the ambient-companions signal bus: where the daemon's socket lives, and reading frames off it. Shared by every renderer so the path is defined once." | |
| 7 | ||
| 8 | [dependencies] | |
| 9 | signal-schema = { path = "../signal-schema" } | |
crates/signal-client/src/lib.rs added +121
| @@ -0,0 +1,121 @@ | ||
| 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 | ||
| 15 | use std::io::BufReader; | |
| 16 | use std::os::unix::net::UnixStream; | |
| 17 | use std::path::{Path, PathBuf}; | |
| 18 | ||
| 19 | use 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. | |
| 26 | pub 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. | |
| 35 | pub 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. | |
| 47 | pub struct Frames { | |
| 48 | reader: BufReader<UnixStream>, | |
| 49 | done: bool, | |
| 50 | } | |
| 51 | ||
| 52 | impl 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 | ||
| 62 | impl 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)] | |
| 88 | mod 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 | } | |
crates/signald/Cargo.toml +2
| @@ -11,6 +11,8 @@ path = "src/main.rs" | ||
| 11 | 11 | |
| 12 | 12 | [dependencies] |
| 13 | 13 | signal-schema = { path = "../signal-schema" } |
| 14 | # Socket-path resolution only; the daemon is not a client of its own bus. | |
| 15 | signal-client = { path = "../signal-client" } | |
| 14 | 16 | # History store: SQLite in WAL mode. `bundled` compiles SQLite in- |
| 15 | 17 | # tree so there is no system-library dependency. This is the one new dependency |
| 16 | 18 | # added in v0.2; signal-schema stays dependency-free by design. |
crates/signald/src/main.rs +1 −51
| @@ -182,7 +182,7 @@ fn parse_args() -> Config { | ||
| 182 | 182 | if repos.is_empty() { |
| 183 | 183 | repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); |
| 184 | 184 | } |
| 185 | let socket = socket.unwrap_or_else(default_socket_path); | |
| 185 | let socket = socket.unwrap_or_else(signal_client::default_socket_path); | |
| 186 | 186 | let base = socket.parent().map(PathBuf::from).unwrap_or_default(); |
| 187 | 187 | Config { |
| 188 | 188 | db: db.unwrap_or_else(|| base.join("signald.sqlite")), |
| @@ -195,26 +195,6 @@ fn parse_args() -> Config { | ||
| 195 | 195 | } |
| 196 | 196 | } |
| 197 | 197 | |
| 198 | /// `$XDG_RUNTIME_DIR/signald.sock`, else `$HOME/.local/state/signald/sock`. | |
| 199 | /// | |
| 200 | /// Split from the environment so it can be tested without mutating it. The | |
| 201 | /// same resolution is duplicated in the other binary and in | |
| 202 | /// `shell-hooks/signald-hooks.zsh`; the README's "Paths" table is the one | |
| 203 | /// place they are written down, and the tests below pin them to it. | |
| 204 | fn socket_path_from(xdg_runtime_dir: Option<&str>, home: Option<&str>) -> PathBuf { | |
| 205 | if let Some(dir) = xdg_runtime_dir { | |
| 206 | return PathBuf::from(dir).join("signald.sock"); | |
| 207 | } | |
| 208 | PathBuf::from(home.unwrap_or(".")).join(".local/state/signald/sock") | |
| 209 | } | |
| 210 | ||
| 211 | fn default_socket_path() -> PathBuf { | |
| 212 | socket_path_from( | |
| 213 | std::env::var("XDG_RUNTIME_DIR").ok().as_deref(), | |
| 214 | std::env::var("HOME").ok().as_deref(), | |
| 215 | ) | |
| 216 | } | |
| 217 | ||
| 218 | 198 | /// `$XDG_CONFIG_HOME/signald/repos`, else `$HOME/.config/signald/repos`. |
| 219 | 199 | fn repos_config_path(xdg_config_home: Option<&str>, home: Option<&str>) -> PathBuf { |
| 220 | 200 | match xdg_config_home { |
| @@ -284,20 +264,6 @@ fn print_self_attestation(cfg: &Config) { | ||
| 284 | 264 | mod path_tests { |
| 285 | 265 | use super::*; |
| 286 | 266 | |
| 287 | /// Pinned to the README "Paths" table. If this changes, the table and | |
| 288 | /// `shell-hooks/signald-hooks.zsh` change with it. | |
| 289 | #[test] | |
| 290 | fn socket_default_follows_xdg_then_home() { | |
| 291 | assert_eq!( | |
| 292 | socket_path_from(Some("/run/user/501"), Some("/Users/x")), | |
| 293 | PathBuf::from("/run/user/501/signald.sock") | |
| 294 | ); | |
| 295 | assert_eq!( | |
| 296 | socket_path_from(None, Some("/Users/x")), | |
| 297 | PathBuf::from("/Users/x/.local/state/signald/sock") | |
| 298 | ); | |
| 299 | } | |
| 300 | ||
| 301 | 267 | #[test] |
| 302 | 268 | fn repos_file_skips_blanks_and_comments() { |
| 303 | 269 | let repos = parse_repos_file( |
| @@ -324,20 +290,4 @@ mod path_tests { | ||
| 324 | 290 | ); |
| 325 | 291 | } |
| 326 | 292 | |
| 327 | /// The db and the spool are derived from the socket's directory, so all | |
| 328 | /// three move together when --socket is given. | |
| 329 | #[test] | |
| 330 | fn db_and_spool_sit_beside_the_socket() { | |
| 331 | let socket = socket_path_from(None, Some("/Users/x")); | |
| 332 | let base = socket.parent().unwrap(); | |
| 333 | assert_eq!( | |
| 334 | base.join("signald.sqlite"), | |
| 335 | PathBuf::from("/Users/x/.local/state/signald/signald.sqlite") | |
| 336 | ); | |
| 337 | assert_eq!( | |
| 338 | base.join("terminal.spool"), | |
| 339 | PathBuf::from("/Users/x/.local/state/signald/terminal.spool"), | |
| 340 | "must equal SIGNALD_SPOOL's default in shell-hooks/signald-hooks.zsh" | |
| 341 | ); | |
| 342 | } | |
| 343 | 293 | } |
crates/terminal-garden/Cargo.toml +1
| @@ -11,3 +11,4 @@ path = "src/main.rs" | ||
| 11 | 11 | |
| 12 | 12 | [dependencies] |
| 13 | 13 | signal-schema = { path = "../signal-schema" } |
| 14 | signal-client = { path = "../signal-client" } | |
crates/terminal-garden/src/main.rs +9 −55
| @@ -19,11 +19,9 @@ | ||
| 19 | 19 | //! `~/.local/state/signald/sock`. |
| 20 | 20 | |
| 21 | 21 | use std::collections::BTreeMap; |
| 22 | use std::io::BufReader; | |
| 23 | use std::os::unix::net::UnixStream; | |
| 24 | use std::path::PathBuf; | |
| 22 | use std::path::{Path, PathBuf}; | |
| 25 | 23 | |
| 26 | use signal_schema::{wire, Signal, SignalName}; | |
| 24 | use signal_schema::{Signal, SignalName}; | |
| 27 | 25 | use terminal_garden::{plots_from_signals, render}; |
| 28 | 26 | |
| 29 | 27 | /// The `name`s the garden cares about: the git aggregates. |
| @@ -48,22 +46,17 @@ fn main() { | ||
| 48 | 46 | /// and repo tag) and re-render the garden on every frame. `Ok(())` is a clean EOF |
| 49 | 47 | /// (the daemon closed the stream). Frames this build cannot decode are skipped, |
| 50 | 48 | /// so an older renderer keeps working against a newer daemon. |
| 51 | fn run(socket: &PathBuf) -> std::io::Result<()> { | |
| 52 | let stream = UnixStream::connect(socket)?; | |
| 53 | let mut reader = BufReader::new(stream); | |
| 49 | fn run(socket: &Path) -> std::io::Result<()> { | |
| 50 | let frames = signal_client::Frames::connect(socket)?; | |
| 54 | 51 | |
| 55 | 52 | // Keyed by (metric name, source, repo tag) so per-repo signals coexist, |
| 56 | 53 | // two collectors reporting the same name stay distinct, and updates replace |
| 57 | 54 | // prior values rather than accumulating. |
| 58 | 55 | let mut latest: BTreeMap<(u8, u8, Option<String>), Signal> = BTreeMap::new(); |
| 59 | loop { | |
| 60 | let sig = match wire::read_frame(&mut reader)? { | |
| 61 | wire::Frame::Signal(sig) => sig, | |
| 62 | // A record this build does not understand: a newer daemon, or a | |
| 63 | // metric appended after this renderer was built. Keep rendering. | |
| 64 | wire::Frame::Skipped => continue, | |
| 65 | wire::Frame::Eof => break, | |
| 66 | }; | |
| 56 | // Frames this build cannot decode are skipped by the iterator, so a metric | |
| 57 | // appended after this renderer was built does not end the stream. | |
| 58 | for sig in frames { | |
| 59 | let sig = sig?; | |
| 67 | 60 | if !SUBSCRIBE.contains(&sig.name) { |
| 68 | 61 | continue; |
| 69 | 62 | } |
| @@ -111,44 +104,5 @@ fn parse_socket() -> PathBuf { | ||
| 111 | 104 | } |
| 112 | 105 | } |
| 113 | 106 | } |
| 114 | default_socket_path() | |
| 115 | } | |
| 116 | ||
| 117 | /// `$XDG_RUNTIME_DIR/signald.sock`, else `$HOME/.local/state/signald/sock`. | |
| 118 | /// | |
| 119 | /// Split from the environment so it can be tested without mutating it. The | |
| 120 | /// same resolution is duplicated in the other binary and in | |
| 121 | /// `shell-hooks/signald-hooks.zsh`; the README's "Paths" table is the one | |
| 122 | /// place they are written down, and the tests below pin them to it. | |
| 123 | fn socket_path_from(xdg_runtime_dir: Option<&str>, home: Option<&str>) -> PathBuf { | |
| 124 | if let Some(dir) = xdg_runtime_dir { | |
| 125 | return PathBuf::from(dir).join("signald.sock"); | |
| 126 | } | |
| 127 | PathBuf::from(home.unwrap_or(".")).join(".local/state/signald/sock") | |
| 128 | } | |
| 129 | ||
| 130 | fn default_socket_path() -> PathBuf { | |
| 131 | socket_path_from( | |
| 132 | std::env::var("XDG_RUNTIME_DIR").ok().as_deref(), | |
| 133 | std::env::var("HOME").ok().as_deref(), | |
| 134 | ) | |
| 135 | } | |
| 136 | ||
| 137 | #[cfg(test)] | |
| 138 | mod path_tests { | |
| 139 | use super::*; | |
| 140 | ||
| 141 | /// Pinned to the README "Paths" table. If this changes, the table and | |
| 142 | /// `shell-hooks/signald-hooks.zsh` change with it. | |
| 143 | #[test] | |
| 144 | fn socket_default_follows_xdg_then_home() { | |
| 145 | assert_eq!( | |
| 146 | socket_path_from(Some("/run/user/501"), Some("/Users/x")), | |
| 147 | PathBuf::from("/run/user/501/signald.sock") | |
| 148 | ); | |
| 149 | assert_eq!( | |
| 150 | socket_path_from(None, Some("/Users/x")), | |
| 151 | PathBuf::from("/Users/x/.local/state/signald/sock") | |
| 152 | ); | |
| 153 | } | |
| 107 | signal_client::default_socket_path() | |
| 154 | 108 | } |