macos-collector/README.md
108 lines · 5302 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` (28) | hardware | Mach `host_processor_info(PROCESSOR_CPU_LOAD_INFO)` tick deltas |
24| Battery percentage `[0,100]` | `battery_pct` (17) | macos | IOKit power sources (`IOPSCopyPowerSourcesInfo`) |
25| Charging (1/0) | `charging` (18) | macos | IOKit power sources |
26| Battery draw (W) | `battery_draw_w` (27) | hardware | IORegistry `AppleSmartBattery` Amperage×Voltage |
27| Thermal state `0..3` | `thermal_state` (19) | 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. Per spec §1.4/§6, a metric that cannot be read cleanly
32without root is **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 (spec §1.5) 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 3 (v0.3); a daemon drops 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=3, ts=0, source=Hardware(3), name=CpuLoad(28), value=0.5,
78tag=none` encodes to these exact 25 bytes:
79
80```text
8115 00 00 00 body_len = 21
8203 00 schema_version = 3
8300 00 00 00 00 00 00 00 ts = 0
8403 source = Hardware
851C name = CpuLoad (28)
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(A live `swift run macos-collector --once --hex` produces frames that decode
98byte-for-byte to this layout; see the project README for a captured run.)
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
103Swift 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
105Phase 3 scope): the collector emits frames to stdout / `--out` today, and the
106guarantee that `signald` parses them is the shared wire-contract test above
107(which exercises the identical `wire::decode` the socket path uses). A full
108cross-process ingest handshake is the next increment.