macos-collector
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.
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 (10) |
hardware | Mach host_processor_info(PROCESSOR_CPU_LOAD_INFO) tick deltas |
Battery percentage [0,100] |
battery_pct (6) |
macos | IOKit power sources (IOPSCopyPowerSourcesInfo) |
| Charging (1/0) | charging (7) |
macos | IOKit power sources |
| Battery draw (W) | battery_draw_w (9) |
hardware | IORegistry AppleSmartBattery Amperage×Voltage |
Thermal state 0..3 |
thermal_state (8) |
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. 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 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 <n>] [--out <path>]
- (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 <path>— 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.
frame:
[u32 body_len] little-endian length of body
body:
[u16 schema_version] must equal 5 (v5); a reader skips 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=5, ts=0, source=Hardware(3), name=CpuLoad(10), value=0.5, tag=none encodes to these exact 25 bytes:
15 00 00 00 body_len = 21
05 00 schema_version = 5
00 00 00 00 00 00 00 00 ts = 0
03 source = Hardware
0A name = CpuLoad (10)
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::decodeparses it,wire::encodereproduces it — this is signald's own parse path), and - Swift —
Tests/CollectorCoreTests/WireTests.swift(encodeproduces 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.
(swift run macos-collector --once --hex prints real frames in this layout if
you want to check by hand.)
Wiring into signald
signald spawns this binary as a child with --interval-ms <n> 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:
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.