Commit 53651f7400
Verified · cmc ci/test: error
README.md +18 −9
| @@ -96,12 +96,15 @@ agent. Signals come from IOKit (`IOPMPowerSource` / power sources, IORegistry | ||
| 96 | 96 | metric can't be reached without root, it is simply **absent from the schema** |
| 97 | 97 | rather than gated behind sudo. Accessibility permission is never requested. |
| 98 | 98 | |
| 99 | It ships in v0.3 as **`macos-collector/`**, a sibling **Swift** package (built | |
| 100 | with `swift build`, kept out of the cargo workspace — SwiftPM and cargo do not | |
| 101 | share a build system). It reads aggregate CPU load, battery %, charging, battery | |
| 102 | draw (W), and thermal state, and emits them as `signal-schema` wire frames — the | |
| 103 | same byte format `signald` parses. GPU/fan are deliberately omitted (no clean | |
| 104 | root-free IOKit channel). See `macos-collector/README.md` for the full **Swift ↔ | |
| 99 | It ships as **`macos-collector/`**, a sibling **Swift** package (built with | |
| 100 | `swift build`, kept out of the cargo workspace — SwiftPM and cargo do not share | |
| 101 | a build system). It reads aggregate CPU load, battery %, charging, battery draw | |
| 102 | (W), and thermal state, and emits them as `signal-schema` wire frames — the | |
| 103 | same byte format `signald` parses. `signald` spawns it as a child process | |
| 104 | (found on `PATH`, or named with `--collector <path>`) and ingests the frames it | |
| 105 | writes to stdout, so hardware signals reach the hub, the history store, and | |
| 106 | every subscriber by the same path as git and terminal signals. GPU/fan are | |
| 107 | deliberately omitted (no clean root-free IOKit channel). See `macos-collector/README.md` for the full **Swift ↔ | |
| 105 | 108 | Rust wire contract** (the byte layout) and the shared canonical-frame test that |
| 106 | 109 | pins both sides to the same bytes. |
| 107 | 110 | |
| @@ -115,12 +118,13 @@ ambient-companions/ | ||
| 115 | 118 | │ │ ├── src/lib.rs |
| 116 | 119 | │ │ └── tests/privacy_invariant.rs |
| 117 | 120 | │ ├── signald/ # the daemon (collectors + live fan-out) |
| 118 | │ │ ├── src/lib.rs # git + terminal collectors, publish | |
| 121 | │ │ ├── src/lib.rs # git + terminal collectors, hardware ingest, publish | |
| 119 | 122 | │ │ ├── src/history.rs # SQLite (WAL) history store + recent() query |
| 120 | 123 | │ │ ├── src/hub.rs # last-value cache + live fan-out |
| 121 | 124 | │ │ ├── src/main.rs # CLI, producer loop, self-attestation |
| 122 | 125 | │ │ ├── tests/git_collector.rs # aggregates vs a temp git repo |
| 123 | 126 | │ │ ├── tests/streaming.rs # last-value cache + live update |
| 127 | │ │ ├── tests/hardware_ingest.rs # collector frames reach a subscriber | |
| 124 | 128 | │ │ └── tests/differential_secret_typing.rs # the full privacy ship-gate |
| 125 | 129 | │ └── terminal-garden/ # first renderer: a socket subscriber |
| 126 | 130 | │ ├── src/lib.rs # signals → plots → render (unit-tested) |
| @@ -193,8 +197,13 @@ and — for the differential test — `zpty` modules ship with zsh). | ||
| 193 | 197 | documented and pinned by a shared canonical-frame test on both sides |
| 194 | 198 | (`crates/signal-schema/tests/hardware_wire.rs` decodes the exact bytes the |
| 195 | 199 | Swift encoder commits to in `macos-collector/Tests/.../WireTests.swift`). |
| 196 | *Still todo:* the menu-bar permadeath pet, and the live socket handshake by | |
| 197 | which `signald` ingests the collector's frames (minimal/documented in v0.3). | |
| 200 | *Still todo:* the menu-bar permadeath pet. | |
| 201 | - **Hardware ingest. ✅ Done (v0.4).** | |
| 202 | `signald` spawns `macos-collector` as a child (`--collector <path>`, or found | |
| 203 | on `PATH`) and reads its stdout with the same `wire::read_frame` the socket | |
| 204 | uses (`collectors::hardware`). The five hardware signals now appear in the | |
| 205 | hub, the history store, and every subscriber's snapshot | |
| 206 | (`crates/signald/tests/hardware_ingest.rs`). | |
| 198 | 207 | - **Phase 4** — sonification (SSH-utility first, then continuous). *Out of scope.* |
| 199 | 208 | - **Phase 5** — live wallpaper (homelab, Path A) + e-ink/poster reuse. |
| 200 | 209 | *Out of scope.* |
crates/signald/src/lib.rs +63 −11
| @@ -9,7 +9,8 @@ | ||
| 9 | 9 | //! **live pub/sub bus with a last-value cache** ([`hub`], [`publish`]). The |
| 10 | 10 | //! sensitive terminal collector is aggregate-only and ships behind the now-active |
| 11 | 11 | //! differential secret-typing test (see `signal-schema/tests/privacy_invariant.rs`). |
| 12 | //! The system/hardware collector remains a stub (spec phase 3). | |
| 12 | //! v0.4 adds the **hardware path**: the daemon spawns the sibling | |
| 13 | //! `macos-collector` and ingests its wire frames ([`collectors::hardware`]). | |
| 13 | 14 | |
| 14 | 15 | use std::time::{SystemTime, UNIX_EPOCH}; |
| 15 | 16 | |
| @@ -205,19 +206,70 @@ pub mod collectors { | ||
| 205 | 206 | } |
| 206 | 207 | } |
| 207 | 208 | |
| 208 | use signal_schema::Signal; | |
| 209 | ||
| 210 | 209 | /// System + hardware collector: **IOKit-only, no powermetrics, no root** |
| 211 | 210 | /// (spec §1.4). Native macOS. |
| 212 | 211 | /// |
| 213 | /// v0.3: the real collector ships out-of-process as the sibling Swift package | |
| 214 | /// `macos-collector/` (SwiftPM, not in this cargo workspace), which reads | |
| 215 | /// aggregate hardware scalars via IOKit and emits `signal-schema` wire frames | |
| 216 | /// this daemon can parse (see `signal-schema/tests/hardware_wire.rs`). This | |
| 217 | /// in-process hook stays a stub: signald's live frame-ingest handshake for | |
| 218 | /// those frames is the next increment. | |
| 219 | pub fn system_hw_tick() -> Vec<Signal> { | |
| 220 | todo!("v0.3: hardware signals come from the sibling macos-collector Swift package") | |
| 212 | /// The reads live out of process in the sibling Swift package | |
| 213 | /// `macos-collector/` (SwiftPM, not in this cargo workspace), which writes | |
| 214 | /// `signal-schema` wire frames to stdout. This module spawns it and publishes | |
| 215 | /// every frame it emits, so hardware signals reach the hub, the history | |
| 216 | /// store, and subscribers by the same path as every other collector. Frames | |
| 217 | /// are decoded by the same `wire::read_frame` the socket uses, so one that | |
| 218 | /// fails the schema's structural checks (version, tag rule) is rejected at | |
| 219 | /// this boundary. | |
| 220 | pub mod hardware { | |
| 221 | use std::io::{self, Read}; | |
| 222 | use std::path::{Path, PathBuf}; | |
| 223 | use std::process::{Child, Command, Stdio}; | |
| 224 | use std::thread; | |
| 225 | ||
| 226 | use signal_schema::wire; | |
| 227 | ||
| 228 | use crate::hub::Hub; | |
| 229 | ||
| 230 | /// The collector binary's name, looked up on `PATH` when no explicit | |
| 231 | /// path is configured. | |
| 232 | pub const PROGRAM: &str = "macos-collector"; | |
| 233 | ||
| 234 | /// Read frames from `reader` until EOF, publishing each into `hub`. | |
| 235 | /// Returns the number of frames published. A malformed frame ends the | |
| 236 | /// stream with an error. | |
| 237 | pub fn ingest(reader: &mut impl Read, hub: &Hub) -> io::Result<usize> { | |
| 238 | let mut n = 0; | |
| 239 | while let Some(sig) = wire::read_frame(reader)? { | |
| 240 | hub.publish(sig); | |
| 241 | n += 1; | |
| 242 | } | |
| 243 | Ok(n) | |
| 244 | } | |
| 245 | ||
| 246 | /// Spawn `program` streaming frames every `interval_ms` and ingest its | |
| 247 | /// stdout on a background thread. Returns once the child is running; | |
| 248 | /// the thread logs when the child's stream ends. The child's stdout is | |
| 249 | /// a pipe, so it exits on its next write after the daemon goes away. | |
| 250 | pub fn spawn(program: &Path, interval_ms: u64, hub: Hub) -> io::Result<Child> { | |
| 251 | let mut child = Command::new(program) | |
| 252 | .arg("--interval-ms") | |
| 253 | .arg(interval_ms.to_string()) | |
| 254 | .stdin(Stdio::null()) | |
| 255 | .stdout(Stdio::piped()) | |
| 256 | .stderr(Stdio::inherit()) | |
| 257 | .spawn()?; | |
| 258 | let mut stdout = child.stdout.take().expect("stdout is piped"); | |
| 259 | thread::spawn(move || match ingest(&mut stdout, &hub) { | |
| 260 | Ok(n) => eprintln!("signald: hardware collector exited after {n} frame(s)"), | |
| 261 | Err(e) => eprintln!("signald: hardware collector stream error: {e}"), | |
| 262 | }); | |
| 263 | Ok(child) | |
| 264 | } | |
| 265 | ||
| 266 | /// Find [`PROGRAM`] on `PATH`. | |
| 267 | pub fn find_on_path() -> Option<PathBuf> { | |
| 268 | let path = std::env::var_os("PATH")?; | |
| 269 | std::env::split_paths(&path) | |
| 270 | .map(|dir| dir.join(PROGRAM)) | |
| 271 | .find(|p| p.is_file()) | |
| 272 | } | |
| 221 | 273 | } |
| 222 | 274 | } |
| 223 | 275 | |
crates/signald/src/main.rs +31 −7
| @@ -7,12 +7,14 @@ | ||
| 7 | 7 | //! ```text |
| 8 | 8 | //! zsh hooks ───▶ terminal collector ─┐ (aggregate counts from the spool) |
| 9 | 9 | //! git/fsevents ▶ git collector ──────┼─▶ hub: last-value cache + fan-out |
| 10 | //! IOKit/AppKit ▶ system+hw collector ┘ (stub, phase 3) | |
| 10 | //! IOKit/AppKit ▶ system+hw collector ┘ (macos-collector child, frames on stdout) | |
| 11 | 11 | //! ├─▶ publish: Unix socket (live) |
| 12 | 12 | //! └─▶ SQLite WAL history |
| 13 | 13 | //! ``` |
| 14 | 14 | //! |
| 15 | //! v0.2: the git and terminal collectors are live; the hub caches the latest | |
| 15 | //! The git and terminal collectors run in-process on a tick; the hardware | |
| 16 | //! collector is the sibling `macos-collector` binary, spawned as a child whose | |
| 17 | //! stdout frames are ingested into the same hub. The hub caches the latest | |
| 16 | 18 | //! value of every metric and streams updates to subscribers; every signal is |
| 17 | 19 | //! persisted to a SQLite (WAL) history store. Collector internals live in the |
| 18 | 20 | //! `signald` library crate; this binary is argument wiring, the producer loop, |
| @@ -21,11 +23,13 @@ | ||
| 21 | 23 | //! Usage: |
| 22 | 24 | //! ```text |
| 23 | 25 | //! signald [--socket <path>] [--db <path>] [--spool <path>] |
| 24 | //! [--interval-ms <n>] [<repo-path> ...] | |
| 26 | //! [--collector <path>] [--interval-ms <n>] [<repo-path> ...] | |
| 25 | 27 | //! ``` |
| 26 | 28 | //! With no repo paths, the current directory is watched. The socket defaults to |
| 27 | 29 | //! `$XDG_RUNTIME_DIR/signald.sock` (fallback `~/.local/state/signald/sock`); the |
| 28 | //! history db and terminal spool default alongside it. | |
| 30 | //! history db and terminal spool default alongside it. `--collector` names the | |
| 31 | //! `macos-collector` binary; by default it is looked up on `PATH` and skipped, | |
| 32 | //! with a log line, when absent. | |
| 29 | 33 | |
| 30 | 34 | use std::path::PathBuf; |
| 31 | 35 | use std::thread; |
| @@ -41,13 +45,14 @@ struct Config { | ||
| 41 | 45 | socket: PathBuf, |
| 42 | 46 | db: PathBuf, |
| 43 | 47 | spool: PathBuf, |
| 48 | collector: Option<PathBuf>, | |
| 44 | 49 | interval: Duration, |
| 45 | 50 | repos: Vec<PathBuf>, |
| 46 | 51 | } |
| 47 | 52 | |
| 48 | 53 | fn main() { |
| 49 | print_self_attestation(); | |
| 50 | 54 | let cfg = parse_args(); |
| 55 | print_self_attestation(&cfg); | |
| 51 | 56 | |
| 52 | 57 | let history = match History::open(&cfg.db) { |
| 53 | 58 | Ok(h) => { |
| @@ -61,6 +66,15 @@ fn main() { | ||
| 61 | 66 | }; |
| 62 | 67 | let hub = Hub::with_history(history); |
| 63 | 68 | |
| 69 | // Hardware: spawn the out-of-process collector and ingest its frames. Not | |
| 70 | // having one (Linux, or a dev build not on PATH) is not fatal. | |
| 71 | if let Some(collector) = &cfg.collector { | |
| 72 | match collectors::hardware::spawn(collector, cfg.interval.as_millis() as u64, hub.clone()) { | |
| 73 | Ok(_) => eprintln!("signald: hardware collector {}", collector.display()), | |
| 74 | Err(e) => eprintln!("signald: cannot start hardware collector {}: {e}", collector.display()), | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 64 | 78 | // Producer: poll the collectors on a tick and publish into the hub. Runs |
| 65 | 79 | // for the life of the daemon, independent of any subscriber. |
| 66 | 80 | let producer = hub.clone(); |
| @@ -90,6 +104,7 @@ fn parse_args() -> Config { | ||
| 90 | 104 | let mut socket: Option<PathBuf> = None; |
| 91 | 105 | let mut db: Option<PathBuf> = None; |
| 92 | 106 | let mut spool: Option<PathBuf> = None; |
| 107 | let mut collector: Option<PathBuf> = None; | |
| 93 | 108 | let mut interval_ms: u64 = 2000; |
| 94 | 109 | let mut repos: Vec<PathBuf> = Vec::new(); |
| 95 | 110 | |
| @@ -99,6 +114,7 @@ fn parse_args() -> Config { | ||
| 99 | 114 | "--socket" => socket = args.next().map(PathBuf::from), |
| 100 | 115 | "--db" => db = args.next().map(PathBuf::from), |
| 101 | 116 | "--spool" => spool = args.next().map(PathBuf::from), |
| 117 | "--collector" => collector = args.next().map(PathBuf::from), | |
| 102 | 118 | "--interval-ms" => { |
| 103 | 119 | interval_ms = args.next().and_then(|s| s.parse().ok()).unwrap_or(interval_ms) |
| 104 | 120 | } |
| @@ -114,6 +130,7 @@ fn parse_args() -> Config { | ||
| 114 | 130 | Config { |
| 115 | 131 | db: db.unwrap_or_else(|| base.join("signald.sqlite")), |
| 116 | 132 | spool: spool.unwrap_or_else(|| base.join("terminal.spool")), |
| 133 | collector: collector.or_else(collectors::hardware::find_on_path), | |
| 117 | 134 | interval: Duration::from_millis(interval_ms), |
| 118 | 135 | repos, |
| 119 | 136 | socket, |
| @@ -132,15 +149,22 @@ fn default_socket_path() -> PathBuf { | ||
| 132 | 149 | /// Log enabled collectors and assert none holds an input-tap capability. A real |
| 133 | 150 | /// keylogger would need one of the forbidden APIs; their absence is the point, |
| 134 | 151 | /// and this is the runtime half of that guarantee (spec §1.5). |
| 135 | fn print_self_attestation() { | |
| 152 | fn print_self_attestation(cfg: &Config) { | |
| 136 | 153 | eprintln!("signald {} — self-attestation", env!("CARGO_PKG_VERSION")); |
| 137 | 154 | eprintln!(" transport: unix socket (length-prefixed frames), live pub/sub"); |
| 138 | 155 | eprintln!(" history: sqlite (WAL), aggregate scalars only"); |
| 156 | let hardware = match &cfg.collector { | |
| 157 | Some(p) => format!("ENABLED (out-of-process {}; IOKit only, no root)", p.display()), | |
| 158 | None => format!( | |
| 159 | "DISABLED ({} not on PATH; pass --collector <path>)", | |
| 160 | collectors::hardware::PROGRAM | |
| 161 | ), | |
| 162 | }; | |
| 139 | 163 | for source in [Source::Terminal, Source::Git, Source::Macos, Source::Hardware] { |
| 140 | 164 | let state = match source { |
| 141 | 165 | Source::Git => "ENABLED (aggregate scalars only)", |
| 142 | 166 | Source::Terminal => "ENABLED (aggregate counts from zsh spool; no input tap)", |
| 143 | Source::Macos | Source::Hardware => "stub (phase 3)", | |
| 167 | Source::Macos | Source::Hardware => hardware.as_str(), | |
| 144 | 168 | }; |
| 145 | 169 | eprintln!(" collector {source:?}: input-tap capability = NONE — {state}"); |
| 146 | 170 | } |
crates/signald/tests/hardware_ingest.rs added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | //! Hardware ingest: frames written by the out-of-process collector are read by | |
| 2 | //! the same `wire::read_frame` the socket uses and published into the hub, so | |
| 3 | //! a subscriber's snapshot carries them like any other collector's signals. | |
| 4 | ||
| 5 | use std::io::Cursor; | |
| 6 | ||
| 7 | use signal_schema::{wire, Signal, SignalName, Source, Tag, Value, SCHEMA_VERSION}; | |
| 8 | use signald::collectors::hardware; | |
| 9 | use signald::hub::Hub; | |
| 10 | ||
| 11 | fn sig(source: Source, name: SignalName, value: f64) -> Signal { | |
| 12 | Signal { | |
| 13 | schema_version: SCHEMA_VERSION, | |
| 14 | ts: 1_723_100_000_000, | |
| 15 | source, | |
| 16 | name, | |
| 17 | value: Value(value), | |
| 18 | tag: None, | |
| 19 | } | |
| 20 | } | |
| 21 | ||
| 22 | /// The five signals `macos-collector` emits per tick, as it emits them. | |
| 23 | fn one_tick() -> Vec<Signal> { | |
| 24 | vec![ | |
| 25 | sig(Source::Hardware, SignalName::CpuLoad, 0.25), | |
| 26 | sig(Source::Macos, SignalName::BatteryPct, 80.0), | |
| 27 | sig(Source::Macos, SignalName::Charging, 1.0), | |
| 28 | sig(Source::Hardware, SignalName::BatteryDrawW, 12.5), | |
| 29 | sig(Source::Macos, SignalName::ThermalState, 0.0), | |
| 30 | ] | |
| 31 | } | |
| 32 | ||
| 33 | fn frames(signals: &[Signal]) -> Vec<u8> { | |
| 34 | let mut buf = Vec::new(); | |
| 35 | for s in signals { | |
| 36 | wire::write_frame(&mut buf, s).unwrap(); | |
| 37 | } | |
| 38 | buf | |
| 39 | } | |
| 40 | ||
| 41 | #[test] | |
| 42 | fn canned_frames_reach_a_subscriber_snapshot() { | |
| 43 | let hub = Hub::new(); | |
| 44 | let mut first = one_tick(); | |
| 45 | let mut second = one_tick(); | |
| 46 | second[0].value = Value(0.75); // cpu_load changes on the second tick | |
| 47 | first.append(&mut second); | |
| 48 | ||
| 49 | let n = hardware::ingest(&mut Cursor::new(frames(&first)), &hub).unwrap(); | |
| 50 | assert_eq!(n, 10, "every frame is published"); | |
| 51 | ||
| 52 | let (snapshot, _rx) = hub.subscribe(); | |
| 53 | assert_eq!(snapshot.len(), 5, "one cached value per metric"); | |
| 54 | let cpu = snapshot.iter().find(|s| s.name == SignalName::CpuLoad).unwrap(); | |
| 55 | assert_eq!(cpu.value, Value(0.75), "keep-latest"); | |
| 56 | assert_eq!(cpu.source, Source::Hardware); | |
| 57 | for name in [ | |
| 58 | SignalName::BatteryPct, | |
| 59 | SignalName::Charging, | |
| 60 | SignalName::BatteryDrawW, | |
| 61 | SignalName::ThermalState, | |
| 62 | ] { | |
| 63 | assert!(snapshot.iter().any(|s| s.name == name), "{name:?} missing"); | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | #[test] | |
| 68 | fn frame_that_breaks_the_tag_rule_is_rejected_at_the_boundary() { | |
| 69 | let hub = Hub::new(); | |
| 70 | let good = sig(Source::Hardware, SignalName::CpuLoad, 0.5); | |
| 71 | // CpuLoad never allows a tag. Encode one anyway, bypassing the schema's | |
| 72 | // well-formedness check, as a misbehaving collector could. | |
| 73 | let mut bad = good.clone(); | |
| 74 | bad.tag = Some(Tag::repo_path("/not/allowed").unwrap()); | |
| 75 | let mut buf = frames(&[good]); | |
| 76 | buf.extend(wire::encode(&bad)); | |
| 77 | ||
| 78 | let err = hardware::ingest(&mut Cursor::new(buf), &hub).unwrap_err(); | |
| 79 | assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); | |
| 80 | let snapshot = hub.snapshot(); | |
| 81 | assert_eq!(snapshot.len(), 1, "the good frame before it was published"); | |
| 82 | assert!(snapshot[0].tag.is_none()); | |
| 83 | } | |
macos-collector/README.md +14 −9
| @@ -97,12 +97,17 @@ execution is not required to prove they agree — the shared literal is the proo | ||
| 97 | 97 | (A live `swift run macos-collector --once --hex` produces frames that decode |
| 98 | 98 | byte-for-byte to this layout; see the project README for a captured run.) |
| 99 | 99 | |
| 100 | ## Wiring into signald (Phase 3 scope note) | |
| 101 | ||
| 102 | `signald`'s in-process `system_hw_tick` collector is a stub; this out-of-process | |
| 103 | Swift collector is its real realization. The **live socket handshake** by which | |
| 104 | `signald` ingests these frames is intentionally minimal in v0.3 (spec's stated | |
| 105 | Phase 3 scope): the collector emits frames to stdout / `--out` today, and the | |
| 106 | guarantee that `signald` parses them is the shared wire-contract test above | |
| 107 | (which exercises the identical `wire::decode` the socket path uses). A full | |
| 108 | cross-process ingest handshake is the next increment. | |
| 100 | ## Wiring into signald | |
| 101 | ||
| 102 | `signald` spawns this binary as a child with `--interval-ms <n>` and reads the | |
| 103 | frames it writes to stdout (`collectors::hardware` in `crates/signald`), using | |
| 104 | the same `wire::read_frame` the socket path uses. It looks for | |
| 105 | `macos-collector` on `PATH`; a dev build is named explicitly: | |
| 106 | ||
| 107 | ```sh | |
| 108 | signald --collector macos-collector/.build/debug/macos-collector | |
| 109 | ``` | |
| 110 | ||
| 111 | When the binary is absent (Linux, or not on `PATH`) signald logs that and runs | |
| 112 | without hardware signals. Stdout is a pipe, so the collector exits on its next | |
| 113 | write after signald goes away. | |
macos-collector/Sources/macos-collector/main.swift +3 −4
| @@ -15,10 +15,9 @@ import CollectorCore | ||
| 15 | 15 | // --out <path> append raw frames to <path> (a spool the daemon can tail) |
| 16 | 16 | // instead of stdout |
| 17 | 17 | // |
| 18 | // Wiring into signald: signald's live frame-ingest handshake is documented in | |
| 19 | // README.md and is intentionally minimal here (spec Phase 3 scope note). The | |
| 20 | // authoritative proof that signald parses these frames is the shared-byte | |
| 21 | // contract test on both sides (see WIRE section of the README). | |
| 18 | // Wiring into signald: signald spawns this binary as a child in the default | |
| 19 | // stream mode and reads the frames from its stdout. The shared-byte contract | |
| 20 | // test on both sides pins the format (see the WIRE section of the README). | |
| 22 | 21 | |
| 23 | 22 | func fail(_ msg: String) -> Never { |
| 24 | 23 | FileHandle.standardError.write(Data("macos-collector: \(msg)\n".utf8)) |