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

macos-collector/README.md

113 lines · 5223 bytes

  1# macos-collector
  2
  3*The macOS IOKit hardware collector (spec Phase 3) — a sibling of the Rust
  4workspace that speaks the shared wire protocol.*
  5
  6This is a **SwiftPM** package, kept deliberately **out** of the cargo workspace
  7(SwiftPM and cargo do not share a build system). It is not a fifth face; it is
  8one more **producer** for the same daemon and the same schema. It reads
  9aggregate hardware scalars via **IOKit only — no `powermetrics`, no root** — and
 10emits them as `signal-schema` wire frames, the exact byte format `signald`
 11already parses.
 12
 13```sh
 14swift build            # builds the collector
 15swift test             # the cross-language wire-contract test
 16swift run macos-collector --once   # one real IOKit read (summary on stderr)
 17```
 18
 19## What it reads (IOKit-only, no root)
 20
 21| Metric | `SignalName` | Source | How |
 22|---|---|---|---|
 23| Aggregate CPU load `[0,1]` | `cpu_load` (10) | hardware | Mach `host_processor_info(PROCESSOR_CPU_LOAD_INFO)` tick deltas |
 24| Battery percentage `[0,100]` | `battery_pct` (6) | macos | IOKit power sources (`IOPSCopyPowerSourcesInfo`) |
 25| Charging (1/0) | `charging` (7) | macos | IOKit power sources |
 26| Battery draw (W) | `battery_draw_w` (9) | hardware | IORegistry `AppleSmartBattery` Amperage×Voltage |
 27| Thermal state `0..3` | `thermal_state` (8) | macos | `ProcessInfo.thermalState` |
 28
 29**Intentionally omitted:** GPU utilization and fan RPM. They are reachable in
 30principle via `IOReport`/AppleSMC, but only through chip-generation-specific,
 31undocumented channels. A metric that cannot be read cleanly without root is
 32**omitted from the schema rather than gated behind sudo**.
 33Battery signals are omitted on a machine with no internal battery (a desktop).
 34
 35Every value is an **aggregate scalar**. This collector has no code path that can
 36read user content: it touches only hardware counters and power registers. The
 37`f64`-only payload and the absence of any input-tap API mean the privacy
 38invariant holds for hardware signals exactly as it does for the
 39terminal collector — and the repo's `forbidden_symbol_scan` gate scans these
 40`.swift` files too.
 41
 42## Modes
 43
 44```
 45macos-collector [--once] [--hex] [--interval-ms <n>] [--out <path>]
 46```
 47
 48- *(default)* stream raw wire frames to stdout every `--interval-ms` (default 1000).
 49- `--once` — one tick: a human-readable summary on **stderr**, the frames on **stdout**.
 50- `--hex` — emit each frame as a hex line (a bridge decodes; also handy to eyeball).
 51- `--out <path>` — append raw frames to a file (a spool the daemon can tail) instead of stdout.
 52
 53## The Swift ↔ Rust wire contract (the byte format)
 54
 55Both languages implement the **same** length-prefixed frame. The authoritative
 56definition lives in Rust (`crates/signal-schema/src/lib.rs`, module `wire`); the
 57Swift encoder (`Sources/CollectorCore/Wire.swift`) mirrors it exactly. All
 58multi-byte integers and the `f64` (via its IEEE-754 bit pattern) are
 59**little-endian**.
 60
 61```text
 62frame:
 63  [u32  body_len]           little-endian length of body
 64  body:
 65    [u16 schema_version]    must equal 4 (v4); a reader skips other versions
 66    [u64 ts]                unix milliseconds
 67    [u8  source]            0=terminal 1=git 2=macos 3=hardware
 68    [u8  name]              SignalName discriminant (see table above)
 69    [f64 value]             the ONLY payload channel — no content field exists
 70    [u8  tag_present]       0 = no tag, 1 = tag follows
 71    [u16 tag_len]           present only if tag_present == 1
 72    [tag_len bytes]         UTF-8 audited identifier; hardware signals never tag
 73```
 74
 75### Canonical frame (pinned by tests on both sides)
 76
 77`schema_version=4, ts=0, source=Hardware(3), name=CpuLoad(10), value=0.5,
 78tag=none` encodes to these exact 25 bytes:
 79
 80```text
 8115 00 00 00                       body_len = 21
 8204 00                             schema_version = 4
 8300 00 00 00 00 00 00 00           ts = 0
 8403                                source = Hardware
 850A                                name = CpuLoad (10)
 8600 00 00 00 00 00 E0 3F           value = 0.5 (f64 LE)
 8700                                tag_present = 0
 88```
 89
 90This identical literal is asserted in **both**:
 91- Rust — `crates/signal-schema/tests/hardware_wire.rs` (`wire::decode` parses it,
 92  `wire::encode` reproduces it — this is signald's own parse path), and
 93- Swift — `Tests/CollectorCoreTests/WireTests.swift` (`encode` produces it).
 94
 95Because both sides independently commit to the same bytes, cross-*process*
 96execution is not required to prove they agree — the shared literal is the proof.
 97(`swift run macos-collector --once --hex` prints real frames in this layout if
 98you want to check by hand.)
 99
100## Wiring into signald
101
102`signald` spawns this binary as a child with `--interval-ms <n>` and reads the
103frames it writes to stdout (`collectors::hardware` in `crates/signald`), using
104the 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
108signald --collector macos-collector/.build/debug/macos-collector
109```
110
111When the binary is absent (Linux, or not on `PATH`) signald logs that and runs
112without hardware signals. Stdout is a pipe, so the collector exits on its next
113write after signald goes away.