# macos-collector *The macOS IOKit hardware collector (spec Phase 3) — a sibling of the Rust workspace that speaks the shared wire protocol.* This is a **SwiftPM** package, kept deliberately **out** of the cargo workspace (SwiftPM and cargo do not share a build system). It is not a fifth face; it is one more **producer** for the same daemon and the same schema. It reads aggregate hardware scalars via **IOKit only — no `powermetrics`, no root** — and emits them as `signal-schema` wire frames, the exact byte format `signald` already parses. ```sh swift build # builds the collector swift test # the cross-language wire-contract test swift run macos-collector --once # one real IOKit read (summary on stderr) ``` ## What it reads (IOKit-only, no root) | Metric | `SignalName` | Source | How | |---|---|---|---| | Aggregate CPU load `[0,1]` | `cpu_load` (28) | hardware | Mach `host_processor_info(PROCESSOR_CPU_LOAD_INFO)` tick deltas | | Battery percentage `[0,100]` | `battery_pct` (17) | macos | IOKit power sources (`IOPSCopyPowerSourcesInfo`) | | Charging (1/0) | `charging` (18) | macos | IOKit power sources | | Battery draw (W) | `battery_draw_w` (27) | hardware | IORegistry `AppleSmartBattery` Amperage×Voltage | | Thermal state `0..3` | `thermal_state` (19) | macos | `ProcessInfo.thermalState` | **Intentionally omitted:** GPU utilization and fan RPM. They are reachable in principle via `IOReport`/AppleSMC, but only through chip-generation-specific, undocumented channels. Per spec §1.4/§6, a metric that cannot be read cleanly without root is **omitted from the schema rather than gated behind sudo**. Battery signals are omitted on a machine with no internal battery (a desktop). Every value is an **aggregate scalar**. This collector has no code path that can read user content: it touches only hardware counters and power registers. The `f64`-only payload and the absence of any input-tap API mean the privacy invariant (spec §1.5) holds for hardware signals exactly as it does for the terminal collector — and the repo's `forbidden_symbol_scan` gate scans these `.swift` files too. ## Modes ``` macos-collector [--once] [--hex] [--interval-ms ] [--out ] ``` - *(default)* stream raw wire frames to stdout every `--interval-ms` (default 1000). - `--once` — one tick: a human-readable summary on **stderr**, the frames on **stdout**. - `--hex` — emit each frame as a hex line (a bridge decodes; also handy to eyeball). - `--out ` — append raw frames to a file (a spool the daemon can tail) instead of stdout. ## The Swift ↔ Rust wire contract (the byte format) Both languages implement the **same** length-prefixed frame. The authoritative definition lives in Rust (`crates/signal-schema/src/lib.rs`, module `wire`); the Swift encoder (`Sources/CollectorCore/Wire.swift`) mirrors it exactly. All multi-byte integers and the `f64` (via its IEEE-754 bit pattern) are **little-endian**. ```text frame: [u32 body_len] little-endian length of body body: [u16 schema_version] must equal 3 (v0.3); a daemon drops other versions [u64 ts] unix milliseconds [u8 source] 0=terminal 1=git 2=macos 3=hardware [u8 name] SignalName discriminant (see table above) [f64 value] the ONLY payload channel — no content field exists [u8 tag_present] 0 = no tag, 1 = tag follows [u16 tag_len] present only if tag_present == 1 [tag_len bytes] UTF-8 audited identifier; hardware signals never tag ``` ### Canonical frame (pinned by tests on both sides) `schema_version=3, ts=0, source=Hardware(3), name=CpuLoad(28), value=0.5, tag=none` encodes to these exact 25 bytes: ```text 15 00 00 00 body_len = 21 03 00 schema_version = 3 00 00 00 00 00 00 00 00 ts = 0 03 source = Hardware 1C name = CpuLoad (28) 00 00 00 00 00 00 E0 3F value = 0.5 (f64 LE) 00 tag_present = 0 ``` This identical literal is asserted in **both**: - Rust — `crates/signal-schema/tests/hardware_wire.rs` (`wire::decode` parses it, `wire::encode` reproduces it — this is signald's own parse path), and - Swift — `Tests/CollectorCoreTests/WireTests.swift` (`encode` produces it). Because both sides independently commit to the same bytes, cross-*process* execution is not required to prove they agree — the shared literal is the proof. (A live `swift run macos-collector --once --hex` produces frames that decode byte-for-byte to this layout; see the project README for a captured run.) ## Wiring into signald `signald` spawns this binary as a child with `--interval-ms ` and reads the frames it writes to stdout (`collectors::hardware` in `crates/signald`), using the same `wire::read_frame` the socket path uses. It looks for `macos-collector` on `PATH`; a dev build is named explicitly: ```sh signald --collector macos-collector/.build/debug/macos-collector ``` When the binary is absent (Linux, or not on `PATH`) signald logs that and runs without hardware signals. Stdout is a pipe, so the collector exits on its next write after signald goes away.