diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b2c5a24b..d8e6d0ae 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,7 +30,7 @@ jobs:
run:
working-directory: app
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Install Linux build dependencies
if: matrix.platform == 'ubuntu-22.04'
@@ -51,8 +51,27 @@ jobs:
if: matrix.platform == 'windows-latest'
run: dotnet publish sidecar -c Release -o sidecar/publish
+ # Failing test names are echoed as ::error:: annotations so they show up
+ # on the PR and in the checks API. Without this a failure is just
+ # "exit code 101" and you have to open the raw log to learn anything —
+ # which is painful when the failure only reproduces on one runner OS.
- name: Run tests
- run: cargo test
+ shell: bash
+ run: |
+ set +e
+ cargo test --no-fail-fast 2>&1 | tee test-output.txt
+ status=${PIPESTATUS[0]}
+ if [ "$status" -ne 0 ]; then
+ echo "::group::Failure summary"
+ grep -E '^test .* FAILED$' test-output.txt | while IFS= read -r line; do
+ echo "::error::$line"
+ done
+ # The panic messages themselves, which say *why*.
+ grep -E "panicked at|assertion .* failed" test-output.txt | head -20 |
+ while IFS= read -r line; do echo "::error::$line"; done
+ echo "::endgroup::"
+ fi
+ exit "$status"
# The web tier's correctness argument rests on never holding a lock guard
# across an await; make that a build failure rather than a review habit.
@@ -70,7 +89,7 @@ jobs:
run: cargo build --release
- name: Install cargo-packager
- run: cargo install cargo-packager --locked
+ run: cargo install cargo-packager --version 0.11.8 --locked
- name: Package installers
run: cargo packager --release --formats ${{ matrix.formats }} --verbose
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 34de2d25..039af5d8 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -28,7 +28,7 @@ jobs:
run:
working-directory: app
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Install Linux build dependencies
if: matrix.platform == 'ubuntu-22.04'
@@ -56,7 +56,7 @@ jobs:
run: cargo build --release
- name: Install cargo-packager
- run: cargo install cargo-packager --locked
+ run: cargo install cargo-packager --version 0.11.8 --locked
- name: Package installers
run: cargo packager --release --formats ${{ matrix.formats }} --verbose
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..dcedcf55
--- /dev/null
+++ b/README.md
@@ -0,0 +1,344 @@
+
+
+
+
+# SensorView
+
+**A native, cross-platform hardware monitor in pure Rust.**
+
+Real sensors on Windows, macOS and Linux. Dense HWiNFO-style tables, live graphs,
+CSV logging, a LAN dashboard and a Prometheus endpoint — in a single ~7 MB binary
+with no Electron, no webview, and no telemetry.
+
+[](https://github.com/Zektopic/sensorview/actions/workflows/ci.yml)
+
+
+
+
+
+> SensorView is a ground-up Rust rewrite that lives alongside the C#
+> OpenHardwareMonitor sources in this repository. The C# tree remains the
+> authoritative reference for low-level sensor access; the Rust app in `app/` is
+> what ships.
+>
+> HWiNFO is a proprietary product. SensorView reproduces a HWiNFO-*style* dense
+> sensor UI; it is not affiliated with or endorsed by HWiNFO.
+
+---
+
+## Why SensorView
+
+Most hardware monitors make you choose between three compromises. SensorView is
+an attempt to avoid all three.
+
+| | Typical Windows-only tool | Electron/webview monitors | Per-platform CLI tools | **SensorView** |
+|---|---|---|---|---|
+| Windows / macOS / Linux | Windows only | varies | one platform each | **all three, one codebase** |
+| Real sensors, not estimates | yes | often estimated | yes | **yes, per-platform native** |
+| Native UI | yes | no (Chromium) | terminal | **yes (egui, GPU-drawn)** |
+| Memory footprint | low | 200 MB+ | low | **~110 MB RSS measured** |
+| Remote / headless access | paid tier or none | sometimes | no | **built-in HTTP + WebSocket** |
+| Prometheus / Grafana | rarely | rarely | no | **`/metrics` built in** |
+| Kernel driver required | yes (Ring 0) | n/a | sometimes | **Windows only — never on macOS** |
+| Cost / licence | freeware or paid | varies | free | **open source** |
+
+### What actually separates it
+
+**One data model, three real backends.** Everything the UI renders comes through a
+single `SensorSource` trait, so the Windows, macOS and Linux backends are
+interchangeable and the UI, web tier, logger and graphs never branch on platform.
+Adding a backend is one trait impl and one line in a factory.
+
+**No kernel driver on macOS.** Apple Silicon sensors are read entirely through
+IOKit as an ordinary user — no helper daemon, no signed kext, no privilege
+prompt. On Windows, full sensor access still requires a Ring-0 driver, as it does
+for every tool on that platform.
+
+**Remote monitoring is not a paid tier.** The embedded HTTP server, live
+WebSocket feed and Prometheus exporter are part of the normal build. Bind it to
+loopback for local scripting or to your LAN for a headless box.
+
+**Honest blanks.** Where a value genuinely isn't available on a platform — CPUID
+on ARM, ACPI tables on Apple Silicon, S.M.A.R.T. behind Apple's storage
+controller — the field renders `—` rather than a plausible-looking guess.
+
+---
+
+## Features
+
+### Sensor monitoring
+- Dense, sortable **Sensors Status** table with current / min / max / average
+ columns, per-type icons, draggable column widths and font zoom
+- Per-sensor **history graphs** in their own windows
+- **System Summary** — CPU, motherboard, memory, GPU, drives, OS, ISA feature grid
+- **Hex Viewer** for raw firmware blobs (ACPI/SMBIOS on Windows and Linux)
+- Configurable poll interval, min/max reset, light/dark/grey themes
+
+### Data out
+- **CSV logging** at 1 Hz to Documents, one column per sensor
+- **Text report export** for pasting into bug reports
+- **REST API** — `/api/telemetry`, `/api/system`, `/api/history/{id}`, `/api/health`
+- **WebSocket** — `/ws/telemetry`, push-based live feed
+- **Prometheus** — `/metrics`, scrape straight into Grafana
+- **Web dashboard** — responsive, embedded in the binary, no external assets
+
+### Security posture
+The web tier binds to loopback by default. Bound off-loopback it becomes
+token-gated automatically: telemetry exposes hardware serials, SPD contents and
+PCI configuration space, so it is not something to leave open on a LAN. The token
+is generated per run and never written to disk.
+
+---
+
+## Platform support
+
+| | Windows | macOS (Apple Silicon) | Linux |
+|---|---|---|---|
+| Backend | LibreHardwareMonitor sidecar | native IOKit | native sysfs/procfs |
+| Temperatures | ✅ | ✅ | ✅ |
+| Power | ✅ | ✅ | hwmon-dependent |
+| Clocks | ✅ | ✅ | hwmon-dependent |
+| Fan speeds | ✅ | —¹ | ✅ |
+| Load / memory | ✅ | ✅ | ✅ |
+| GPU | ✅ | ✅ | hwmon-dependent |
+| Storage | ✅ | ✅ | hwmon-dependent |
+| Battery | ✅ | ✅ | — |
+| Firmware tables | ACPI + SMBIOS | —² | ACPI |
+| Needs elevation | **yes** (Ring-0 driver) | **no** | root for some sysfs nodes |
+
+¹ Not implemented. Fans would come from the same HID sensor plane as
+temperatures (a different usage page), but development happened on a fanless
+MacBook Air where there was nothing to read or verify against.
+² Apple Silicon has no ACPI or SMBIOS; the firmware uses an ARM device tree.
+
+Intel Macs are **not** supported. They would need a completely different
+`AppleSMC` backend, which does not exist on M-series hardware and could not be
+tested during development.
+
+---
+
+## Install
+
+Download the installer for your platform from [Releases](https://github.com/Zektopic/sensorview/releases):
+
+| Platform | Artifact | Notes |
+|---|---|---|
+| Windows | `SensorView-setup.exe` (NSIS) | Bundles the sensor sidecar; optionally installs the PawnIO driver |
+| macOS | `SensorView__aarch64.dmg` | Apple Silicon only; **unsigned** — see below |
+| Linux | `.deb` / `.AppImage` | |
+
+**macOS first launch:** the `.dmg` is currently unsigned and un-notarized, so
+Gatekeeper will block it. Open **System Settings → Privacy & Security**, find the
+blocked-app notice, and choose **Open Anyway**. (On older macOS the right-click →
+Open trick also works.)
+
+**Windows:** full sensor coverage needs a Ring-0 driver. If Memory Integrity/HVCI
+blocks the classic WinRing0 driver, install [PawnIO](https://pawnio.eu/) — the
+installer offers this, and Settings → Driver Management explains the state.
+
+---
+
+## Build from source
+
+Requires [Rust](https://rustup.rs) 1.92+.
+
+```bash
+git clone https://github.com/Zektopic/sensorview.git
+cd sensorview/app
+cargo run --release
+```
+
+Platform prerequisites:
+
+```bash
+# Windows — also build the sensor sidecar (needs .NET 8 SDK)
+dotnet publish sidecar -c Release -o sidecar/publish
+
+# Linux
+sudo apt-get install -y libgtk-3-dev libxcb-render0-dev libxcb-shape0-dev \
+ libxcb-xfixes0-dev libxkbcommon-dev libwayland-dev libssl-dev
+
+# macOS — Xcode Command Line Tools are sufficient
+xcode-select --install
+```
+
+Useful flags:
+
+```bash
+SENSORVIEW_SOURCE=demo cargo run # synthetic data, no drivers — good for UI work
+cargo run --no-default-features # GUI only, drops the web tier (no listening socket)
+cargo packager --release --formats dmg # or nsis / deb / appimage
+```
+
+---
+
+## Technical breakdown
+
+### Threading model
+
+Four threads, deliberately decoupled so the UI can never be blocked by hardware.
+
+```
+┌──────────────┐ ArcSwap ┌──────────────┐
+│ poll thread │─────────────▶│ UI (main) │ eframe/egui, winit needs main
+│ fast lane │ └──────────────┘
+│ ~1 Hz │ broadcast ┌──────────────┐
+└──────┬───────┘─────────────▶│ web thread │ tokio + axum
+ │ └──────────────┘
+┌──────▼───────┐
+│ slow lane │ ~30 s — S.M.A.R.T., SPD, PCIe topology, firmware tables
+└──────────────┘
+```
+
+The latest telemetry frame is published through an `ArcSwap`, so a UI read is a
+single atomic pointer load — the render loop never contends with the poller and
+never holds a lock across a frame. Settings changes are *sent* to the poll thread
+as commands rather than applied under a shared lock.
+
+**Why two polling lanes.** S.M.A.R.T. and NVMe log pages keep drives out of
+low-power states and burn a limited read budget; SPD and EC reads go over
+SMBus/I²C, where polling faster than ~2 Hz collides with firmware and shows up as
+audio dropouts; PCIe topology only changes on hotplug. Those are separated into a
+30-second lane behind a distinct `InventorySource` trait so the two cadences can
+never be accidentally coupled.
+
+### Extension surface
+
+```rust
+pub trait SensorSource: Send {
+ fn name(&self) -> &'static str;
+ fn snapshot(&mut self) -> Vec;
+ fn diagnostics(&self) -> Diagnostics { Diagnostics::default() }
+}
+
+pub trait InventorySource: Send {
+ fn name(&self) -> &'static str;
+ fn collect(&mut self) -> Inventory; // may block for seconds
+}
+```
+
+That is the whole platform abstraction — no registry, no plugin system. The data
+model (`SensorType`, `HardwareType`, `Sensor`, `Hardware`) mirrors
+OpenHardwareMonitor's `ISensor.cs` / `IHardware.cs` so the C# reference and the
+Rust port speak the same vocabulary.
+
+### How each platform reads hardware
+
+**Windows** spawns a .NET 8 LibreHardwareMonitor sidecar and reads
+newline-delimited JSON from its stdout — one `{"meta": …}` line, then one tree
+per tick. Keeping LHM out-of-process means a driver fault takes down the sidecar,
+not the UI. Static system info comes from WMI in-process; ACPI/SMBIOS come from
+`EnumSystemFirmwareTables`, which needs no driver at all.
+
+**macOS (Apple Silicon)** reads everything in-process through IOKit:
+
+| Data | Mechanism |
+|---|---|
+| Die temperatures | `IOHIDEventSystemClient` sensor plane (private) |
+| CPU/GPU/ANE/DRAM power | `libIOReport` "Energy Model" (private, `dlopen`ed) |
+| CPU/GPU clocks + VID | IOReport DVFS residency × `pmgr` `voltage-states` |
+| CPU load, memory | Mach `host_processor_info` / `host_statistics64` |
+| GPU load, memory | `IOAccelerator` `PerformanceStatistics` |
+| SSD throughput | `IOBlockStorageDriver` statistics |
+| Battery | `AppleSmartBattery` |
+
+Apple Silicon exposes no "current MHz" register, so clocks are *reconstructed*:
+IOReport gives residency per DVFS state, the `pmgr` device-tree node gives the
+frequency/voltage table, and weighting one by the other yields the effective
+clock and voltage — the same quantity `powermetrics` reports, without needing
+root.
+
+Two of those are private frameworks. Every symbol is resolved at runtime and
+every collector degrades to producing no sensors rather than panicking, because
+the release profile is `panic = "abort"` and a missing symbol must not take down
+the app. This is fine for `.dmg` distribution and notarization, but rules out the
+Mac App Store. `AppleSMC` is deliberately unused — it does not exist on M-series
+Macs.
+
+**Linux** reads `/sys/class/hwmon`, `/proc/stat` and `/proc/meminfo` directly,
+plus `/sys/class/dmi/id` for board identity and `/sys/firmware/acpi/tables` for
+the Hex Viewer.
+
+### Design constraints worth knowing
+
+- **Rate-derived sensors never disappear.** Power, clocks and throughput are all
+ deltas, so they produce nothing on the first poll. A sensor that has no reading
+ publishes `value: None` (rendered `—`) rather than being dropped from the tree
+ — otherwise rows flicker in and out and history graphs break. There is a
+ regression test that polls repeatedly and asserts the published identifier set
+ never changes.
+- **Sensor identifiers are stable strings**, not indices. Graph history, CSV
+ columns and the web API all key off them.
+- **`panic = "abort"` in release.** Anything that can be absent is an `Option`,
+ never an `unwrap`.
+
+### Repository layout
+
+```
+sensorview/
+├── app/ # the Rust application — this is what ships
+│ ├── src/
+│ │ ├── main.rs # entry point, thread wiring
+│ │ ├── model/ # Sensor/Hardware model, storage, topology, hex blobs
+│ │ ├── source/ # SensorSource trait + backends
+│ │ │ ├── lhm_bridge.rs # Windows: .NET sidecar
+│ │ │ ├── macos/ # macOS: IOKit (iokit, hid, ioreport, freq, dvfs, …)
+│ │ │ ├── linux.rs # Linux: sysfs/procfs
+│ │ │ └── demo.rs # synthetic
+│ │ ├── poll.rs # fast lane + min/max/avg
+│ │ ├── inventory.rs # slow lane
+│ │ ├── ui/ # egui windows
+│ │ └── web/ # axum server, REST, WebSocket, Prometheus
+│ ├── sidecar/ # C# LibreHardwareMonitor bridge (Windows)
+│ ├── web-dashboard/ # embedded static dashboard
+│ └── installer/ # Inno Setup script
+├── Hardware/ GUI/ WMI/ … # original C# OpenHardwareMonitor reference
+└── .github/workflows/ # CI + release (3-platform matrix)
+```
+
+---
+
+## Development
+
+```bash
+cd app
+cargo test # unit + hardware-probe tests
+cargo clippy --all-targets -- -D warnings # CI gate
+cargo check --no-default-features --all-targets # GUI-only build must keep working
+```
+
+Hardware-dependent tests skip with a `SKIP:` note when the underlying device or
+API isn't present, so they stay green on virtualized CI runners while still
+asserting ranges, uniqueness and stability on real hardware. Two `#[ignore]`d
+diagnostic probes dump live readings:
+
+```bash
+cargo test -- --ignored --nocapture dump_live_tree
+cargo test -- --ignored --nocapture dump_system_summary
+```
+
+CI builds and packages on `windows-latest`, `ubuntu-22.04` and `macos-latest`;
+tagged `v*` pushes publish installers to Releases.
+
+---
+
+## Status and known gaps
+
+- macOS fan sensors are **not implemented** — see the platform table footnote.
+- macOS `.dmg` is unsigned and un-notarized.
+- Intel Macs are unsupported.
+- NVMe S.M.A.R.T. health on macOS reports identity only — Apple's controller
+ isn't a standard NVMe endpoint, so the health log page is unreachable; those
+ fields report `Unknown` rather than a fabricated "Good".
+- Linux coverage depends on what your `hwmon` drivers expose.
+
+## Contributing
+
+Adding a platform backend means implementing `SensorSource`, adding one arm to
+`source::default_source()`, and — importantly — widening the fallback arm's
+`not(any(...))` so two definitions don't end up live at once.
+
+## Licence
+
+The Rust application and the OpenHardwareMonitor reference sources are covered by
+the licences in [`Licenses/`](Licenses/).
diff --git a/app/Cargo.lock b/app/Cargo.lock
index 0322d831..ecb6e8c5 100644
--- a/app/Cargo.lock
+++ b/app/Cargo.lock
@@ -2229,6 +2229,15 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+[[package]]
+name = "mach2"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "matchit"
version = "0.8.4"
@@ -3370,12 +3379,16 @@ version = "0.1.0"
dependencies = [
"arc-swap",
"axum",
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
"dirs",
"eframe",
"egui_extras",
"egui_tiles",
"futures-util",
"image",
+ "libc",
+ "mach2",
"mime_guess",
"rand 0.9.5",
"rust-embed",
diff --git a/app/Cargo.toml b/app/Cargo.toml
index 5a2fce54..4f03a8de 100644
--- a/app/Cargo.toml
+++ b/app/Cargo.toml
@@ -34,6 +34,21 @@ futures-util = { version = "0.3", default-features = false, optional = true }
[target.'cfg(windows)'.dependencies]
wmi = "0.18"
+# --- macOS (Apple Silicon) native backend ----------------------------------
+# IOKit itself is hand-declared (`#[link(name = "IOKit", kind = "framework")]`,
+# see src/source/macos/iokit.rs) to match the extern-block style used for
+# advapi32/kernel32 elsewhere. CoreFoundation is the exception: IOKit returns
+# CFDictionary/CFArray/CFNumber trees, and hand-rolling retain/release around
+# those is where that idiom stops paying for itself.
+[target.'cfg(target_os = "macos")'.dependencies]
+core-foundation = "0.10"
+core-foundation-sys = "0.8"
+# sysctlbyname, dlopen/dlsym (libIOReport is not on disk — see ioreport.rs),
+# geteuid.
+libc = "0.2"
+# host_processor_info / host_statistics64 for per-core load and memory.
+mach2 = "0.4"
+
[features]
default = ["web"]
# The LAN dashboard tier. Compiling without it drops tokio/axum entirely and
@@ -48,7 +63,9 @@ web = [
"dep:futures-util",
]
-[build-dependencies]
+# build.rs only ever touches Windows resources, so don't make Linux/macOS
+# builds compile winresource to reach a branch that is `if false` for them.
+[target.'cfg(windows)'.build-dependencies]
winresource = "0.1"
[profile.release]
diff --git a/app/build.rs b/app/build.rs
index 8fbd2b64..d8f825d5 100644
--- a/app/build.rs
+++ b/app/build.rs
@@ -1,9 +1,22 @@
fn main() {
+ #[cfg(windows)]
+ windows_resources();
+}
+
+// `winresource` is a Windows-host-only build-dependency (see Cargo.toml), so
+// this whole function must be cfg'd out elsewhere — the `if` below is a runtime
+// check on the *target*, which is not enough to keep the crate path resolvable
+// when building on macOS/Linux.
+#[cfg(windows)]
+fn windows_resources() {
// Embed the app icon + version info into the Windows executable, and — for
// release builds only — a requireAdministrator manifest so the app elevates
// at launch like HWiNFO does (full sensor access needs admin: Super-I/O,
// MSR, SMBus via the kernel driver). Debug builds stay asInvoker so
// `cargo test` / dev runs don't trip UAC.
+ //
+ // macOS needs no equivalent: sensors there are read through IOKit with no
+ // driver and no elevation, and cargo-packager generates the Info.plist.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let mut res = winresource::WindowsResource::new();
res.set_icon("assets/icon.ico");
diff --git a/app/src/inventory.rs b/app/src/inventory.rs
index c895550c..0e114f99 100644
Binary files a/app/src/inventory.rs and b/app/src/inventory.rs differ
diff --git a/app/src/model/mod.rs b/app/src/model/mod.rs
index 2de7677a..fb04b395 100644
--- a/app/src/model/mod.rs
+++ b/app/src/model/mod.rs
@@ -85,6 +85,9 @@ pub enum HardwareType {
#[serde(alias = "GpuAmd", alias = "GpuAti")]
GpuAti,
GpuIntel,
+ /// Apple Silicon integrated GPU. Not an LHM category — added for the macOS
+ /// backend, where the GPU shares the SoC and its unified memory.
+ GpuApple,
TBalancer,
Heatmaster,
#[serde(alias = "HDD")]
diff --git a/app/src/source/macos/battery.rs b/app/src/source/macos/battery.rs
new file mode 100644
index 00000000..b13587fe
--- /dev/null
+++ b/app/src/source/macos/battery.rs
@@ -0,0 +1,147 @@
+//! Battery telemetry from the `AppleSmartBattery` IOKit service.
+//!
+//! Unprivileged and present on every portable Mac; desktops simply match no
+//! service and contribute no `Battery` node.
+
+use crate::model::{Hardware, HardwareType, SensorType};
+
+use super::iokit::{self, dict_i64};
+use super::sensor;
+
+/// `None` on desktop Macs (no battery service).
+pub fn collect() -> Option {
+ let services = iokit::matching_services("AppleSmartBattery");
+ let props = iokit::properties(services.first()?.0)?;
+
+ let mut sensors = Vec::new();
+
+ // Reported in centi-degrees Celsius: the probe read 3057 => 30.57 °C.
+ if let Some(raw) = dict_i64(&props, "Temperature") {
+ sensors.push(sensor(
+ "/battery/0/temperature/0",
+ "Battery",
+ SensorType::Temperature,
+ 0,
+ raw as f32 / 100.0,
+ ));
+ }
+
+ // Millivolts.
+ if let Some(mv) = dict_i64(&props, "Voltage") {
+ sensors.push(sensor(
+ "/battery/0/voltage/0",
+ "Battery",
+ SensorType::Voltage,
+ 0,
+ mv as f32 / 1000.0,
+ ));
+ }
+
+ // Amperage is signed: negative while discharging, positive while charging.
+ // It is stored two's-complement in an unsigned slot, so it MUST be read as
+ // i64 — as u64 the probe value 18446744073709551264 (= -352) would render
+ // as 1.8e16 A. See iokit::dict_i64.
+ if let Some(ma) = dict_i64(&props, "Amperage") {
+ sensors.push(sensor(
+ "/battery/0/current/0",
+ "Battery",
+ SensorType::Current,
+ 0,
+ ma as f32 / 1000.0,
+ ));
+
+ // Instantaneous power draw, sign-matched to the current.
+ if let Some(mv) = dict_i64(&props, "Voltage") {
+ sensors.push(sensor(
+ "/battery/0/power/0",
+ "Battery Rate",
+ SensorType::Power,
+ 0,
+ (ma as f32 / 1000.0) * (mv as f32 / 1000.0),
+ ));
+ }
+ }
+
+ // CurrentCapacity is a percentage when MaxCapacity is 100 (the modern
+ // reporting style seen on this machine); older firmware reported mAh
+ // against a real MaxCapacity, so derive the ratio rather than assuming.
+ let current = dict_i64(&props, "CurrentCapacity");
+ let max = dict_i64(&props, "MaxCapacity");
+ if let (Some(current), Some(max)) = (current, max) {
+ if max > 0 {
+ sensors.push(sensor(
+ "/battery/0/level/0",
+ "Charge Level",
+ SensorType::Level,
+ 0,
+ (current as f32 / max as f32 * 100.0).clamp(0.0, 100.0),
+ ));
+ }
+ }
+
+ if let Some(cycles) = dict_i64(&props, "CycleCount") {
+ sensors.push(sensor(
+ "/battery/0/factor/0",
+ "Cycle Count",
+ SensorType::Factor,
+ 0,
+ cycles as f32,
+ ));
+ }
+
+ // Health: full-charge capacity against the original design capacity.
+ let design = dict_i64(&props, "DesignCapacity");
+ let full = dict_i64(&props, "AppleRawMaxCapacity").or_else(|| dict_i64(&props, "NominalChargeCapacity"));
+ if let (Some(design), Some(full)) = (design, full) {
+ if design > 0 {
+ sensors.push(sensor(
+ "/battery/0/level/1",
+ "Battery Health",
+ SensorType::Level,
+ 1,
+ (full as f32 / design as f32 * 100.0).clamp(0.0, 200.0),
+ ));
+ }
+ }
+
+ if sensors.is_empty() {
+ return None;
+ }
+
+ Some(Hardware {
+ identifier: "/battery/0".into(),
+ name: "Battery".into(),
+ hardware_type: HardwareType::Battery,
+ sensors,
+ sub_hardware: Vec::new(),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Guards the signed-vs-unsigned trap: a naive u64 read of Amperage yields
+ /// ~1.8e19 mA. Anything outside a few tens of amps means the sign handling
+ /// regressed. Skipped on desktop Macs, which have no battery.
+ #[test]
+ fn battery_values_are_physically_plausible() {
+ let Some(hw) = collect() else {
+ return;
+ };
+ for s in &hw.sensors {
+ let v = s.value.unwrap();
+ assert!(v.is_finite(), "{} is not finite", s.name);
+ let ok = match s.sensor_type {
+ SensorType::Temperature => (-20.0..=100.0).contains(&v),
+ SensorType::Voltage => (0.0..=30.0).contains(&v),
+ SensorType::Current => (-30.0..=30.0).contains(&v),
+ SensorType::Power => (-300.0..=300.0).contains(&v),
+ SensorType::Level => (0.0..=200.0).contains(&v),
+ SensorType::Factor => (0.0..=10000.0).contains(&v),
+ _ => true,
+ };
+ assert!(ok, "{} = {v} {} is out of physical range", s.name, s.sensor_type.unit());
+ }
+ }
+}
diff --git a/app/src/source/macos/dvfs.rs b/app/src/source/macos/dvfs.rs
new file mode 100644
index 00000000..ba48ed8f
--- /dev/null
+++ b/app/src/source/macos/dvfs.rs
@@ -0,0 +1,172 @@
+//! DVFS (frequency/voltage) tables from the SoC power manager.
+//!
+//! Apple Silicon does not expose a "current MHz" register. Frequency has to be
+//! reconstructed: the `pmgr` device-tree node lists the discrete performance
+//! states each block can run at, and IOReport reports how long the block spent
+//! in each one (see [`super::ioreport`]). Multiplying the two gives an
+//! effective clock — the same thing `powermetrics` prints.
+//!
+//! The tables are packed arrays of `(frequency, voltage)` `u32` pairs. Units
+//! are **not** consistent between blocks on the same machine: on this M5 the
+//! CPU tables are in kHz (max 4,464,000 = 4464 MHz) while the GPU table is in
+//! Hz (max 1,578,000,000 = 1578 MHz), so the scale is detected from the
+//! magnitude rather than assumed.
+
+use super::iokit;
+
+/// Device-tree path to the power manager.
+const PMGR_PATH: &str = "IODeviceTree:/arm-io/pmgr";
+
+/// Which block's performance-state table to read.
+///
+/// The `-sram` variants are used for the CPU clusters because the plain
+/// `voltage-states1`/`5` entries describe a different rail; the SRAM tables are
+/// the ones whose frequencies match the cores.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Block {
+ /// Efficiency cluster.
+ Ecpu,
+ /// Performance cluster.
+ Pcpu,
+ Gpu,
+}
+
+impl Block {
+ fn property(self) -> &'static str {
+ match self {
+ Block::Ecpu => "voltage-states1-sram",
+ Block::Pcpu => "voltage-states5-sram",
+ Block::Gpu => "voltage-states9",
+ }
+ }
+}
+
+/// One DVFS performance state.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct State {
+ pub mhz: f32,
+ /// Rail voltage for this state, in volts. The tables pair every frequency
+ /// with the voltage needed to sustain it, which is where the CPU "VID"
+ /// reading comes from — there is no separate voltage sensor on Apple
+ /// Silicon.
+ pub volts: f32,
+}
+
+/// Available frequencies for a block, in MHz, in performance-state order.
+///
+/// Empty when the node or property is missing — every caller treats that as
+/// "no frequency sensors for this block" rather than an error.
+pub fn frequencies_mhz(block: Block) -> Vec {
+ states(block).into_iter().map(|s| s.mhz).collect()
+}
+
+/// Full performance-state table (frequency + voltage).
+pub fn states(block: Block) -> Vec {
+ let Some(entry) = iokit::entry_from_path(PMGR_PATH) else {
+ return Vec::new();
+ };
+ let Some(props) = iokit::properties(entry.0) else {
+ return Vec::new();
+ };
+ let Some(bytes) = iokit::dict_data(&props, block.property()) else {
+ return Vec::new();
+ };
+ parse_states(&bytes)
+}
+
+/// Decode packed `(freq, voltage)` `u32` pairs.
+fn parse_states(bytes: &[u8]) -> Vec {
+ let pairs: Vec<(u32, u32)> = bytes
+ .chunks_exact(8)
+ .map(|c| {
+ (
+ u32::from_le_bytes([c[0], c[1], c[2], c[3]]),
+ u32::from_le_bytes([c[4], c[5], c[6], c[7]]),
+ )
+ })
+ .collect();
+ if pairs.is_empty() {
+ return Vec::new();
+ }
+
+ // Detect the unit from the largest entry. No Apple SoC runs at 100 GHz, and
+ // none has a 100 MHz *maximum*, so this threshold separates Hz from kHz
+ // without needing a per-block table that would rot on the next chip.
+ let max = pairs.iter().map(|(f, _)| *f).max().unwrap_or(0) as f64;
+ let to_mhz: f64 = if max >= 100_000_000.0 { 1.0e6 } else { 1.0e3 };
+
+ pairs
+ .iter()
+ .map(|(freq, mv)| State {
+ mhz: (*freq as f64 / to_mhz) as f32,
+ // Voltages are millivolts (790 => 0.790 V).
+ volts: *mv as f32 / 1000.0,
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn cpu_tables_are_plausible_and_ascending() {
+ for block in [Block::Ecpu, Block::Pcpu] {
+ let states = frequencies_mhz(block);
+ if states.is_empty() {
+ crate::source::macos::absent(&format!("{block:?} DVFS table"));
+ continue;
+ }
+ // Every Apple core sits between a few hundred MHz and ~6 GHz. A
+ // unit-scale mistake lands far outside this on either side.
+ for mhz in &states {
+ assert!(
+ (100.0..=6000.0).contains(mhz),
+ "{block:?} state {mhz} MHz implies the kHz/Hz scale was misread"
+ );
+ }
+ let top = states.last().copied().unwrap();
+ assert!(top >= 2000.0, "{block:?} top state {top} MHz is too low");
+ }
+ }
+
+ /// The GPU table is stored in Hz where the CPU tables are in kHz — this is
+ /// the case the magnitude heuristic exists for.
+ #[test]
+ fn gpu_table_uses_a_different_unit_but_still_decodes_to_mhz() {
+ let states = frequencies_mhz(Block::Gpu);
+ if states.is_empty() {
+ return crate::source::macos::absent("GPU DVFS table");
+ }
+ let top = states.last().copied().unwrap();
+ assert!(
+ (300.0..=4000.0).contains(&top),
+ "GPU top state {top} MHz implies the Hz/kHz scale was misread"
+ );
+ }
+
+ #[test]
+ fn scale_detection_handles_both_units() {
+ // kHz-encoded: 972 MHz and 4464 MHz.
+ let khz = [972_000u32, 790, 4_464_000, 980]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::>();
+ assert_eq!(parse_states(&khz).iter().map(|s| s.mhz).collect::>(), vec![972.0, 4464.0]);
+ assert_eq!(parse_states(&khz)[0].volts, 0.790);
+
+ // Hz-encoded: 338 MHz and 1578 MHz.
+ let hz = [338_000_000u32, 500, 1_578_000_000, 900]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::>();
+ assert_eq!(parse_states(&hz).iter().map(|s| s.mhz).collect::>(), vec![338.0, 1578.0]);
+ }
+
+ #[test]
+ fn truncated_table_does_not_panic() {
+ assert!(parse_states(&[]).is_empty());
+ // Fewer than one full pair — chunks_exact drops the remainder.
+ assert!(parse_states(&[1, 2, 3]).is_empty());
+ }
+}
diff --git a/app/src/source/macos/dynlib.rs b/app/src/source/macos/dynlib.rs
new file mode 100644
index 00000000..3c93fcbe
--- /dev/null
+++ b/app/src/source/macos/dynlib.rs
@@ -0,0 +1,109 @@
+//! Runtime symbol lookup for Apple's private sensor APIs.
+//!
+//! `IOHIDEventSystemClient*` (in IOKit.framework) and the whole of
+//! `libIOReport.dylib` are SPI: the symbols exist and are callable, but they
+//! are absent from the public SDK stubs, so linking against them directly
+//! either fails at link time or hard-fails at launch if Apple ever drops them.
+//!
+//! Resolving them at runtime instead makes their absence a *recoverable*
+//! condition. That matters more than usual here: the release profile is
+//! `panic = "abort"`, so "sensor unavailable" has to be representable as data,
+//! not as a panic. Every lookup returns `Option`, and every caller degrades to
+//! producing no sensors.
+//!
+//! Consequence worth stating: an app using these can be distributed as a
+//! signed/notarized `.dmg` (notarization does not inspect SPI use) but can
+//! never ship on the Mac App Store.
+
+use std::ffi::CString;
+
+/// Look a symbol up in an explicitly opened dylib.
+pub struct Library {
+ handle: *mut libc::c_void,
+}
+
+// SAFETY: the handle is only ever passed to dlsym, which is thread-safe, and
+// the library is never closed for the process lifetime.
+unsafe impl Send for Library {}
+
+impl Library {
+ /// `None` if the library isn't present on this macOS version.
+ pub fn open(path: &str) -> Option {
+ let cpath = CString::new(path).ok()?;
+ // RTLD_LAZY|RTLD_LOCAL: we only need the symbols we ask for.
+ let handle = unsafe { libc::dlopen(cpath.as_ptr(), libc::RTLD_LAZY | libc::RTLD_LOCAL) };
+ (!handle.is_null()).then_some(Self { handle })
+ }
+
+ /// Resolve a symbol and transmute it to a function pointer type.
+ ///
+ /// # Safety
+ /// `F` must exactly match the symbol's real ABI signature. Getting this
+ /// wrong is UB, so every call site keeps the C declaration in a comment
+ /// next to the type alias.
+ pub unsafe fn symbol(&self, name: &str) -> Option {
+ debug_assert_eq!(
+ std::mem::size_of::(),
+ std::mem::size_of::<*const libc::c_void>(),
+ "F must be a plain function pointer"
+ );
+ let cname = CString::new(name).ok()?;
+ let ptr = libc::dlsym(self.handle, cname.as_ptr());
+ (!ptr.is_null()).then(|| std::mem::transmute_copy(&ptr))
+ }
+}
+
+/// Resolve a symbol from any image already loaded into the process.
+///
+/// Used for the `IOHIDEventSystemClient*` family: IOKit.framework is linked in
+/// already (see `iokit.rs`), so its private symbols are reachable without
+/// dlopen'ing the framework a second time.
+///
+/// # Safety
+/// As [`Library::symbol`] — `F` must match the real signature.
+pub unsafe fn global_symbol(name: &str) -> Option {
+ debug_assert_eq!(
+ std::mem::size_of::(),
+ std::mem::size_of::<*const libc::c_void>(),
+ "F must be a plain function pointer"
+ );
+ let cname = CString::new(name).ok()?;
+ let ptr = libc::dlsym(libc::RTLD_DEFAULT, cname.as_ptr());
+ (!ptr.is_null()).then(|| std::mem::transmute_copy(&ptr))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn missing_library_is_none_not_a_panic() {
+ assert!(Library::open("/usr/lib/libSensorViewNoSuchThing.dylib").is_none());
+ }
+
+ #[test]
+ fn missing_symbol_is_none_not_a_panic() {
+ type Fp = unsafe extern "C" fn() -> i32;
+ assert!(unsafe { global_symbol::("SensorViewNoSuchSymbol") }.is_none());
+ }
+
+ /// Documents the load-bearing assumption: libIOReport is not a file on
+ /// disk on modern macOS, it lives in the dyld shared cache, and dlopen
+ /// still resolves it. If this ever fails, power sensors go away and the
+ /// app must still run.
+ #[test]
+ fn libioreport_is_dlopenable_from_the_shared_cache() {
+ // Not asserted as a hard requirement: this is SPI, and a virtualized
+ // CI runner or a future macOS may not ship it. The point of the test is
+ // that when it IS resolvable, it resolves from the dyld shared cache
+ // rather than from a file on disk.
+ if Library::open("/usr/lib/libIOReport.dylib").is_none() {
+ eprintln!("SKIP: libIOReport.dylib is not available on this machine");
+ return;
+ }
+ assert!(
+ !std::path::Path::new("/usr/lib/libIOReport.dylib").exists(),
+ "libIOReport became a real file; the dlopen-not-link rationale is stale"
+ );
+ }
+}
diff --git a/app/src/source/macos/freq.rs b/app/src/source/macos/freq.rs
new file mode 100644
index 00000000..61ccf7c3
--- /dev/null
+++ b/app/src/source/macos/freq.rs
@@ -0,0 +1,459 @@
+//! Effective CPU and GPU clocks, reconstructed from DVFS residency.
+//!
+//! Apple Silicon exposes no "current MHz" register. IOReport's `CPU Stats` /
+//! `GPU Stats` groups instead report, per block, how many ticks were spent in
+//! each performance state since boot. Differencing two samples gives the time
+//! spent in each state during the interval, and weighting the state table from
+//! [`super::dvfs`] by that residency gives the average clock over the interval
+//! — the same quantity `powermetrics` reports.
+//!
+//! Idle residency is excluded: including it would report ~0 MHz for a mostly
+//! idle core, whereas every other monitor reports the clock the core runs at
+//! *while running*. When a block was fully idle across the interval it reports
+//! no sensor at all rather than a misleading zero.
+
+use std::time::Instant;
+
+use core_foundation::base::{CFType, TCFType};
+use core_foundation::dictionary::CFDictionary;
+use core_foundation::string::CFString;
+use core_foundation_sys::base::{CFRelease, CFTypeRef};
+use core_foundation_sys::dictionary::CFDictionaryRef;
+use core_foundation_sys::string::CFStringRef;
+
+use crate::model::{Sensor, SensorType};
+
+use super::dvfs::{self, Block, State};
+use super::dynlib::Library;
+use super::sensor;
+
+// SPI signatures, as in `ioreport.rs`:
+// int32_t IOReportStateGetCount(CFDictionaryRef);
+// CFStringRef IOReportStateGetNameForIndex(CFDictionaryRef, int32_t);
+// int64_t IOReportStateGetResidency(CFDictionaryRef, int32_t);
+type CopyChannelsInGroup =
+ unsafe extern "C" fn(CFStringRef, CFStringRef, u64, u64, u64) -> CFDictionaryRef;
+type CreateSubscription = unsafe extern "C" fn(
+ *const libc::c_void,
+ CFDictionaryRef,
+ *mut CFDictionaryRef,
+ u64,
+ CFTypeRef,
+) -> CFTypeRef;
+type CreateSamples = unsafe extern "C" fn(CFTypeRef, CFDictionaryRef, CFTypeRef) -> CFDictionaryRef;
+type CreateSamplesDelta =
+ unsafe extern "C" fn(CFDictionaryRef, CFDictionaryRef, CFTypeRef) -> CFDictionaryRef;
+type ChannelGetName = unsafe extern "C" fn(CFDictionaryRef) -> CFStringRef;
+type ChannelGetSubGroup = unsafe extern "C" fn(CFDictionaryRef) -> CFStringRef;
+type ChannelGetFormat = unsafe extern "C" fn(CFDictionaryRef) -> i32;
+type StateGetCount = unsafe extern "C" fn(CFDictionaryRef) -> i32;
+type StateGetNameForIndex = unsafe extern "C" fn(CFDictionaryRef, i32) -> CFStringRef;
+type StateGetResidency = unsafe extern "C" fn(CFDictionaryRef, i32) -> i64;
+
+/// `kIOReportFormatState` — residency counters per performance state.
+const FORMAT_STATE: i32 = 2;
+
+struct Api {
+ _library: Library,
+ create_samples: CreateSamples,
+ create_samples_delta: CreateSamplesDelta,
+ channel_name: ChannelGetName,
+ channel_subgroup: ChannelGetSubGroup,
+ channel_format: ChannelGetFormat,
+ state_count: StateGetCount,
+ state_name: StateGetNameForIndex,
+ state_residency: StateGetResidency,
+}
+
+/// One IOReport subscription. `CPU Stats` and `GPU Stats` are separate groups
+/// and cannot be subscribed to through a single call, so each gets its own —
+/// merging them would silently drop whichever came second, which is exactly
+/// the bug that made GPU clocks never appear.
+struct Group {
+ subscription: CFTypeRef,
+ channels: CFDictionaryRef,
+ prev: Option<(CFDictionaryRef, Instant)>,
+}
+
+impl Drop for Group {
+ fn drop(&mut self) {
+ unsafe {
+ if let Some((prev, _)) = self.prev.take() {
+ if !prev.is_null() {
+ CFRelease(prev.cast());
+ }
+ }
+ if !self.channels.is_null() {
+ CFRelease(self.channels.cast());
+ }
+ if !self.subscription.is_null() {
+ CFRelease(self.subscription);
+ }
+ }
+ }
+}
+
+pub struct FrequencyReporter {
+ api: Option,
+ groups: Vec,
+ ecpu: Vec,
+ pcpu: Vec,
+ gpu: Vec,
+}
+
+// SAFETY: as `ioreport::EnergyReporter` — the CF handles are owned solely by
+// this struct, which is moved once onto the poll thread and used only there.
+unsafe impl Send for FrequencyReporter {}
+
+impl FrequencyReporter {
+ pub fn new() -> Self {
+ let mut this = Self {
+ api: None,
+ groups: Vec::new(),
+ ecpu: dvfs::states(Block::Ecpu),
+ pcpu: dvfs::states(Block::Pcpu),
+ gpu: dvfs::states(Block::Gpu),
+ };
+
+ let Some(library) = Library::open("/usr/lib/libIOReport.dylib") else {
+ return this;
+ };
+ let syms = unsafe {
+ (
+ library.symbol::("IOReportCopyChannelsInGroup"),
+ library.symbol::("IOReportCreateSubscription"),
+ library.symbol::("IOReportCreateSamples"),
+ library.symbol::("IOReportCreateSamplesDelta"),
+ library.symbol::("IOReportChannelGetChannelName"),
+ library.symbol::("IOReportChannelGetSubGroup"),
+ library.symbol::("IOReportChannelGetFormat"),
+ library.symbol::("IOReportStateGetCount"),
+ library.symbol::("IOReportStateGetNameForIndex"),
+ library.symbol::("IOReportStateGetResidency"),
+ )
+ };
+ let (
+ Some(copy_channels),
+ Some(create_subscription),
+ Some(create_samples),
+ Some(create_samples_delta),
+ Some(channel_name),
+ Some(channel_subgroup),
+ Some(channel_format),
+ Some(state_count),
+ Some(state_name),
+ Some(state_residency),
+ ) = syms
+ else {
+ return this;
+ };
+
+ // One subscription per group. The subgroup filter is left null so a
+ // renamed subgroup can't silently drop everything.
+ for group in ["CPU Stats", "GPU Stats"] {
+ let cf = CFString::new(group);
+ let desired =
+ unsafe { copy_channels(cf.as_concrete_TypeRef(), std::ptr::null(), 0, 0, 0) };
+ if desired.is_null() {
+ continue;
+ }
+ let mut subscribed: CFDictionaryRef = std::ptr::null();
+ let subscription = unsafe {
+ create_subscription(std::ptr::null(), desired, &mut subscribed, 0, std::ptr::null())
+ };
+ unsafe { CFRelease(desired.cast()) };
+
+ if subscription.is_null() || subscribed.is_null() {
+ if !subscription.is_null() {
+ unsafe { CFRelease(subscription) };
+ }
+ continue;
+ }
+ this.groups.push(Group { subscription, channels: subscribed, prev: None });
+ }
+ if this.groups.is_empty() {
+ return this;
+ }
+
+ this.api = Some(Api {
+ _library: library,
+ create_samples,
+ create_samples_delta,
+ channel_name,
+ channel_subgroup,
+ channel_format,
+ state_count,
+ state_name,
+ state_residency,
+ });
+ this
+ }
+
+ /// True when the subscription exists *and* at least one DVFS table was
+ /// readable — without the table, residency can't be turned into MHz.
+ pub fn available(&self) -> bool {
+ self.api.is_some()
+ && !self.groups.is_empty()
+ && !(self.ecpu.is_empty() && self.pcpu.is_empty() && self.gpu.is_empty())
+ }
+
+ /// Clock sensors in MHz. Empty on the first call (no baseline).
+ pub fn clock_sensors(&mut self) -> Vec {
+ let Some(api) = self.api.as_ref() else {
+ return Vec::new();
+ };
+
+ // Collect the per-group deltas first so the borrow of `self.groups`
+ // ends before `read_delta` needs `&self` for the DVFS tables.
+ let mut deltas = Vec::new();
+ for group in &mut self.groups {
+ let now =
+ unsafe { (api.create_samples)(group.subscription, group.channels, std::ptr::null()) };
+ if now.is_null() {
+ continue;
+ }
+ let taken_at = Instant::now();
+
+ let Some((prev, _)) = group.prev.take() else {
+ group.prev = Some((now, taken_at));
+ continue;
+ };
+ let delta = unsafe { (api.create_samples_delta)(prev, now, std::ptr::null()) };
+ unsafe { CFRelease(prev.cast()) };
+ group.prev = Some((now, taken_at));
+
+ if !delta.is_null() {
+ deltas.push(delta);
+ }
+ }
+
+ let mut out = Vec::new();
+ for delta in deltas {
+ out.extend(self.read_delta(api, delta, &out));
+ unsafe { CFRelease(delta.cast()) };
+ }
+ out
+ }
+
+ fn read_delta(&self, api: &Api, delta: CFDictionaryRef, already: &[Sensor]) -> Vec {
+ let dict: CFDictionary =
+ unsafe { CFDictionary::wrap_under_get_rule(delta) };
+ let Some(channels) =
+ dict.find(CFString::new("IOReportChannels")).and_then(|v| super::iokit::as_array(&v))
+ else {
+ return Vec::new();
+ };
+
+ let mut out = Vec::new();
+ for channel in channels.iter() {
+ let raw = channel.as_CFTypeRef() as CFDictionaryRef;
+ if unsafe { (api.channel_format)(raw) } != FORMAT_STATE {
+ continue;
+ }
+
+ let name = cf_string(unsafe { (api.channel_name)(raw) });
+ let subgroup = cf_string(unsafe { (api.channel_subgroup)(raw) });
+
+ // Match the channel to a DVFS table. Names are firmware strings
+ // ("ECPU", "PCPU0", "GPUPH"), so match on prefix rather than
+ // equality, and check the subgroup too because both groups use
+ // similar channel names.
+ //
+ // The names deliberately contain "Core": the System Summary picks
+ // CPU clocks out of the tree by matching that substring (so it can
+ // exclude things like bus speed), and the GPU panel does the same.
+ // Renaming these to "E-Cluster"/"P-Cluster" would silently blank
+ // the Max Clock and Avg. Active Clock rows.
+ let context = format!("{subgroup} {name}");
+ let (table, label, id) = if context.contains("GPU") {
+ (&self.gpu, "GPU Core".to_string(), "gpu".to_string())
+ } else if name.starts_with("ECPU") {
+ (&self.ecpu, "E-Core Clock".to_string(), "ecpu".to_string())
+ } else if name.starts_with("PCPU") {
+ (&self.pcpu, "P-Core Clock".to_string(), "pcpu".to_string())
+ } else {
+ continue;
+ };
+ if table.is_empty() {
+ continue;
+ }
+
+ let Some((mhz, volts)) = weighted_state(api, raw, table) else {
+ continue;
+ };
+
+ // A cluster reports several channels (per-core and aggregate);
+ // keep the first per block so the UI shows one clock per cluster.
+ let identifier = format!("/applesoc/0/clock/{id}");
+ let seen = |s: &Sensor| s.identifier == identifier;
+ if out.iter().any(seen) || already.iter().any(seen) {
+ continue;
+ }
+ let index = (already.len() + out.len()) as u32;
+ out.push(sensor(&identifier, &label, SensorType::Clock, index, mhz));
+
+ // The DVFS table pairs every state with its rail voltage, so the
+ // same residency weighting yields a real VID — Apple Silicon has no
+ // separate voltage sensor. Skip it for the GPU, where the Summary
+ // has no VID column.
+ if id != "gpu" {
+ out.push(sensor(
+ &format!("/applesoc/0/voltage/{id}"),
+ // "E-Core Clock" -> "E-Core VID"; the Summary matches
+ // CPU voltages on the substring "vid".
+ &format!("{} VID", label.trim_end_matches(" Clock")),
+ SensorType::Voltage,
+ index,
+ volts,
+ ));
+ }
+ }
+ out
+ }
+}
+
+/// Residency-weighted average frequency, excluding idle states.
+///
+/// When the block was idle for the *whole* interval there is no weighted
+/// average to take. It reports the lowest running state rather than `None`,
+/// for two reasons: that is the clock the block runs at when it next wakes
+/// (and the value this function already returns for a barely-active block),
+/// and returning `None` would drop the sensor from the tree entirely — making
+/// the row flicker out of the Sensors table and the Summary whenever the GPU
+/// or a cluster went quiet.
+fn weighted_state(api: &Api, channel: CFDictionaryRef, table: &[State]) -> Option<(f32, f32)> {
+ let count = unsafe { (api.state_count)(channel) };
+ if count <= 0 {
+ return None;
+ }
+
+ let mut weighted = 0.0f64;
+ let mut weighted_v = 0.0f64;
+ let mut active = 0.0f64;
+
+ // Whether the DVFS table itself carries an entry for the idle state
+ // decides how residency indices line up with it, and the SoC is not
+ // consistent about this: the GPU table (`voltage-states9`) starts with a
+ // literal 0 MHz idle entry, while the CPU tables (`voltage-states*-sram`)
+ // start directly at the lowest running state. Getting this wrong maps
+ // every GPU state one slot low and reports a flat 0 MHz.
+ let table_includes_idle = table.first().is_some_and(|s| s.mhz == 0.0);
+ let offset = if table_includes_idle { 0 } else { 1 };
+
+ // State index 0 is idle/off on every block; the remaining indices line up
+ // with the DVFS table in order.
+ for index in 0..count {
+ let residency = unsafe { (api.state_residency)(channel, index) };
+ if residency <= 0 {
+ continue;
+ }
+ let state = cf_string(unsafe { (api.state_name)(channel, index) });
+ // Belt and braces: skip anything the firmware names as idle/off even
+ // if it isn't at index 0.
+ let idle = index == 0
+ || state.eq_ignore_ascii_case("IDLE")
+ || state.eq_ignore_ascii_case("OFF")
+ || state.eq_ignore_ascii_case("DOWN");
+ if idle {
+ continue;
+ }
+ let Some(state) = table.get((index - offset) as usize) else {
+ continue;
+ };
+ // A 0 MHz entry is an idle slot that slipped through; counting it would
+ // drag the average toward zero.
+ if state.mhz <= 0.0 {
+ continue;
+ }
+ weighted += state.mhz as f64 * residency as f64;
+ weighted_v += state.volts as f64 * residency as f64;
+ active += residency as f64;
+ }
+
+ if active <= 0.0 {
+ // Fully idle: fall back to the lowest running state.
+ return table.iter().find(|s| s.mhz > 0.0).map(|s| (s.mhz, s.volts));
+ }
+ let mhz = (weighted / active) as f32;
+ let volts = (weighted_v / active) as f32;
+ (mhz.is_finite() && volts.is_finite()).then_some((mhz, volts))
+}
+
+fn cf_string(raw: CFStringRef) -> String {
+ if raw.is_null() {
+ return String::new();
+ }
+ unsafe { CFString::wrap_under_get_rule(raw) }.to_string()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn clocks_need_a_baseline_then_report_mhz() {
+ let mut reporter = FrequencyReporter::new();
+ assert!(
+ reporter.clock_sensors().is_empty(),
+ "residency counters must not be published as a clock on the first poll"
+ );
+ if !reporter.available() {
+ return crate::source::macos::absent("IOReport CPU/GPU Stats");
+ }
+
+ // Give the clusters something to do so they leave idle, otherwise a
+ // quiet machine legitimately reports no active residency.
+ let spinner = std::thread::spawn(|| {
+ let end = Instant::now() + std::time::Duration::from_millis(400);
+ let mut x = 0u64;
+ while Instant::now() < end {
+ x = x.wrapping_mul(6364136223846793005).wrapping_add(1);
+ }
+ x
+ });
+ std::thread::sleep(std::time::Duration::from_millis(300));
+ let sensors = reporter.clock_sensors();
+ let _ = spinner.join();
+
+ if sensors.is_empty() {
+ return crate::source::macos::absent("active DVFS residency");
+ }
+ // Each cluster contributes a clock and, for the CPU, a VID derived
+ // from the same weighting.
+ for s in &sensors {
+ let v = s.value.unwrap();
+ match s.sensor_type {
+ // Anything outside this means the state table and the residency
+ // indices are misaligned, or the unit scale is wrong.
+ SensorType::Clock => assert!(
+ (100.0..=6000.0).contains(&v),
+ "{} = {v} MHz is not a plausible Apple Silicon clock",
+ s.name
+ ),
+ SensorType::Voltage => assert!(
+ (0.3..=1.5).contains(&v),
+ "{} = {v} V is not a plausible core voltage",
+ s.name
+ ),
+ other => panic!("unexpected sensor type {other:?} from the clock reporter"),
+ }
+ }
+ assert!(sensors.iter().any(|s| s.sensor_type == SensorType::Clock));
+ }
+
+ #[test]
+ fn one_clock_sensor_per_block_at_most() {
+ let mut reporter = FrequencyReporter::new();
+ let _ = reporter.clock_sensors();
+ std::thread::sleep(std::time::Duration::from_millis(200));
+ let sensors = reporter.clock_sensors();
+
+ let ids: std::collections::HashSet<_> = sensors.iter().map(|s| &s.identifier).collect();
+ assert_eq!(ids.len(), sensors.len(), "duplicate clock identifiers");
+ let clocks = sensors.iter().filter(|s| s.sensor_type == SensorType::Clock).count();
+ assert!(clocks <= 3, "expected at most E-cluster, P-cluster and GPU clocks");
+ // VID only for the two CPU clusters, never the GPU.
+ let vids = sensors.iter().filter(|s| s.sensor_type == SensorType::Voltage).count();
+ assert!(vids <= 2, "expected at most E-cluster and P-cluster VID");
+ }
+}
diff --git a/app/src/source/macos/gpu.rs b/app/src/source/macos/gpu.rs
new file mode 100644
index 00000000..3998937c
--- /dev/null
+++ b/app/src/source/macos/gpu.rs
@@ -0,0 +1,103 @@
+//! Apple integrated GPU load and memory, from the AGX accelerator's
+//! `PerformanceStatistics` dictionary.
+//!
+//! The IOKit class name is generation-specific (`AGXAcceleratorG17G` on M5,
+//! `AGXAcceleratorG16P` and so on earlier), so matching by exact class would
+//! break on every new chip. Match the stable parent class `IOAccelerator`
+//! instead and read the statistics off whatever concrete driver is attached.
+
+use crate::model::{Hardware, HardwareType, SensorType};
+
+use super::iokit::{self, dict_f64};
+use super::sensor;
+
+pub fn collect() -> Option {
+ // `IOAccelerator` is the stable superclass; every AGX driver registers as
+ // one. Fall back to the concrete AGX class in case matching by superclass
+ // ever stops working.
+ let services = {
+ let s = iokit::matching_services("IOAccelerator");
+ if s.is_empty() {
+ iokit::matching_services("AGXAccelerator")
+ } else {
+ s
+ }
+ };
+ let service = services.first()?;
+ let props = iokit::properties(service.0)?;
+ let stats = iokit::dict_dict(&props, "PerformanceStatistics")?;
+
+ let mut sensors = Vec::new();
+
+ // "Device Utilization %" is the headline busy figure Activity Monitor
+ // shows; the renderer/tiler split is useful detail underneath it.
+ if let Some(v) = dict_f64(&stats, "Device Utilization %") {
+ sensors.push(sensor("/gpu/0/load/0", "GPU Core", SensorType::Load, 0, v as f32));
+ }
+ if let Some(v) = dict_f64(&stats, "Renderer Utilization %") {
+ sensors.push(sensor("/gpu/0/load/1", "GPU Renderer", SensorType::Load, 1, v as f32));
+ }
+ if let Some(v) = dict_f64(&stats, "Tiler Utilization %") {
+ sensors.push(sensor("/gpu/0/load/2", "GPU Tiler", SensorType::Load, 2, v as f32));
+ }
+
+ // Unified memory, so this is a slice of system RAM rather than dedicated
+ // VRAM — named accordingly so it isn't mistaken for a discrete pool.
+ const MB: f64 = 1024.0 * 1024.0;
+ if let Some(v) = dict_f64(&stats, "In use system memory") {
+ sensors.push(sensor(
+ "/gpu/0/smalldata/0",
+ "GPU Memory In Use",
+ SensorType::SmallData,
+ 0,
+ (v / MB) as f32,
+ ));
+ }
+ if let Some(v) = dict_f64(&stats, "Alloc system memory") {
+ sensors.push(sensor(
+ "/gpu/0/smalldata/1",
+ "GPU Memory Allocated",
+ SensorType::SmallData,
+ 1,
+ (v / MB) as f32,
+ ));
+ }
+
+ if sensors.is_empty() {
+ return None;
+ }
+
+ // Prefer the marketing name from the registry; fall back to the SoC name.
+ let name = iokit::dict_string(&props, "model")
+ .or_else(|| {
+ crate::sysinfo::sysctl_string("machdep.cpu.brand_string").map(|c| format!("{c} GPU"))
+ })
+ .unwrap_or_else(|| "Apple GPU".to_string());
+
+ Some(Hardware {
+ identifier: "/gpu/0".into(),
+ name,
+ hardware_type: HardwareType::GpuApple,
+ sensors,
+ sub_hardware: Vec::new(),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn gpu_is_present_and_utilisation_is_a_percentage() {
+ let Some(hw) = collect() else {
+ return crate::source::macos::absent("IOAccelerator (integrated GPU)");
+ };
+ assert_eq!(hw.hardware_type, HardwareType::GpuApple);
+
+ let Some(load) = hw.sensors.iter().find(|s| s.sensor_type == SensorType::Load) else {
+ return crate::source::macos::absent("GPU utilisation statistics");
+ };
+ let v = load.value.unwrap();
+ assert!((0.0..=100.0).contains(&v), "GPU utilisation {v} out of range");
+ }
+}
diff --git a/app/src/source/macos/hid.rs b/app/src/source/macos/hid.rs
new file mode 100644
index 00000000..8cae9b45
--- /dev/null
+++ b/app/src/source/macos/hid.rs
@@ -0,0 +1,287 @@
+//! Die temperatures via the `IOHIDEventSystemClient` sensor plane.
+//!
+//! This is how Apple Silicon exposes thermals. There is no `AppleSMC` service
+//! on M-series machines (verified: `ioreg -n AppleSMC` returns nothing on M5),
+//! so the SMC key protocol that works on Intel Macs is not an option here.
+//!
+//! Sensors live as HID services on Apple's vendor usage page (0xFF00) and are
+//! read by asking each service for a temperature event. Their names are
+//! firmware strings like `"NAND CH0 temp"` or `"PMU tdev1"` and differ between
+//! chip generations, so nothing here may hardcode a specific sensor name.
+//!
+//! All symbols are resolved at runtime (see `dynlib`) and every failure path
+//! yields zero sensors rather than a panic.
+
+use core_foundation::array::CFArray;
+use core_foundation::base::{CFType, TCFType};
+use core_foundation::dictionary::CFDictionary;
+use core_foundation::number::CFNumber;
+use core_foundation::string::CFString;
+use core_foundation_sys::base::{CFAllocatorRef, CFRelease, CFTypeRef};
+use core_foundation_sys::string::CFStringRef;
+
+use crate::model::{Sensor, SensorType};
+
+use super::dynlib;
+
+/// `kHIDPage_AppleVendor`.
+const APPLE_VENDOR_USAGE_PAGE: i32 = 0xff00;
+/// `kHIDUsage_AppleVendor_TemperatureSensor`.
+const APPLE_USAGE_TEMPERATURE: i32 = 5;
+/// `kIOHIDEventTypeTemperature`.
+const EVENT_TYPE_TEMPERATURE: i64 = 15;
+/// Event fields are `(type << 16) | offset`; offset 0 is the level itself.
+const FIELD_TEMPERATURE_LEVEL: i32 = (EVENT_TYPE_TEMPERATURE as i32) << 16;
+
+// C signatures, kept beside the aliases because getting these wrong is UB:
+// IOHIDEventSystemClientRef IOHIDEventSystemClientCreate(CFAllocatorRef);
+// void IOHIDEventSystemClientSetMatching(IOHIDEventSystemClientRef, CFDictionaryRef);
+// CFArrayRef IOHIDEventSystemClientCopyServices(IOHIDEventSystemClientRef);
+// CFTypeRef IOHIDServiceClientCopyProperty(IOHIDServiceClientRef, CFStringRef);
+// IOHIDEventRef IOHIDServiceClientCopyEvent(IOHIDServiceClientRef, int64_t, int32_t, int64_t);
+// double IOHIDEventGetFloatValue(IOHIDEventRef, int32_t);
+type ClientCreate = unsafe extern "C" fn(CFAllocatorRef) -> CFTypeRef;
+type ClientSetMatching = unsafe extern "C" fn(CFTypeRef, CFTypeRef);
+type ClientCopyServices = unsafe extern "C" fn(CFTypeRef) -> CFTypeRef;
+type ServiceCopyProperty = unsafe extern "C" fn(CFTypeRef, CFStringRef) -> CFTypeRef;
+type ServiceCopyEvent = unsafe extern "C" fn(CFTypeRef, i64, i32, i64) -> CFTypeRef;
+type EventGetFloatValue = unsafe extern "C" fn(CFTypeRef, i32) -> f64;
+
+struct Api {
+ copy_services: ClientCopyServices,
+ copy_property: ServiceCopyProperty,
+ copy_event: ServiceCopyEvent,
+ event_float: EventGetFloatValue,
+}
+
+pub struct HidSensors {
+ api: Option,
+ /// The HID client, retained for the collector's lifetime. Creating one per
+ /// poll leaks kernel ports and is measurably slow.
+ client: CFTypeRef,
+ /// The matched services, captured **once**. Re-fetching them each poll
+ /// risks a different order or count, which would misalign them against
+ /// `names` and change sensor identities mid-session.
+ services: Option>,
+ /// Sensor names, index-aligned with `services`.
+ names: Vec,
+}
+
+// SAFETY: `client` is a CoreFoundation object owned solely by this struct. The
+// collector is built on the main thread and immediately moved to the polling
+// thread, then only ever touched from there — CF objects may be used from any
+// one thread at a time, and no aliasing handle is kept anywhere.
+unsafe impl Send for HidSensors {}
+
+impl Drop for HidSensors {
+ fn drop(&mut self) {
+ if !self.client.is_null() {
+ unsafe { CFRelease(self.client) };
+ }
+ }
+}
+
+impl HidSensors {
+ pub fn new() -> Self {
+ let mut this = Self {
+ api: None,
+ client: std::ptr::null(),
+ services: None,
+ names: Vec::new(),
+ };
+
+ // IOKit.framework is already linked into the process, so the private
+ // IOHID* symbols are reachable via RTLD_DEFAULT without dlopen.
+ let (create, set_matching, copy_services, copy_property, copy_event, event_float) = unsafe {
+ (
+ dynlib::global_symbol::("IOHIDEventSystemClientCreate"),
+ dynlib::global_symbol::("IOHIDEventSystemClientSetMatching"),
+ dynlib::global_symbol::("IOHIDEventSystemClientCopyServices"),
+ dynlib::global_symbol::("IOHIDServiceClientCopyProperty"),
+ dynlib::global_symbol::("IOHIDServiceClientCopyEvent"),
+ dynlib::global_symbol::("IOHIDEventGetFloatValue"),
+ )
+ };
+ // Any missing symbol means this macOS doesn't expose the sensor plane
+ // the way we expect; report no sensors instead of guessing.
+ let (Some(create), Some(set_matching), Some(copy_services), Some(copy_property), Some(copy_event), Some(event_float)) =
+ (create, set_matching, copy_services, copy_property, copy_event, event_float)
+ else {
+ return this;
+ };
+
+ let client = unsafe { create(std::ptr::null()) };
+ if client.is_null() {
+ return this;
+ }
+
+ // Restrict to Apple-vendor temperature sensors.
+ let matching = CFDictionary::from_CFType_pairs(&[
+ (
+ CFString::new("PrimaryUsagePage").as_CFType(),
+ CFNumber::from(APPLE_VENDOR_USAGE_PAGE).as_CFType(),
+ ),
+ (
+ CFString::new("PrimaryUsage").as_CFType(),
+ CFNumber::from(APPLE_USAGE_TEMPERATURE).as_CFType(),
+ ),
+ ]);
+ unsafe { set_matching(client, matching.as_CFTypeRef()) };
+
+ this.client = client;
+ this.api = Some(Api { copy_services, copy_property, copy_event, event_float });
+ // Capture the service list once, then never re-enumerate: the sensor
+ // set the app publishes has to be fixed for the lifetime of the run.
+ this.services = this.copy_services();
+ this.names = this.service_names();
+ this
+ }
+
+ /// Enumerate the matched services. Called once, from `new`.
+ fn copy_services(&self) -> Option> {
+ let api = self.api.as_ref()?;
+ if self.client.is_null() {
+ return None;
+ }
+ let array = unsafe { (api.copy_services)(self.client) };
+ if array.is_null() {
+ return None;
+ }
+ Some(unsafe { CFArray::::wrap_under_create_rule(array.cast()) })
+ }
+
+ fn service_names(&self) -> Vec {
+ let Some(api) = self.api.as_ref() else {
+ return Vec::new();
+ };
+ let Some(services) = self.services.as_ref() else {
+ return Vec::new();
+ };
+ let key = CFString::new("Product");
+ services
+ .iter()
+ .enumerate()
+ .map(|(i, service)| {
+ let raw =
+ unsafe { (api.copy_property)(service.as_CFTypeRef(), key.as_concrete_TypeRef()) };
+ if raw.is_null() {
+ return format!("Sensor {i}");
+ }
+ let value = unsafe { CFType::wrap_under_create_rule(raw) };
+ value
+ .downcast::()
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| format!("Sensor {i}"))
+ })
+ .collect()
+ }
+
+ /// True when the sensor plane resolved and matched at least one service.
+ pub fn available(&self) -> bool {
+ self.api.is_some() && !self.names.is_empty()
+ }
+
+ pub fn sensor_count(&self) -> usize {
+ self.names.len()
+ }
+
+ /// One `Temperature` sensor per matched service, **every** poll.
+ ///
+ /// A service stops answering whenever the subsystem it measures is powered
+ /// down, which is entirely normal and can happen at any moment. Dropping
+ /// the sensor then would remove its row from the Sensors table and blank
+ /// the Summary until the next tick — the flicker this collector used to
+ /// cause — and, because names are de-duplicated by position, would also
+ /// renumber every same-named sensor below it (this machine reports seven
+ /// `"gas gauge battery"` probes).
+ ///
+ /// So the set is fixed and a non-responding sensor reports `None`, which
+ /// the UI renders as "—".
+ pub fn temperatures(&self) -> Vec {
+ let Some(api) = self.api.as_ref() else {
+ return Vec::new();
+ };
+ let Some(services) = self.services.as_ref() else {
+ return Vec::new();
+ };
+
+ let mut out = Vec::new();
+ for (index, service) in services.iter().enumerate() {
+ let event =
+ unsafe { (api.copy_event)(service.as_CFTypeRef(), EVENT_TYPE_TEMPERATURE, 0, 0) };
+
+ let celsius = if event.is_null() {
+ None
+ } else {
+ let value = unsafe { (api.event_float)(event, FIELD_TEMPERATURE_LEVEL) };
+ unsafe { CFRelease(event) };
+ // Reject obvious garbage: a powered-down or misparsed sensor
+ // reports 0 or a wild value, and a fake 0 degC reading is worse
+ // than an honest blank.
+ (value.is_finite() && (1.0..=150.0).contains(&value)).then_some(value as f32)
+ };
+
+ let name = self.names.get(index).cloned().unwrap_or_else(|| format!("Sensor {index}"));
+ out.push(super::sensor_opt(
+ &format!("/applesoc/0/temperature/{index}"),
+ &name,
+ SensorType::Temperature,
+ index as u32,
+ celsius,
+ ));
+ }
+ out
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn sensor_plane_resolves_on_apple_silicon() {
+ let hid = HidSensors::new();
+ if !hid.available() {
+ return crate::source::macos::absent("IOHIDEventSystem sensor plane");
+ }
+ assert!(hid.sensor_count() > 0);
+ }
+
+ #[test]
+ fn temperatures_are_plausible_and_named() {
+ let hid = HidSensors::new();
+ let temps = hid.temperatures();
+ if temps.is_empty() {
+ return crate::source::macos::absent("live temperature sensors");
+ }
+ // Every service is published every poll; a quiet one carries None.
+ assert!(
+ temps.iter().any(|s| s.value.is_some()),
+ "no sensor produced a reading at all"
+ );
+ for s in &temps {
+ assert!(!s.name.is_empty());
+ if let Some(v) = s.value {
+ assert!((1.0..=150.0).contains(&v), "{} = {v} degC out of range", s.name);
+ }
+ }
+ }
+
+ /// Identifiers key history graphs and CSV columns, so they must be stable
+ /// and unique across polls.
+ #[test]
+ fn identifiers_are_unique_and_stable() {
+ let hid = HidSensors::new();
+ let first = hid.temperatures();
+ if first.is_empty() {
+ return crate::source::macos::absent("live temperature sensors");
+ }
+ let ids: std::collections::HashSet<_> = first.iter().map(|s| &s.identifier).collect();
+ assert_eq!(ids.len(), first.len(), "duplicate sensor identifiers");
+
+ let second = hid.temperatures();
+ let second_ids: std::collections::HashSet<_> =
+ second.iter().map(|s| &s.identifier).collect();
+ assert_eq!(ids, second_ids, "identifiers must not change between polls");
+ }
+}
diff --git a/app/src/source/macos/inventory.rs b/app/src/source/macos/inventory.rs
new file mode 100644
index 00000000..6ee79db8
--- /dev/null
+++ b/app/src/source/macos/inventory.rs
@@ -0,0 +1,179 @@
+//! Slow-lane inventory for macOS: internal storage identity and capacity.
+//!
+//! Apple Silicon has no ACPI or SMBIOS tables (the firmware describes hardware
+//! with an ARM device tree), so unlike Windows and Linux there is nothing here
+//! for the Hex Viewer to dump — `FirmwareTables` is deliberately not used.
+//!
+//! Scope limit worth being explicit about: this reports drive **identity** from
+//! IOKit, not the full NVMe Health Information Log (log page 0x02). Apple's
+//! internal controller (`AppleANS3CGv2Controller`) is not a standard NVMe
+//! endpoint and the log page is not reachable through the public registry, so
+//! `power_on_hours`, `life_remaining_pct` and the rest stay `None` rather than
+//! being invented. Drive temperature is already published as a live sensor
+//! (`NAND CH0 temp`) by the fast lane.
+
+use crate::inventory::{Inventory, InventorySource};
+use crate::model::storage::{HealthStatus, StorageHealth, StorageProtocol};
+
+use super::iokit::{self, dict_i64, dict_string};
+
+pub struct MacInventory;
+
+impl InventorySource for MacInventory {
+ fn name(&self) -> &'static str {
+ "macOS IOKit"
+ }
+
+ fn collect(&mut self) -> Inventory {
+ Inventory { storage: collect_storage(), ..Default::default() }
+ }
+}
+
+fn collect_storage() -> Vec {
+ // The whole-media size is on IOMedia, not the controller, so pair them up
+ // by order — Apple Silicon has exactly one internal NVMe controller.
+ let capacity = physical_disk_sizes();
+
+ iokit::matching_services("IONVMeController")
+ .iter()
+ .enumerate()
+ .filter_map(|(index, service)| {
+ let props = iokit::properties(service.0)?;
+ let model = dict_string(&props, "Model Number").unwrap_or_default();
+ // A controller with no model string is not something we can
+ // meaningfully report on.
+ if model.is_empty() {
+ return None;
+ }
+ Some(StorageHealth {
+ identifier: format!("/storage/{index}"),
+ model,
+ serial: dict_string(&props, "Serial Number").unwrap_or_default(),
+ firmware: dict_string(&props, "Firmware Revision").unwrap_or_default(),
+ protocol: StorageProtocol::Nvme,
+ capacity_bytes: capacity.get(index).copied(),
+ // Everything below needs the NVMe health log — see module docs.
+ temperature_c: None,
+ power_on_hours: None,
+ power_cycles: None,
+ life_remaining_pct: None,
+ total_bytes_written: None,
+ total_bytes_read: None,
+ // Not "Good": we have no health data, and claiming a healthy
+ // drive on no evidence is worse than admitting we don't know.
+ status: HealthStatus::Unknown,
+ warnings: Vec::new(),
+ attributes: Vec::new(),
+ nvme: None,
+ })
+ })
+ .collect()
+}
+
+/// Sizes of the *physical* whole disks, largest first.
+///
+/// Two filters are needed, and neither alone is sufficient:
+///
+/// - `"Whole" = Yes` drops partitions (`disk0s1`, …).
+/// - An exact class check drops APFS synthesized volumes. `IOServiceMatching`
+/// matches subclasses, so asking for `IOMedia` also returns every
+/// `AppleAPFSMedia` container — and those report `"Whole" = Yes` too, so on
+/// this machine the naive version reported four "disks" (500 GB physical
+/// plus 494 GB, 5.4 GB and 577 MB APFS containers).
+pub fn physical_disk_sizes() -> Vec {
+ let mut sizes: Vec = iokit::matching_services("IOMedia")
+ .iter()
+ .filter_map(|service| {
+ if iokit::object_class(service.0).as_deref() != Some("IOMedia") {
+ return None;
+ }
+ let props = iokit::properties(service.0)?;
+ if iokit::dict_bool(&props, "Whole") != Some(true) {
+ return None;
+ }
+ dict_i64(&props, "Size").filter(|s| *s > 0).map(|s| s as u64)
+ })
+ .collect();
+ sizes.sort_unstable_by(|a, b| b.cmp(a));
+ sizes
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn internal_drive_is_identified() {
+ let drives = collect_storage();
+ if drives.is_empty() {
+ return crate::source::macos::absent("IONVMeController");
+ }
+
+ let drive = &drives[0];
+ assert!(!drive.model.is_empty(), "model number should be populated");
+ assert_eq!(drive.protocol, StorageProtocol::Nvme);
+
+ // Capacity is paired with the controller positionally, which only
+ // holds when the machine exposes matching whole-media nodes.
+ let Some(bytes) = drive.capacity_bytes else {
+ return crate::source::macos::absent("whole-media size for the NVMe controller");
+ };
+ assert!(bytes > 1_000_000_000, "capacity {bytes} bytes is implausibly small for a disk");
+ }
+
+ /// Health fields are deliberately unset; this pins that down so nobody
+ /// later reports a cheerful "Good" without actually reading the log page.
+ #[test]
+ fn health_is_reported_as_unknown_not_invented() {
+ for drive in collect_storage() {
+ assert_eq!(drive.status, HealthStatus::Unknown);
+ assert!(drive.power_on_hours.is_none());
+ assert!(drive.life_remaining_pct.is_none());
+ }
+ }
+
+ /// Regression test for the two-filter rule in `physical_disk_sizes`.
+ /// Before the class check it returned four entries on an M5 Air — the real
+ /// 500 GB disk plus three APFS synthesized containers.
+ ///
+ /// The invariant is expressed against the registry rather than against any
+ /// particular disk layout: sizes, counts and partition-vs-container
+ /// thresholds are all properties of the machine, and CI runs on a
+ /// virtualized Mac whose layout is nothing like a laptop's.
+ #[test]
+ fn apfs_containers_are_excluded_by_class() {
+ // Every whole-media node, subclasses included — what the naive version
+ // returned.
+ let mut whole = 0usize;
+ let mut synthesized = 0usize;
+ for service in iokit::matching_services("IOMedia") {
+ let Some(props) = iokit::properties(service.0) else {
+ continue;
+ };
+ if iokit::dict_bool(&props, "Whole") != Some(true) {
+ continue;
+ }
+ if dict_i64(&props, "Size").filter(|s| *s > 0).is_none() {
+ continue;
+ }
+ whole += 1;
+ // AppleAPFSMedia and friends: matched by IOServiceMatching because
+ // it matches subclasses, which is the whole reason for the check.
+ if iokit::object_class(service.0).as_deref() != Some("IOMedia") {
+ synthesized += 1;
+ }
+ }
+
+ if whole == 0 {
+ return crate::source::macos::absent("whole IOMedia nodes");
+ }
+
+ // The filter must remove exactly the synthesized nodes — no more, no
+ // fewer — whatever this machine's disk layout happens to be.
+ assert_eq!(
+ physical_disk_sizes().len(),
+ whole - synthesized,
+ "class filter removed the wrong set ({whole} whole, {synthesized} synthesized)"
+ );
+ }
+}
diff --git a/app/src/source/macos/iokit.rs b/app/src/source/macos/iokit.rs
new file mode 100644
index 00000000..7f259da5
--- /dev/null
+++ b/app/src/source/macos/iokit.rs
@@ -0,0 +1,318 @@
+//! Minimal IOKit FFI + CoreFoundation extraction helpers.
+//!
+//! IOKit is declared by hand (rather than pulled in as a crate) to match the
+//! `extern` blocks already used for advapi32/kernel32 in `sysinfo.rs` and
+//! `firmware.rs`. CoreFoundation is *not* hand-rolled: IOKit hands back nested
+//! CFDictionary/CFArray/CFNumber trees, and getting retain/release right around
+//! those by hand is where that idiom stops paying for itself.
+//!
+//! Everything here is fallible-by-default. A service class that doesn't exist,
+//! a property that was renamed between macOS releases, or a type that isn't
+//! what we expected must all produce `None` — never a panic. The release
+//! profile is `panic = "abort"`, so a single bad `unwrap` here takes down the
+//! whole app rather than greying out one sensor.
+
+#![allow(non_upper_case_globals, non_camel_case_types)]
+
+use core_foundation::array::CFArray;
+use core_foundation::base::{CFType, TCFType};
+use core_foundation::dictionary::CFDictionary;
+use core_foundation::number::CFNumber;
+use core_foundation::string::CFString;
+use core_foundation_sys::base::{kCFAllocatorDefault, CFAllocatorRef, CFTypeRef};
+use core_foundation_sys::dictionary::CFDictionaryRef;
+use core_foundation_sys::string::CFStringRef;
+
+pub type io_object_t = u32;
+pub type io_iterator_t = io_object_t;
+pub type io_registry_entry_t = io_object_t;
+pub type kern_return_t = i32;
+pub type mach_port_t = u32;
+
+pub const KERN_SUCCESS: kern_return_t = 0;
+/// `kIOMainPortDefault` is `MACH_PORT_NULL`; passing 0 selects the default
+/// port on every supported macOS without needing the renamed-in-12.0 symbol
+/// (`kIOMasterPortDefault` → `kIOMainPortDefault`).
+pub const MAIN_PORT_DEFAULT: mach_port_t = 0;
+
+#[link(name = "IOKit", kind = "framework")]
+extern "C" {
+ fn IOServiceMatching(name: *const libc::c_char) -> CFDictionaryRef;
+ fn IOServiceGetMatchingServices(
+ main_port: mach_port_t,
+ matching: CFDictionaryRef,
+ existing: *mut io_iterator_t,
+ ) -> kern_return_t;
+ fn IOIteratorNext(iterator: io_iterator_t) -> io_object_t;
+ fn IOObjectRelease(object: io_object_t) -> kern_return_t;
+ fn IORegistryEntryCreateCFProperties(
+ entry: io_registry_entry_t,
+ properties: *mut CFDictionaryRef,
+ allocator: CFAllocatorRef,
+ options: u32,
+ ) -> kern_return_t;
+ fn IORegistryEntryGetName(entry: io_registry_entry_t, name: *mut libc::c_char) -> kern_return_t;
+ fn IOObjectGetClass(object: io_object_t, class_name: *mut libc::c_char) -> kern_return_t;
+ fn IORegistryEntryFromPath(
+ main_port: mach_port_t,
+ path: *const libc::c_char,
+ ) -> io_registry_entry_t;
+ fn IORegistryEntrySearchCFProperty(
+ entry: io_registry_entry_t,
+ plane: *const libc::c_char,
+ key: CFStringRef,
+ allocator: CFAllocatorRef,
+ options: u32,
+ ) -> CFTypeRef;
+}
+
+/// Search options for [`search_property`]: walk toward the registry root.
+pub const kIORegistryIterateRecursively: u32 = 0x0000_0001;
+pub const kIORegistryIterateParents: u32 = 0x0000_0002;
+
+/// An owned IOKit object handle. Exists so every early return releases the
+/// port — leaking `io_object_t`s in a function polled once a second is a slow
+/// resource leak that only shows up after hours of running.
+pub struct IoObject(pub io_object_t);
+
+impl Drop for IoObject {
+ fn drop(&mut self) {
+ if self.0 != 0 {
+ unsafe { IOObjectRelease(self.0) };
+ }
+ }
+}
+
+/// Every service matching an IOKit class name, e.g. `"AppleSmartBattery"`.
+///
+/// Returns an empty vec if the class does not exist on this machine — which is
+/// the normal case for plenty of classes (no fans on a MacBook Air, no
+/// `AppleSMC` at all on M-series).
+pub fn matching_services(class: &str) -> Vec {
+ let Ok(cname) = std::ffi::CString::new(class) else {
+ return Vec::new();
+ };
+ let mut out = Vec::new();
+ unsafe {
+ let matching = IOServiceMatching(cname.as_ptr());
+ if matching.is_null() {
+ return out;
+ }
+ let mut iter: io_iterator_t = 0;
+ // NB: IOServiceGetMatchingServices consumes a reference to `matching`,
+ // so it must not be released here even on the error path.
+ if IOServiceGetMatchingServices(MAIN_PORT_DEFAULT, matching, &mut iter) != KERN_SUCCESS {
+ return out;
+ }
+ let _iter_guard = IoObject(iter);
+ loop {
+ let next = IOIteratorNext(iter);
+ if next == 0 {
+ break;
+ }
+ out.push(IoObject(next));
+ }
+ }
+ out
+}
+
+/// Snapshot an entry's whole property dictionary.
+pub fn properties(entry: io_registry_entry_t) -> Option> {
+ unsafe {
+ let mut props: CFDictionaryRef = std::ptr::null();
+ if IORegistryEntryCreateCFProperties(entry, &mut props, kCFAllocatorDefault, 0)
+ != KERN_SUCCESS
+ || props.is_null()
+ {
+ return None;
+ }
+ Some(CFDictionary::wrap_under_create_rule(props))
+ }
+}
+
+/// The object's **exact** IOKit class name.
+///
+/// Needed because `IOServiceMatching` also matches subclasses: asking for
+/// `IOMedia` additionally returns every `AppleAPFSMedia` synthesized volume,
+/// which is indistinguishable by properties alone (they too report
+/// `"Whole" = Yes`). Comparing the concrete class separates real disks from
+/// APFS containers.
+pub fn object_class(object: io_object_t) -> Option {
+ // io_name_t is a fixed char[128] out-parameter.
+ let mut buf = [0i8; 128];
+ unsafe {
+ if IOObjectGetClass(object, buf.as_mut_ptr()) != KERN_SUCCESS {
+ return None;
+ }
+ Some(std::ffi::CStr::from_ptr(buf.as_ptr()).to_string_lossy().into_owned())
+ }
+}
+
+/// The registry entry's name (e.g. the `+-o ` shown by `ioreg`).
+#[allow(dead_code)] // Kept alongside object_class for registry debugging.
+pub fn entry_name(entry: io_registry_entry_t) -> Option {
+ // io_name_t is a fixed char[128] out-parameter.
+ let mut buf = [0i8; 128];
+ unsafe {
+ if IORegistryEntryGetName(entry, buf.as_mut_ptr()) != KERN_SUCCESS {
+ return None;
+ }
+ Some(std::ffi::CStr::from_ptr(buf.as_ptr()).to_string_lossy().into_owned())
+ }
+}
+
+/// Resolve a registry entry by path, e.g.
+/// `"IODeviceTree:/arm-io/pmgr"` for the power-manager node that carries the
+/// DVFS (`voltage-states*`) tables. Those live in the device-tree plane and
+/// are not an `IOService`, so `matching_services` cannot reach them.
+pub fn entry_from_path(path: &str) -> Option {
+ let cpath = std::ffi::CString::new(path).ok()?;
+ let entry = unsafe { IORegistryEntryFromPath(MAIN_PORT_DEFAULT, cpath.as_ptr()) };
+ (entry != 0).then_some(IoObject(entry))
+}
+
+/// A raw `CFData` property, e.g. a packed `voltage-states` table.
+pub fn dict_data(dict: &CFDictionary, key: &str) -> Option> {
+ let value = dict_get(dict, key)?;
+ let data = value.downcast::()?;
+ Some(data.bytes().to_vec())
+}
+
+/// Look up a property on an entry *or its ancestors*. Needed because the
+/// interesting properties often sit on a parent of the service that matched
+/// (e.g. NVMe model/serial live above the block-storage driver).
+pub fn search_property(entry: io_registry_entry_t, key: &str) -> Option {
+ let cf_key = CFString::new(key);
+ let plane = std::ffi::CString::new("IOService").ok()?;
+ unsafe {
+ let value = IORegistryEntrySearchCFProperty(
+ entry,
+ plane.as_ptr(),
+ cf_key.as_concrete_TypeRef(),
+ kCFAllocatorDefault,
+ kIORegistryIterateRecursively | kIORegistryIterateParents,
+ );
+ if value.is_null() {
+ return None;
+ }
+ Some(CFType::wrap_under_create_rule(value))
+ }
+}
+
+// ---- CoreFoundation extraction ------------------------------------------
+//
+// All of these take `&CFDictionary` and a plain &str key so
+// call sites read like dictionary lookups rather than FFI.
+
+pub fn dict_get(dict: &CFDictionary, key: &str) -> Option {
+ dict.find(CFString::new(key)).map(|v| v.clone())
+}
+
+/// Read a numeric property as a **signed** 64-bit value.
+///
+/// This is the correct default for IOKit. `AppleSmartBattery` in particular
+/// stores negative values (discharge current, `ISS`) as two's-complement in an
+/// unsigned slot — `ioreg` shows `"Amperage" = 18446744073709551264`, which is
+/// -352. Reading those as unsigned yields ~1.8e19 mA.
+pub fn dict_i64(dict: &CFDictionary, key: &str) -> Option {
+ let value = dict_get(dict, key)?;
+ let number = value.downcast::()?;
+ number.to_i64().or_else(|| number.to_f64().map(|f| f as i64))
+}
+
+pub fn dict_f64(dict: &CFDictionary, key: &str) -> Option {
+ let value = dict_get(dict, key)?;
+ let number = value.downcast::()?;
+ number.to_f64().or_else(|| number.to_i64().map(|i| i as f64))
+}
+
+pub fn dict_string(dict: &CFDictionary, key: &str) -> Option {
+ let value = dict_get(dict, key)?;
+ // Some IOKit "string" properties are actually CFData holding raw bytes
+ // (e.g. the device-tree `model`), so fall back to a lossy byte decode.
+ if let Some(s) = value.downcast::() {
+ return Some(s.to_string());
+ }
+ let data = value.downcast::()?;
+ let bytes: &[u8] = data.bytes();
+ let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
+ let s = String::from_utf8_lossy(&bytes[..end]).trim().to_string();
+ (!s.is_empty()).then_some(s)
+}
+
+pub fn dict_bool(dict: &CFDictionary, key: &str) -> Option {
+ let value = dict_get(dict, key)?;
+ if let Some(b) = value.downcast::() {
+ return Some(b.into());
+ }
+ dict_i64(dict, key).map(|v| v != 0)
+}
+
+/// Reinterpret a `CFType` as a string-keyed dictionary.
+///
+/// `downcast` only works for the untyped `CFDictionary` (it is the one that
+/// implements `ConcreteCFType`), so the type check happens there and the
+/// key/value types are re-applied afterwards. Safe because CoreFoundation
+/// dictionaries from IOKit are always `CFString`-keyed.
+pub fn as_dict(value: &CFType) -> Option> {
+ let untyped = value.downcast::()?;
+ Some(unsafe { CFDictionary::wrap_under_get_rule(untyped.as_concrete_TypeRef()) })
+}
+
+/// Reinterpret a `CFType` as an array of `CFType`. See [`as_dict`].
+pub fn as_array(value: &CFType) -> Option> {
+ let untyped = value.downcast::()?;
+ Some(unsafe { CFArray::wrap_under_get_rule(untyped.as_concrete_TypeRef()) })
+}
+
+/// A nested dictionary property, e.g. `IOBlockStorageDriver`'s `Statistics`.
+pub fn dict_dict(
+ dict: &CFDictionary,
+ key: &str,
+) -> Option> {
+ as_dict(&dict_get(dict, key)?)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// The registry root always exists, so this exercises the matching +
+ /// property-snapshot path without depending on any particular hardware.
+ #[test]
+ fn platform_expert_is_matchable_and_has_a_model() {
+ let services = matching_services("IOPlatformExpertDevice");
+ let Some(first) = services.first() else {
+ return crate::source::macos::absent("IOPlatformExpertDevice");
+ };
+ let Some(props) = properties(first.0) else {
+ return crate::source::macos::absent("IOPlatformExpertDevice properties");
+ };
+ // `model` is CFData here ("Mac17,3"), which is exactly the CFData
+ // fallback in dict_string exists for.
+ match dict_string(&props, "model") {
+ Some(model) => assert!(!model.is_empty()),
+ None => crate::source::macos::absent("IOPlatformExpertDevice `model`"),
+ }
+ }
+
+ /// A class that does not exist must yield an empty vec, not a panic — this
+ /// is the contract every collector relies on for graceful degradation.
+ #[test]
+ fn unknown_service_class_yields_empty() {
+ assert!(matching_services("SensorViewNoSuchClass").is_empty());
+ }
+
+ /// AppleSMC is absent on Apple Silicon; make sure asking for it is benign.
+ #[test]
+ fn missing_property_yields_none() {
+ let services = matching_services("IOPlatformExpertDevice");
+ let Some(first) = services.first() else {
+ return crate::source::macos::absent("IOPlatformExpertDevice");
+ };
+ let props = properties(first.0).unwrap();
+ assert!(dict_i64(&props, "SensorViewNoSuchKey").is_none());
+ assert!(dict_string(&props, "SensorViewNoSuchKey").is_none());
+ assert!(dict_dict(&props, "SensorViewNoSuchKey").is_none());
+ }
+}
diff --git a/app/src/source/macos/ioreport.rs b/app/src/source/macos/ioreport.rs
new file mode 100644
index 00000000..4f2ba43f
--- /dev/null
+++ b/app/src/source/macos/ioreport.rs
@@ -0,0 +1,445 @@
+//! CPU / GPU / ANE power via `libIOReport`.
+//!
+//! The "Energy Model" channel group publishes **cumulative energy counters**,
+//! not instantaneous power. Watts therefore come from differencing two samples
+//! and dividing by the wall-clock interval between them, which means the first
+//! poll after startup produces no power sensors at all.
+//!
+//! `libIOReport.dylib` is not a file on disk on modern macOS — it lives in the
+//! dyld shared cache — so it must be `dlopen`ed rather than linked (see
+//! `dynlib`). Every symbol is optional and every failure degrades to "no power
+//! sensors" rather than a panic.
+//!
+//! Channel names are firmware strings that differ per SoC generation, so units
+//! are read from the channel's own unit label instead of being assumed.
+
+use std::time::Instant;
+
+use core_foundation::base::{CFType, TCFType};
+use core_foundation::dictionary::CFDictionary;
+use core_foundation::string::CFString;
+use core_foundation_sys::base::{CFRelease, CFTypeRef};
+use core_foundation_sys::dictionary::CFDictionaryRef;
+use core_foundation_sys::string::CFStringRef;
+
+use crate::model::{Sensor, SensorType};
+
+use super::dynlib::Library;
+use super::sensor;
+
+// C signatures (SPI — kept here because a mismatch is UB):
+// CFMutableDictionaryRef IOReportCopyChannelsInGroup(CFStringRef group, CFStringRef subgroup,
+// uint64_t, uint64_t, uint64_t);
+// IOReportSubscriptionRef IOReportCreateSubscription(void *, CFMutableDictionaryRef desired,
+// CFMutableDictionaryRef *subbed,
+// uint64_t, CFTypeRef);
+// CFDictionaryRef IOReportCreateSamples(IOReportSubscriptionRef, CFMutableDictionaryRef, CFTypeRef);
+// CFDictionaryRef IOReportCreateSamplesDelta(CFDictionaryRef prev, CFDictionaryRef now, CFTypeRef);
+// CFStringRef IOReportChannelGetChannelName(CFDictionaryRef);
+// CFStringRef IOReportChannelGetUnitLabel(CFDictionaryRef);
+// int32_t IOReportChannelGetFormat(CFDictionaryRef);
+// int64_t IOReportSimpleGetIntegerValue(CFDictionaryRef, int32_t);
+type CopyChannelsInGroup =
+ unsafe extern "C" fn(CFStringRef, CFStringRef, u64, u64, u64) -> CFDictionaryRef;
+type CreateSubscription = unsafe extern "C" fn(
+ *const libc::c_void,
+ CFDictionaryRef,
+ *mut CFDictionaryRef,
+ u64,
+ CFTypeRef,
+) -> CFTypeRef;
+type CreateSamples = unsafe extern "C" fn(CFTypeRef, CFDictionaryRef, CFTypeRef) -> CFDictionaryRef;
+type CreateSamplesDelta =
+ unsafe extern "C" fn(CFDictionaryRef, CFDictionaryRef, CFTypeRef) -> CFDictionaryRef;
+type ChannelGetName = unsafe extern "C" fn(CFDictionaryRef) -> CFStringRef;
+type ChannelGetUnitLabel = unsafe extern "C" fn(CFDictionaryRef) -> CFStringRef;
+type ChannelGetFormat = unsafe extern "C" fn(CFDictionaryRef) -> i32;
+type SimpleGetIntegerValue = unsafe extern "C" fn(CFDictionaryRef, i32) -> i64;
+
+/// `kIOReportFormatSimple` — a single scalar per channel. The Energy Model
+/// group uses this; State/Histogram channels (CPU residency, for instance) must
+/// NOT be read with `IOReportSimpleGetIntegerValue`.
+const FORMAT_SIMPLE: i32 = 1;
+
+struct Api {
+ _library: Library,
+ create_samples: CreateSamples,
+ create_samples_delta: CreateSamplesDelta,
+ channel_name: ChannelGetName,
+ channel_unit: ChannelGetUnitLabel,
+ channel_format: ChannelGetFormat,
+ integer_value: SimpleGetIntegerValue,
+}
+
+pub struct EnergyReporter {
+ api: Option,
+ subscription: CFTypeRef,
+ channels: CFDictionaryRef,
+ /// Previous cumulative sample + when it was taken, for the rate.
+ prev: Option<(CFDictionaryRef, Instant)>,
+}
+
+// SAFETY: all three raw pointers are CoreFoundation objects owned exclusively
+// by this struct. It is constructed on the main thread, moved once onto the
+// poll thread, and thereafter used only from that thread; no handle is shared.
+unsafe impl Send for EnergyReporter {}
+
+impl Drop for EnergyReporter {
+ fn drop(&mut self) {
+ unsafe {
+ if let Some((prev, _)) = self.prev.take() {
+ if !prev.is_null() {
+ CFRelease(prev.cast());
+ }
+ }
+ if !self.channels.is_null() {
+ CFRelease(self.channels.cast());
+ }
+ if !self.subscription.is_null() {
+ CFRelease(self.subscription);
+ }
+ }
+ }
+}
+
+impl EnergyReporter {
+ pub fn new() -> Self {
+ let mut this = Self {
+ api: None,
+ subscription: std::ptr::null(),
+ channels: std::ptr::null(),
+ prev: None,
+ };
+
+ let Some(library) = Library::open("/usr/lib/libIOReport.dylib") else {
+ return this;
+ };
+
+ let (copy_channels, create_subscription, create_samples, create_samples_delta, channel_name, channel_unit, channel_format, integer_value) = unsafe {
+ (
+ library.symbol::("IOReportCopyChannelsInGroup"),
+ library.symbol::("IOReportCreateSubscription"),
+ library.symbol::("IOReportCreateSamples"),
+ library.symbol::("IOReportCreateSamplesDelta"),
+ library.symbol::("IOReportChannelGetChannelName"),
+ library.symbol::("IOReportChannelGetUnitLabel"),
+ library.symbol::("IOReportChannelGetFormat"),
+ library.symbol::("IOReportSimpleGetIntegerValue"),
+ )
+ };
+ let (
+ Some(copy_channels),
+ Some(create_subscription),
+ Some(create_samples),
+ Some(create_samples_delta),
+ Some(channel_name),
+ Some(channel_unit),
+ Some(channel_format),
+ Some(integer_value),
+ ) = (
+ copy_channels,
+ create_subscription,
+ create_samples,
+ create_samples_delta,
+ channel_name,
+ channel_unit,
+ channel_format,
+ integer_value,
+ )
+ else {
+ return this;
+ };
+
+ // "Energy Model" is the group carrying CPU/GPU/ANE energy counters.
+ let group = CFString::new("Energy Model");
+ let desired =
+ unsafe { copy_channels(group.as_concrete_TypeRef(), std::ptr::null(), 0, 0, 0) };
+ if desired.is_null() {
+ return this;
+ }
+
+ let mut subscribed: CFDictionaryRef = std::ptr::null();
+ let subscription = unsafe {
+ create_subscription(
+ std::ptr::null(),
+ desired,
+ &mut subscribed,
+ 0,
+ std::ptr::null(),
+ )
+ };
+ unsafe { CFRelease(desired.cast()) };
+
+ if subscription.is_null() || subscribed.is_null() {
+ if !subscription.is_null() {
+ unsafe { CFRelease(subscription) };
+ }
+ return this;
+ }
+
+ this.subscription = subscription;
+ this.channels = subscribed;
+ this.api = Some(Api {
+ _library: library,
+ create_samples,
+ create_samples_delta,
+ channel_name,
+ channel_unit,
+ channel_format,
+ integer_value,
+ });
+ this
+ }
+
+ /// True when libIOReport resolved and an Energy Model subscription exists.
+ pub fn available(&self) -> bool {
+ self.api.is_some() && !self.subscription.is_null()
+ }
+
+ /// Power sensors in watts. Empty on the first call (no baseline) and
+ /// whenever IOReport is unavailable.
+ pub fn power_sensors(&mut self) -> Vec {
+ let Some(api) = self.api.as_ref() else {
+ return Vec::new();
+ };
+
+ let now_sample = unsafe { (api.create_samples)(self.subscription, self.channels, std::ptr::null()) };
+ if now_sample.is_null() {
+ return Vec::new();
+ }
+ let taken_at = Instant::now();
+
+ let Some((prev_sample, prev_at)) = self.prev.take() else {
+ // First poll: store the baseline and report nothing this tick.
+ self.prev = Some((now_sample, taken_at));
+ return Vec::new();
+ };
+
+ let interval = taken_at.duration_since(prev_at).as_secs_f64();
+ let delta =
+ unsafe { (api.create_samples_delta)(prev_sample, now_sample, std::ptr::null()) };
+ unsafe { CFRelease(prev_sample.cast()) };
+ self.prev = Some((now_sample, taken_at));
+
+ if delta.is_null() || interval <= 0.0 {
+ if !delta.is_null() {
+ unsafe { CFRelease(delta.cast()) };
+ }
+ return Vec::new();
+ }
+
+ let sensors = self.read_delta(api, delta, interval);
+ unsafe { CFRelease(delta.cast()) };
+ sensors
+ }
+
+ fn read_delta(&self, api: &Api, delta: CFDictionaryRef, interval: f64) -> Vec {
+ // The delta dictionary holds an "IOReportChannels" array of per-channel
+ // dictionaries.
+ let dict: CFDictionary =
+ unsafe { CFDictionary::wrap_under_get_rule(delta) };
+ let Some(channels) =
+ dict.find(CFString::new("IOReportChannels")).and_then(|v| super::iokit::as_array(&v))
+ else {
+ return Vec::new();
+ };
+
+ let mut out = Vec::new();
+ for (index, channel) in channels.iter().enumerate() {
+ let raw = channel.as_CFTypeRef() as CFDictionaryRef;
+
+ let name = unsafe { (api.channel_name)(raw) };
+ if name.is_null() {
+ continue;
+ }
+ let name = unsafe { CFString::wrap_under_get_rule(name) }.to_string();
+
+ let unit = unsafe { (api.channel_unit)(raw) };
+ let unit = if unit.is_null() {
+ String::new()
+ } else {
+ unsafe { CFString::wrap_under_get_rule(unit) }.to_string()
+ };
+
+ // Only Simple-format channels hold a scalar. The second argument is
+ // a element index, NOT a Python-style index-from-end: passing -1
+ // makes libIOReport dereference 0xffffffff and segfault. Index 0 is
+ // the only valid element for a Simple channel.
+ if unsafe { (api.channel_format)(raw) } != FORMAT_SIMPLE {
+ continue;
+ }
+ // A rail that burned no measurable energy this interval reads 0,
+ // which is a real measurement — dropping the channel instead would
+ // make the row vanish from the table and reappear on the next tick
+ // whenever a block idles. Negative means the counter wrapped or the
+ // device re-enumerated; clamp rather than report nonsense.
+ let energy = unsafe { (api.integer_value)(raw, 0) }.max(0);
+
+ // Convert the counter's own unit to joules rather than assuming
+ // millijoules — the label differs across SoC generations.
+ let joules = match unit.trim() {
+ "mJ" => energy as f64 / 1.0e3,
+ "uJ" | "µJ" => energy as f64 / 1.0e6,
+ "nJ" => energy as f64 / 1.0e9,
+ // Unknown unit: refuse to guess rather than publish a number
+ // that is wrong by three orders of magnitude.
+ _ => continue,
+ };
+
+ let watts = joules / interval;
+ // Sanity bound: a whole Apple Silicon package is tens of watts.
+ if !watts.is_finite() || !(0.0..=1000.0).contains(&watts) {
+ continue;
+ }
+
+ // The Energy Model group publishes ~80 channels, most of them
+ // per-core detail rails. Showing all of them buries the handful
+ // anyone reads.
+ if !is_headline_rail(&name) {
+ continue;
+ }
+
+ // Identify by a slug of the channel name rather than its position:
+ // the channel set differs per SoC and ordering is not guaranteed
+ // stable, but graph history and CSV columns key off this string.
+ out.push(sensor(
+ &format!("/{}/power/{}", rail_of(&name).node(), slug(&name)),
+ &friendly_name(&name),
+ SensorType::Power,
+ index as u32,
+ watts as f32,
+ ));
+ }
+ out
+ }
+}
+
+/// Which hardware node a power rail belongs under.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Rail {
+ Soc,
+ Gpu,
+ Memory,
+}
+
+impl Rail {
+ fn node(self) -> &'static str {
+ match self {
+ Rail::Soc => "applesoc/0",
+ Rail::Gpu => "gpu/0",
+ Rail::Memory => "ram/0",
+ }
+ }
+}
+
+/// Route a channel to the node it physically belongs to, so GPU power appears
+/// under the GPU and memory power under memory rather than all of it landing
+/// in one undifferentiated CPU node.
+pub fn rail_of(channel: &str) -> Rail {
+ let name = channel.trim();
+ if name.starts_with("GPU") {
+ return Rail::Gpu;
+ }
+ // AMCC and DCS are the memory controller and DRAM command/storage rails.
+ if name.starts_with("DRAM") || name.starts_with("AMCC") || name.starts_with("DCS") {
+ return Rail::Memory;
+ }
+ Rail::Soc
+}
+
+/// Keep only aggregate rails, dropping the per-core detail channels.
+///
+/// Dropped: `*DTL*` (per-core detail), `*_SRAM` (per-core cache rails), and
+/// numbered per-core rails like `ECPU3` / `PCPU1`. Kept: cluster totals
+/// (`ECPU`, `PCPU`), the package total, and the non-CPU blocks (GPU, ANE,
+/// DRAM, DISP, ISP, ...).
+pub fn is_headline_rail(channel: &str) -> bool {
+ let name = channel.trim();
+ if name.is_empty() || name.contains("DTL") || name.ends_with("_SRAM") {
+ return false;
+ }
+ // ECPU0, PCPU12 — a cluster prefix followed only by digits.
+ for prefix in ["ECPU", "PCPU", "ECPM", "PCPM"] {
+ if let Some(rest) = name.strip_prefix(prefix) {
+ if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
+ return false;
+ }
+ }
+ }
+ true
+}
+
+/// Stable, filesystem-ish identifier fragment for a channel name.
+fn slug(name: &str) -> String {
+ name.trim()
+ .chars()
+ .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '_' })
+ .collect()
+}
+
+/// Firmware channel names are terse ("ANE", "GPU Energy"); make them read like
+/// the rest of the UI without hiding which channel they came from.
+fn friendly_name(raw: &str) -> String {
+ let trimmed = raw.trim();
+ match trimmed {
+ "CPU Energy" => "CPU Package Power".to_string(),
+ "GPU Energy" => "GPU Power".to_string(),
+ "ANE" | "ANE Energy" => "Neural Engine Power".to_string(),
+ other => {
+ // "DRAM Energy" -> "DRAM Power": the sensor reports a rate.
+ if let Some(stem) = other.strip_suffix(" Energy") {
+ format!("{stem} Power")
+ } else {
+ other.to_string()
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn energy_model_subscription_succeeds() {
+ let reporter = EnergyReporter::new();
+ if !reporter.available() {
+ crate::source::macos::absent("libIOReport Energy Model");
+ }
+ }
+
+ #[test]
+ fn power_needs_a_baseline_then_reports_watts() {
+ let mut reporter = EnergyReporter::new();
+ // Holds whether or not IOReport is present: no baseline means no rate.
+ assert!(
+ reporter.power_sensors().is_empty(),
+ "cumulative counters must not be published as power on the first poll"
+ );
+ if !reporter.available() {
+ return crate::source::macos::absent("libIOReport Energy Model");
+ }
+
+ std::thread::sleep(std::time::Duration::from_millis(250));
+ let sensors = reporter.power_sensors();
+ if sensors.is_empty() {
+ return crate::source::macos::absent("Energy Model power channels");
+ }
+
+ for s in &sensors {
+ let w = s.value.unwrap();
+ assert!(w.is_finite(), "{} is not finite", s.name);
+ // A fanless laptop SoC does not draw 200 W; catching a unit error
+ // (mJ read as J) is the whole point of this bound.
+ assert!((0.0..200.0).contains(&w), "{} = {w} W implausible", s.name);
+ }
+ }
+
+ #[test]
+ fn energy_suffix_is_rewritten_to_power() {
+ assert_eq!(friendly_name("CPU Energy"), "CPU Package Power");
+ assert_eq!(friendly_name("DRAM Energy"), "DRAM Power");
+ assert_eq!(friendly_name("ANE"), "Neural Engine Power");
+ assert_eq!(friendly_name("Something Else"), "Something Else");
+ }
+}
diff --git a/app/src/source/macos/load.rs b/app/src/source/macos/load.rs
new file mode 100644
index 00000000..21720130
--- /dev/null
+++ b/app/src/source/macos/load.rs
@@ -0,0 +1,296 @@
+//! CPU load and memory pressure, via the public Mach host interfaces.
+//!
+//! Nothing here is private API — `host_processor_info` and `host_statistics64`
+//! are stable and unprivileged. Load is a *rate*, so the per-core tick counters
+//! must be differenced against the previous poll; the first snapshot after
+//! startup therefore reports no load rather than a meaningless
+//! since-boot average.
+
+use mach2::kern_return::KERN_SUCCESS;
+use mach2::mach_types::host_t;
+use mach2::message::mach_msg_type_number_t;
+use mach2::traps::mach_task_self;
+use mach2::vm_types::natural_t;
+
+use crate::model::{Sensor, SensorType};
+
+use super::sensor;
+
+const CPU_STATE_MAX: usize = 4;
+const CPU_STATE_USER: usize = 0;
+const CPU_STATE_SYSTEM: usize = 1;
+const CPU_STATE_IDLE: usize = 2;
+const CPU_STATE_NICE: usize = 3;
+const PROCESSOR_CPU_LOAD_INFO: libc::c_int = 2;
+const HOST_VM_INFO64: libc::c_int = 4;
+
+extern "C" {
+ fn mach_host_self() -> host_t;
+ fn host_processor_info(
+ host: host_t,
+ flavor: libc::c_int,
+ out_processor_count: *mut natural_t,
+ out_processor_info: *mut *mut libc::c_int,
+ out_processor_infoCnt: *mut mach_msg_type_number_t,
+ ) -> libc::c_int;
+ fn vm_deallocate(target: u32, address: usize, size: usize) -> libc::c_int;
+ fn host_statistics64(
+ host_priv: host_t,
+ flavor: libc::c_int,
+ host_info_out: *mut libc::c_int,
+ host_info_outCnt: *mut mach_msg_type_number_t,
+ ) -> libc::c_int;
+}
+
+/// Subset of `vm_statistics64_data_t` we actually read. Declared locally with
+/// the full leading field order so the offsets line up with the kernel's
+/// struct; trailing fields we don't use are simply not named.
+#[repr(C)]
+#[derive(Default, Clone, Copy)]
+struct VmStatistics64 {
+ free_count: natural_t,
+ active_count: natural_t,
+ inactive_count: natural_t,
+ wire_count: natural_t,
+ zero_fill_count: u64,
+ reactivations: u64,
+ pageins: u64,
+ pageouts: u64,
+ faults: u64,
+ cow_faults: u64,
+ lookups: u64,
+ hits: u64,
+ purges: u64,
+ purgeable_count: natural_t,
+ speculative_count: natural_t,
+ decompressions: u64,
+ compressions: u64,
+ swapins: u64,
+ swapouts: u64,
+ compressor_page_count: natural_t,
+ throttled_count: natural_t,
+ external_page_count: natural_t,
+ internal_page_count: natural_t,
+ total_uncompressed_pages_in_compressor: u64,
+}
+
+pub struct LoadCollector {
+ /// Per-core cumulative ticks from the previous poll, for differencing.
+ prev_ticks: Vec<[u32; CPU_STATE_MAX]>,
+ page_size: u64,
+}
+
+impl LoadCollector {
+ pub fn new() -> Self {
+ let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
+ Self {
+ prev_ticks: Vec::new(),
+ // sysconf returns -1 on failure; 16 KiB is the Apple Silicon page
+ // size and a far better guess than 0 (which would divide by zero).
+ page_size: if page_size > 0 { page_size as u64 } else { 16384 },
+ }
+ }
+
+ /// Per-core load percentages, newest deltas against the last call.
+ /// Empty on the very first call (no baseline to difference against).
+ fn per_core_load(&mut self) -> Vec {
+ let mut count: natural_t = 0;
+ let mut info: *mut libc::c_int = std::ptr::null_mut();
+ let mut info_count: mach_msg_type_number_t = 0;
+
+ let kr = unsafe {
+ host_processor_info(
+ mach_host_self(),
+ PROCESSOR_CPU_LOAD_INFO,
+ &mut count,
+ &mut info,
+ &mut info_count,
+ )
+ };
+ if kr != KERN_SUCCESS || info.is_null() || count == 0 {
+ return Vec::new();
+ }
+
+ let ticks: Vec<[u32; CPU_STATE_MAX]> = (0..count as usize)
+ .map(|cpu| {
+ let mut states = [0u32; CPU_STATE_MAX];
+ for (state, slot) in states.iter_mut().enumerate() {
+ // SAFETY: the kernel guarantees CPU_STATE_MAX ints per CPU.
+ *slot = unsafe { *info.add(cpu * CPU_STATE_MAX + state) } as u32;
+ }
+ states
+ })
+ .collect();
+
+ // The kernel vm_allocate'd this buffer for us; it is ours to free.
+ unsafe {
+ vm_deallocate(
+ mach_task_self(),
+ info as usize,
+ info_count as usize * std::mem::size_of::(),
+ );
+ }
+
+ let loads = if self.prev_ticks.len() == ticks.len() {
+ ticks
+ .iter()
+ .zip(&self.prev_ticks)
+ .map(|(now, before)| {
+ // Counters are u32 and do wrap on long uptimes; wrapping_sub
+ // keeps a wrap from producing a huge bogus delta.
+ let delta = |i: usize| now[i].wrapping_sub(before[i]) as u64;
+ let busy = delta(CPU_STATE_USER) + delta(CPU_STATE_SYSTEM) + delta(CPU_STATE_NICE);
+ let total = busy + delta(CPU_STATE_IDLE);
+ if total == 0 {
+ 0.0
+ } else {
+ (busy as f32 / total as f32 * 100.0).clamp(0.0, 100.0)
+ }
+ })
+ .collect()
+ } else {
+ Vec::new()
+ };
+
+ self.prev_ticks = ticks;
+ loads
+ }
+
+ /// CPU load sensors: one total plus one per core.
+ pub fn cpu_load_sensors(&mut self) -> Vec {
+ let loads = self.per_core_load();
+ if loads.is_empty() {
+ return Vec::new();
+ }
+ let total = loads.iter().sum::() / loads.len() as f32;
+
+ let mut out = vec![sensor(
+ "/applesoc/0/load/0",
+ "Total CPU Usage",
+ SensorType::Load,
+ 0,
+ total,
+ )];
+ out.extend(loads.iter().enumerate().map(|(i, load)| {
+ sensor(
+ &format!("/applesoc/0/load/{}", i + 1),
+ &format!("CPU Core #{}", i + 1),
+ SensorType::Load,
+ i as u32 + 1,
+ *load,
+ )
+ }));
+ out
+ }
+
+ /// Memory sensors, using Activity Monitor's notion of "used": application
+ /// memory (internal minus purgeable) plus wired plus compressed. Raw
+ /// `free_count` badly overstates availability on macOS because the OS
+ /// deliberately keeps memory populated.
+ pub fn memory_sensors(&self) -> Vec {
+ let mut stats = VmStatistics64::default();
+ // The flavor's count is expressed in 32-bit words.
+ let mut count = (std::mem::size_of::() / std::mem::size_of::())
+ as mach_msg_type_number_t;
+
+ let kr = unsafe {
+ host_statistics64(
+ mach_host_self(),
+ HOST_VM_INFO64,
+ (&mut stats as *mut VmStatistics64).cast(),
+ &mut count,
+ )
+ };
+ if kr != KERN_SUCCESS {
+ return Vec::new();
+ }
+
+ let page = self.page_size;
+ let app = (stats.internal_page_count as u64).saturating_sub(stats.purgeable_count as u64);
+ let used_bytes =
+ (app + stats.wire_count as u64 + stats.compressor_page_count as u64) * page;
+ let total_bytes = crate::sysinfo::sysctl_u64("hw.memsize").unwrap_or(0);
+ if total_bytes == 0 {
+ return Vec::new();
+ }
+
+ const GB: f64 = 1024.0 * 1024.0 * 1024.0;
+ let used_gb = used_bytes as f64 / GB;
+ let total_gb = total_bytes as f64 / GB;
+
+ vec![
+ sensor(
+ "/ram/0/load/0",
+ "Memory Used",
+ SensorType::Load,
+ 0,
+ (used_gb / total_gb * 100.0) as f32,
+ ),
+ sensor("/ram/0/data/0", "Memory Used", SensorType::Data, 0, used_gb as f32),
+ sensor(
+ "/ram/0/data/1",
+ "Memory Available",
+ SensorType::Data,
+ 1,
+ (total_gb - used_gb).max(0.0) as f32,
+ ),
+ sensor(
+ "/ram/0/data/2",
+ "Compressed",
+ SensorType::Data,
+ 2,
+ (stats.compressor_page_count as u64 * page) as f64 as f32 / GB as f32,
+ ),
+ ]
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn memory_sensors_are_plausible() {
+ let collector = LoadCollector::new();
+ let sensors = collector.memory_sensors();
+ if sensors.is_empty() {
+ return crate::source::macos::absent("host_statistics64 memory stats");
+ }
+
+ let load = sensors.iter().find(|s| s.sensor_type == SensorType::Load).unwrap();
+ let pct = load.value.unwrap();
+ assert!((0.0..=100.0).contains(&pct), "memory load {pct} out of range");
+
+ // Used must be a real fraction of installed RAM, not a page count.
+ let used = sensors
+ .iter()
+ .find(|s| s.name == "Memory Used" && s.sensor_type == SensorType::Data)
+ .unwrap()
+ .value
+ .unwrap();
+ assert!(used > 0.1, "used memory {used} GB implausibly small");
+ assert!(used < 1024.0, "used memory {used} GB implausibly large");
+ }
+
+ #[test]
+ fn first_load_poll_has_no_baseline_then_reports() {
+ let mut collector = LoadCollector::new();
+ // No previous ticks to difference against yet.
+ assert!(collector.cpu_load_sensors().is_empty());
+
+ std::thread::sleep(std::time::Duration::from_millis(120));
+ let sensors = collector.cpu_load_sensors();
+ if sensors.is_empty() {
+ return crate::source::macos::absent("host_processor_info CPU load");
+ }
+ // One total + one per logical core. Compared against the reported core
+ // count only when sysctl agrees it knows one.
+ if let Some(cores) = crate::sysinfo::sysctl_u64("hw.logicalcpu") {
+ assert_eq!(sensors.len(), cores as usize + 1);
+ }
+ for s in &sensors {
+ let v = s.value.unwrap();
+ assert!((0.0..=100.0).contains(&v), "load {v} out of range for {}", s.name);
+ }
+ }
+}
diff --git a/app/src/source/macos/mod.rs b/app/src/source/macos/mod.rs
new file mode 100644
index 00000000..33ce8257
--- /dev/null
+++ b/app/src/source/macos/mod.rs
@@ -0,0 +1,418 @@
+//! Native macOS (Apple Silicon) sensor backend.
+//!
+//! Unlike the Windows path, this needs **no sidecar process, no kernel driver
+//! and no elevation** — every source below is readable by an ordinary user:
+//!
+//! | Data | Mechanism |
+//! |-------------------------|------------------------------------|
+//! | Die temperatures | `IOHIDEventSystemClient` (SPI) |
+//! | CPU/GPU/ANE power | `libIOReport` (SPI, dlopen'd) |
+//! | CPU load, memory | Mach `host_*` (public) |
+//! | GPU utilisation/memory | IOKit `IOAccelerator` (public) |
+//! | SSD throughput | IOKit `IOBlockStorageDriver` |
+//! | Battery | IOKit `AppleSmartBattery` |
+//!
+//! Two of those are private API. That is fine for `.dmg` distribution and for
+//! notarization, but it rules out the Mac App Store, and Apple can change or
+//! remove them in any macOS release. Because the release profile sets
+//! `panic = "abort"`, an unavailable API must never be a panic: every collector
+//! degrades to producing no sensors, and [`SensorSource::diagnostics`] reports
+//! which subsystems resolved so the Settings dialog can explain a thin tree.
+//!
+//! `AppleSMC` is deliberately not used — it does not exist on M-series Macs
+//! (verified on an M5: `ioreg -n AppleSMC` matches nothing). Fan sensors would
+//! come from the same HID plane as temperatures on Macs that have fans; this
+//! machine is fanless, so that path is untested.
+
+pub mod battery;
+pub mod dvfs;
+pub mod dynlib;
+pub mod freq;
+pub mod gpu;
+pub mod hid;
+pub mod inventory;
+pub mod iokit;
+pub mod ioreport;
+pub mod load;
+pub mod storage;
+pub mod sysprofile;
+
+use crate::model::{Hardware, HardwareType, Sensor, SensorType};
+
+use super::{Diagnostics, SensorSource};
+
+/// Build a reading. min/max/avg are the `Monitor`'s job, matching `DemoSource`.
+pub(crate) fn sensor(
+ identifier: &str,
+ name: &str,
+ sensor_type: SensorType,
+ index: u32,
+ value: f32,
+) -> Sensor {
+ sensor_opt(identifier, name, sensor_type, index, Some(value))
+}
+
+/// Build a reading that may have no value this tick.
+///
+/// `Sensor::value` is `Option` precisely so a sensor can be present without
+/// having produced a reading (see `model/mod.rs`). Publishing the sensor with
+/// `None` keeps the row in the table — and its position stable — where
+/// omitting it entirely would make the row flicker in and out. `poll::enrich`
+/// skips `None` without disturbing the accumulated min/max/avg, and the CSV
+/// logger writes an empty cell without shifting columns.
+pub(crate) fn sensor_opt(
+ identifier: &str,
+ name: &str,
+ sensor_type: SensorType,
+ index: u32,
+ value: Option,
+) -> Sensor {
+ Sensor {
+ identifier: identifier.to_string(),
+ name: name.to_string(),
+ sensor_type,
+ index,
+ value,
+ min: None,
+ max: None,
+ avg: None,
+ }
+}
+
+/// Note a piece of hardware or SPI that isn't present, so a test can skip the
+/// *presence* check without going quiet about it.
+///
+/// CI runs macOS on virtualized runners, where the IOHID sensor plane, an
+/// integrated GPU, an NVMe controller or a battery may all be missing. Failing
+/// there would say nothing about the code. Only presence is ever skipped —
+/// every consistency, range and uniqueness assertion still runs whenever the
+/// hardware *is* there, which is where the real coverage comes from.
+#[cfg(test)]
+pub(crate) fn absent(what: &str) {
+ eprintln!("SKIP: {what} is not available on this machine");
+}
+
+/// Suffix repeated display names with `#2`, `#3`, … within one node.
+///
+/// The firmware exposes several sensors sharing a name (this M5 reports seven
+/// distinct `"gas gauge battery"` probes). Identifiers stay unique regardless,
+/// but a table showing the same label seven times is unreadable.
+///
+/// Keyed on name **and type**: a name reused across types is not a collision,
+/// because the UI shows those in different unit columns. "GPU Core" as both a
+/// load and a clock is the intended LHM naming, and "Memory Used" appears
+/// deliberately as both a percentage and a size.
+fn disambiguate(sensors: &mut [Sensor]) {
+ let mut seen: std::collections::HashMap<(String, SensorType), u32> =
+ std::collections::HashMap::new();
+ for s in sensors.iter_mut() {
+ let count = seen.entry((s.name.clone(), s.sensor_type)).or_insert(0);
+ *count += 1;
+ if *count > 1 {
+ s.name = format!("{} #{}", s.name, *count);
+ }
+ }
+}
+
+pub struct MacSource {
+ hid: hid::HidSensors,
+ energy: ioreport::EnergyReporter,
+ clocks: freq::FrequencyReporter,
+ load: load::LoadCollector,
+ storage: storage::StorageCollector,
+ /// Cached at construction: which subsystems came up.
+ report: String,
+}
+
+impl MacSource {
+ pub fn new() -> Self {
+ let hid = hid::HidSensors::new();
+ let energy = ioreport::EnergyReporter::new();
+ let clocks = freq::FrequencyReporter::new();
+
+ // Record availability once, at startup, so the Settings dialog can say
+ // *why* the tree is thin instead of silently showing fewer sensors.
+ let mut lines = Vec::new();
+ lines.push(format!(
+ "IOHIDEventSystem (temperatures): {}",
+ if hid.available() {
+ format!("ok — {} sensors", hid.sensor_count())
+ } else {
+ "unavailable — no temperature sensors".to_string()
+ }
+ ));
+ lines.push(format!(
+ "libIOReport (power): {}",
+ if energy.available() { "ok" } else { "unavailable — no power sensors" }
+ ));
+ lines.push(format!(
+ "IOReport DVFS residency (clocks): {}",
+ if clocks.available() { "ok" } else { "unavailable — no clock sensors" }
+ ));
+ lines.push(
+ "No kernel driver or elevation is required on macOS; all sensors are read via IOKit."
+ .to_string(),
+ );
+
+ Self {
+ hid,
+ energy,
+ clocks,
+ load: load::LoadCollector::new(),
+ storage: storage::StorageCollector::new(),
+ report: lines.join("\n"),
+ }
+ }
+}
+
+impl SensorSource for MacSource {
+ fn name(&self) -> &'static str {
+ "macOS IOKit (native)"
+ }
+
+ fn snapshot(&mut self) -> Vec {
+ let mut tree = Vec::new();
+
+ // Power rails belong to the block that burns them, so split the one
+ // IOReport sample across the SoC / GPU / memory nodes rather than
+ // dumping every rail under the CPU.
+ let (mut soc_power, mut gpu_power, mut ram_power) = (Vec::new(), Vec::new(), Vec::new());
+ for s in self.energy.power_sensors() {
+ match ioreport::rail_of(&s.name) {
+ ioreport::Rail::Gpu => gpu_power.push(s),
+ ioreport::Rail::Memory => ram_power.push(s),
+ ioreport::Rail::Soc => soc_power.push(s),
+ }
+ }
+
+ // --- SoC: temperatures + power + CPU load in one node --------------
+ // Apple Silicon is a single package, so splitting CPU temperature from
+ // CPU power into separate nodes would misrepresent the hardware.
+ // Clocks are reconstructed from DVFS residency, so the GPU's clock
+ // arrives on the same sample as the CPU clusters' and has to be split
+ // out to the GPU node alongside its power.
+ let mut gpu_clocks = Vec::new();
+ let mut soc_clocks = Vec::new();
+ for s in self.clocks.clock_sensors() {
+ if s.identifier.ends_with("/gpu") {
+ gpu_clocks.push(s);
+ } else {
+ soc_clocks.push(s);
+ }
+ }
+
+ let mut soc_sensors = self.load.cpu_load_sensors();
+ soc_sensors.append(&mut soc_clocks);
+ soc_sensors.append(&mut soc_power);
+ soc_sensors.extend(self.hid.temperatures());
+ disambiguate(&mut soc_sensors);
+
+ if !soc_sensors.is_empty() {
+ let name = crate::sysinfo::sysctl_string("machdep.cpu.brand_string")
+ .unwrap_or_else(|| "Apple Silicon".to_string());
+ tree.push(Hardware {
+ identifier: "/applesoc/0".into(),
+ name,
+ hardware_type: HardwareType::Cpu,
+ sensors: soc_sensors,
+ sub_hardware: Vec::new(),
+ });
+ }
+
+ if let Some(mut gpu) = gpu::collect() {
+ gpu.sensors.append(&mut gpu_clocks);
+ gpu.sensors.append(&mut gpu_power);
+ tree.push(gpu);
+ }
+
+ let mut memory_sensors = self.load.memory_sensors();
+ memory_sensors.append(&mut ram_power);
+ if !memory_sensors.is_empty() {
+ tree.push(Hardware {
+ identifier: "/ram/0".into(),
+ name: "Unified Memory".into(),
+ hardware_type: HardwareType::Ram,
+ sensors: memory_sensors,
+ sub_hardware: Vec::new(),
+ });
+ }
+
+ tree.extend(self.storage.collect());
+
+ if let Some(battery) = battery::collect() {
+ tree.push(battery);
+ }
+
+ // Cheap, and keeps a future firmware revision that repeats a rail name
+ // from producing an unreadable table.
+ for hw in &mut tree {
+ disambiguate(&mut hw.sensors);
+ }
+
+ tree
+ }
+
+ fn diagnostics(&self) -> Diagnostics {
+ Diagnostics {
+ engine_version: format!("macOS IOKit backend {}", env!("CARGO_PKG_VERSION")),
+ driver_report: self.report.clone(),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// End-to-end: the backend must produce a real tree on this machine, and
+ /// must do so on the *second* poll — several collectors are rate-based and
+ /// legitimately return nothing on the first.
+ #[test]
+ fn snapshot_produces_a_populated_tree() {
+ let mut source = MacSource::new();
+ let _ = source.snapshot();
+ std::thread::sleep(std::time::Duration::from_millis(250));
+ let tree = source.snapshot();
+
+ if tree.is_empty() {
+ return absent("any macOS sensor subsystem");
+ }
+
+ // Which nodes exist depends entirely on what the machine exposes, and
+ // CI runs macOS virtualized. Report what is missing rather than
+ // asserting hardware into existence; the value assertions below are
+ // what actually guard correctness.
+ let types: Vec<_> = tree.iter().map(|h| h.hardware_type).collect();
+ for (ty, label) in [
+ (HardwareType::Cpu, "SoC node"),
+ (HardwareType::Ram, "memory node"),
+ (HardwareType::GpuApple, "GPU node"),
+ (HardwareType::Storage, "storage node"),
+ ] {
+ if !types.contains(&ty) {
+ absent(label);
+ }
+ }
+
+ // A sensor may be present without a reading this tick (see
+ // `sensor_opt`), but any value it does carry must be a real number —
+ // NaN/inf would propagate into the graphs and the CSV log.
+ for hw in &tree {
+ for s in &hw.sensors {
+ if let Some(v) = s.value {
+ assert!(v.is_finite(), "{}/{} is not finite", hw.name, s.name);
+ }
+ }
+ }
+ if !tree.iter().any(|hw| hw.sensors.iter().any(|s| s.value.is_some())) {
+ absent("any live reading");
+ }
+ }
+
+ /// The SoC node is the headline: it must carry temperature, power and load
+ /// together, which is what proves both SPI paths resolved.
+ #[test]
+ fn soc_node_has_temperature_power_and_load() {
+ let mut source = MacSource::new();
+ let _ = source.snapshot();
+ std::thread::sleep(std::time::Duration::from_millis(250));
+ let tree = source.snapshot();
+
+ let Some(soc) = tree.iter().find(|h| h.hardware_type == HardwareType::Cpu) else {
+ return absent("SoC node");
+ };
+ for wanted in [SensorType::Load, SensorType::Temperature, SensorType::Power] {
+ if !soc.sensors.iter().any(|s| s.sensor_type == wanted) {
+ absent(&format!("SoC {wanted:?} sensors"));
+ }
+ }
+ }
+
+ /// Rate-derived sensors used to drop out whenever a block idled: a power
+ /// rail that burned no measurable energy, or a cluster/GPU with no
+ /// non-idle residency, produced no sensor that tick. The row then vanished
+ /// from the Sensors table and blanked the Summary, reappearing on the next
+ /// poll — visible as values flickering in and out.
+ ///
+ /// After warm-up the published set must be identical from poll to poll.
+ #[test]
+ fn sensor_set_is_stable_across_polls() {
+ let mut source = MacSource::new();
+ // Two polls to establish every baseline (power, clocks, storage, load).
+ let _ = source.snapshot();
+ std::thread::sleep(std::time::Duration::from_millis(200));
+ let _ = source.snapshot();
+
+ let ids = |tree: &[Hardware]| -> std::collections::BTreeSet {
+ tree.iter()
+ .flat_map(|hw| hw.sensors.iter().map(|s| s.identifier.clone()))
+ .collect()
+ };
+
+ std::thread::sleep(std::time::Duration::from_millis(200));
+ let baseline = ids(&source.snapshot());
+ if baseline.is_empty() {
+ return absent("any macOS sensors");
+ }
+
+ // Several quiet polls: this is exactly when idle blocks used to drop.
+ for poll in 0..4 {
+ std::thread::sleep(std::time::Duration::from_millis(200));
+ let current = ids(&source.snapshot());
+ let missing: Vec<_> = baseline.difference(¤t).collect();
+ let added: Vec<_> = current.difference(&baseline).collect();
+ assert!(
+ missing.is_empty() && added.is_empty(),
+ "sensor set changed on poll {poll}: disappeared {missing:?}, appeared {added:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn identifiers_are_globally_unique() {
+ let mut source = MacSource::new();
+ let _ = source.snapshot();
+ std::thread::sleep(std::time::Duration::from_millis(250));
+ let tree = source.snapshot();
+
+ let mut seen = std::collections::HashSet::new();
+ for hw in &tree {
+ assert!(seen.insert(hw.identifier.clone()), "duplicate hardware id {}", hw.identifier);
+ for s in &hw.sensors {
+ assert!(seen.insert(s.identifier.clone()), "duplicate sensor id {}", s.identifier);
+ }
+ }
+ }
+
+ /// Not an assertion — a probe. `cargo test -- --ignored --nocapture
+ /// dump_live_tree` prints what the backend actually reads, for eyeballing
+ /// against `powermetrics` / `ioreg` / Activity Monitor.
+ #[test]
+ #[ignore = "diagnostic probe; run explicitly"]
+ fn dump_live_tree() {
+ let mut source = MacSource::new();
+ let _ = source.snapshot();
+ std::thread::sleep(std::time::Duration::from_millis(500));
+ println!("\n=== {} ===\n{}\n", source.name(), source.diagnostics().driver_report);
+ for hw in source.snapshot() {
+ println!("{:?} {}", hw.hardware_type, hw.name);
+ for s in &hw.sensors {
+ println!(
+ " {:<28} {:>10.2} {}",
+ s.name,
+ s.value.unwrap_or(f32::NAN),
+ s.sensor_type.unit()
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn diagnostics_describe_the_backend() {
+ let source = MacSource::new();
+ let diag = source.diagnostics();
+ assert!(diag.engine_version.contains("macOS IOKit"));
+ assert!(diag.driver_report.contains("IOHIDEventSystem"));
+ assert!(diag.driver_report.contains("libIOReport"));
+ }
+}
diff --git a/app/src/source/macos/storage.rs b/app/src/source/macos/storage.rs
new file mode 100644
index 00000000..0a04c7c3
--- /dev/null
+++ b/app/src/source/macos/storage.rs
@@ -0,0 +1,151 @@
+//! Internal SSD throughput, from `IOBlockStorageDriver`'s `Statistics`.
+//!
+//! The registry exposes cumulative byte counters, so throughput is a rate and
+//! must be differenced between polls — the first snapshot reports nothing
+//! rather than a since-boot average.
+
+use std::time::Instant;
+
+use crate::model::{Hardware, HardwareType, SensorType};
+
+use super::iokit::{self, dict_i64};
+use super::sensor;
+
+#[derive(Clone, Copy)]
+struct Counters {
+ read_bytes: i64,
+ write_bytes: i64,
+ at: Instant,
+}
+
+pub struct StorageCollector {
+ /// Previous cumulative counters, keyed by position in the service list.
+ prev: Vec