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 + +# 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. + +[![CI](https://github.com/Zektopic/sensorview/actions/workflows/ci.yml/badge.svg)](https://github.com/Zektopic/sensorview/actions/workflows/ci.yml) +![Platforms](https://img.shields.io/badge/platforms-Windows%20%7C%20macOS%20%7C%20Linux-blue) +![Rust](https://img.shields.io/badge/rust-1.92%2B-orange) + +
+ +> 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>, +} + +impl StorageCollector { + pub fn new() -> Self { + Self { prev: Vec::new() } + } + + pub fn collect(&mut self) -> Vec { + let services = iokit::matching_services("IOBlockStorageDriver"); + if self.prev.len() != services.len() { + self.prev = vec![None; services.len()]; + } + + let mut out = Vec::new(); + for (index, service) in services.iter().enumerate() { + let Some(props) = iokit::properties(service.0) else { + continue; + }; + let Some(stats) = iokit::dict_dict(&props, "Statistics") else { + continue; + }; + + let read_bytes = dict_i64(&stats, "Bytes (Read)").unwrap_or(0); + let write_bytes = dict_i64(&stats, "Bytes (Write)").unwrap_or(0); + let now = Counters { read_bytes, write_bytes, at: Instant::now() }; + + let mut sensors = Vec::new(); + + // Throughput needs a baseline; skip on the first poll for a device. + if let Some(before) = self.prev[index] { + let secs = now.at.duration_since(before.at).as_secs_f64(); + if secs > 0.0 { + const MB: f64 = 1024.0 * 1024.0; + let rate = |now: i64, before: i64| { + // saturating_sub: counters reset if a device re-enumerates. + (now.saturating_sub(before).max(0) as f64 / MB / secs) as f32 + }; + sensors.push(sensor( + &format!("/storage/{index}/throughput/0"), + "Read Rate", + SensorType::Throughput, + 0, + rate(now.read_bytes, before.read_bytes), + )); + sensors.push(sensor( + &format!("/storage/{index}/throughput/1"), + "Write Rate", + SensorType::Throughput, + 1, + rate(now.write_bytes, before.write_bytes), + )); + } + } + self.prev[index] = Some(now); + + // Cumulative totals are useful on their own and need no baseline. + const GB: f64 = 1024.0 * 1024.0 * 1024.0; + sensors.push(sensor( + &format!("/storage/{index}/data/0"), + "Total Read", + SensorType::Data, + 0, + (read_bytes as f64 / GB) as f32, + )); + sensors.push(sensor( + &format!("/storage/{index}/data/1"), + "Total Written", + SensorType::Data, + 1, + (write_bytes as f64 / GB) as f32, + )); + + // Model/serial live on an ancestor of the block-storage driver, so + // this needs the recursive parent search rather than a plain lookup. + let name = iokit::search_property(service.0, "Model") + .and_then(|v| v.downcast::()) + .map(|s| s.to_string().trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| format!("Disk {index}")); + + out.push(Hardware { + identifier: format!("/storage/{index}"), + name, + hardware_type: HardwareType::Storage, + sensors, + sub_hardware: Vec::new(), + }); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn throughput_appears_only_after_a_baseline() { + let mut collector = StorageCollector::new(); + + let first = collector.collect(); + if first.is_empty() { + return crate::source::macos::absent("IOBlockStorageDriver"); + } + assert!( + first.iter().all(|hw| hw.sensors.iter().all(|s| s.sensor_type != SensorType::Throughput)), + "first poll must not invent a rate from cumulative counters" + ); + + std::thread::sleep(std::time::Duration::from_millis(120)); + let second = collector.collect(); + let rates: Vec<_> = second + .iter() + .flat_map(|hw| hw.sensors.iter()) + .filter(|s| s.sensor_type == SensorType::Throughput) + .collect(); + if rates.is_empty() { + return crate::source::macos::absent("block-storage throughput counters"); + } + // Read + write per device. + assert_eq!(rates.len() % 2, 0, "expected a read and a write rate per device"); + for s in rates { + let v = s.value.unwrap(); + // 100 GB/s would mean the delta or the interval is being misread. + assert!((0.0..100_000.0).contains(&v), "{} = {v} MB/s implausible", s.name); + } + } +} diff --git a/app/src/source/macos/sysprofile.rs b/app/src/source/macos/sysprofile.rs new file mode 100644 index 00000000..b55a6194 --- /dev/null +++ b/app/src/source/macos/sysprofile.rs @@ -0,0 +1,166 @@ +//! Static machine facts for the System Summary window. +//! +//! These are one-shot IOKit lookups that don't belong in a sensor collector: +//! the marketing model name, the boot firmware version, GPU identity and the +//! internal drive list. `sysinfo::query()` reads them once at startup. +//! +//! Kept here rather than in `sysinfo.rs` so all the IOKit FFI stays behind the +//! one `iokit` helper module. + +use super::iokit; + +/// Marketing name, e.g. `"MacBook Air (13-inch, M5)"`. +/// +/// Lives in the device tree, not on `IOPlatformExpertDevice` (which only +/// carries the board id `"Mac17,3"`). Stored as `CFData`, which `dict_string` +/// decodes. +pub fn product_name() -> Option { + let entry = iokit::entry_from_path("IODeviceTree:/product")?; + let props = iokit::properties(entry.0)?; + iokit::dict_string(&props, "product-name") + .or_else(|| iokit::dict_string(&props, "product-description")) +} + +/// Boot firmware version, e.g. `"mBoot-18000.121.3"` — the Apple Silicon +/// analogue of a BIOS version. +pub fn firmware_version() -> Option { + let entry = iokit::entry_from_path("IODeviceTree:/chosen")?; + let props = iokit::properties(entry.0)?; + iokit::dict_string(&props, "system-firmware-version") +} + +/// GPU core count and Metal driver name, both best-effort. +pub fn gpu_identity() -> (Option, Option) { + let services = { + let s = iokit::matching_services("IOAccelerator"); + if s.is_empty() { + iokit::matching_services("AGXAccelerator") + } else { + s + } + }; + let Some(service) = services.first() else { + return (None, None); + }; + + // The core count sits on an ancestor of the accelerator, so it needs the + // recursive parent search rather than a direct property read. + let cores = iokit::search_property(service.0, "gpu-core-count") + .and_then(|v| v.downcast::()) + .and_then(|n| n.to_i64()) + .filter(|n| *n > 0) + .map(|n| n as u32); + + let driver = iokit::properties(service.0) + .and_then(|props| iokit::dict_string(&props, "MetalPluginName")); + + (cores, driver) +} + +/// Internal storage: `(model, capacity_bytes)` per physical NVMe drive. +pub fn drives() -> Vec<(String, Option)> { + let capacities = super::inventory::physical_disk_sizes(); + iokit::matching_services("IONVMeController") + .iter() + .enumerate() + .filter_map(|(index, service)| { + let props = iokit::properties(service.0)?; + let model = iokit::dict_string(&props, "Model Number")?; + if model.is_empty() { + return None; + } + Some((model, capacities.get(index).copied())) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Diagnostic probe for the System Summary panels. Run with + /// `cargo test -- --ignored --nocapture dump_system_summary`. + #[test] + #[ignore = "diagnostic probe; run explicitly"] + fn dump_system_summary() { + let info = crate::sysinfo::query_for_test(); + println!("\ncomputer : {}", info.computer_name); + println!("user : {}", info.user_name); + println!("--- Motherboard ---"); + println!(" product : {} {}", info.board.manufacturer, info.board.product); + println!(" firmware : {}", info.board.bios_version); + println!("--- CPU ---"); + println!(" name : {}", info.cpu.name); + println!(" cores : {:?} threads {:?}", info.cpu.cores, info.cpu.threads); + println!(" layout : {:?}", info.cpu.socket); + println!(" base/max : {:?} / {:?} MHz", info.cpu.base_clock_mhz, info.cpu.max_clock_mhz); + println!(" codename : {}", info.cpu.codename); + println!(" features : {} detected", info.cpu.features.iter().filter(|f| f.1).count()); + println!("--- Memory ---"); + println!(" total : {:?} GB", info.total_memory_gb); + for m in &info.memory_modules { + println!(" module : {} {} {:.0} GB", m.bank, m.memory_type, m.capacity_gb); + } + println!("--- GPU ---"); + for g in &info.gpus { + println!(" {} (driver {})", g.name, g.driver_version); + } + println!("--- Drives ---"); + for d in &info.drives { + println!(" {} {} {:?} GB", d.model, d.interface, d.size_gb.map(|g| g as u64)); + } + println!("--- OS ---"); + println!(" {} build {} {}", info.os.caption, info.os.build, info.os.arch); + } + + #[test] + fn product_name_is_a_marketing_name_not_a_board_id() { + let Some(name) = product_name() else { + return crate::source::macos::absent("IODeviceTree:/product"); + }; + assert!(!name.is_empty()); + // "Mac17,3" is the board id; this lookup exists precisely to get past + // it to the human-readable name. + assert!( + !name.starts_with("Mac") || name.contains(' '), + "got the board id {name} instead of a marketing name" + ); + } + + #[test] + fn firmware_version_is_populated() { + let Some(version) = firmware_version() else { + return crate::source::macos::absent("system-firmware-version"); + }; + assert!(!version.is_empty()); + // Should carry a version number, not just a label. + assert!( + version.chars().any(|c| c.is_ascii_digit()), + "firmware version {version} has no digits" + ); + } + + #[test] + fn gpu_core_count_is_plausible() { + let (cores, _driver) = gpu_identity(); + let Some(cores) = cores else { + return crate::source::macos::absent("gpu-core-count"); + }; + // Apple Silicon ranges from 7 cores (base M-series) to 80 (Ultra). + assert!((4..=128).contains(&cores), "GPU core count {cores} is implausible"); + } + + #[test] + fn drives_are_listed_with_capacity() { + let drives = drives(); + if drives.is_empty() { + return crate::source::macos::absent("IONVMeController"); + } + let (model, size) = &drives[0]; + assert!(!model.is_empty()); + let Some(bytes) = size else { + return crate::source::macos::absent("whole-media size"); + }; + assert!(*bytes > 1_000_000_000, "capacity {bytes} implausibly small for a disk"); + } +} diff --git a/app/src/source/mod.rs b/app/src/source/mod.rs index 6a0156ce..bae62560 100644 --- a/app/src/source/mod.rs +++ b/app/src/source/mod.rs @@ -14,6 +14,8 @@ pub mod firmware; pub mod linux; #[cfg(windows)] pub mod lhm_bridge; +#[cfg(target_os = "macos")] +pub mod macos; use serde::{Deserialize, Serialize}; @@ -51,8 +53,14 @@ pub trait SensorSource: Send { /// Pick the best available source for the current build/platform. /// /// Windows: the LibreHardwareMonitor bridge (full real sensors), falling back -/// to the demo source if the sidecar is missing or fails. Other platforms (and -/// `SENSORVIEW_SOURCE=demo`): the demo source, until the native engine lands. +/// to the demo source if the sidecar is missing or fails. macOS: the native +/// IOKit backend — no sidecar, no kernel driver, no elevation. Linux: sysfs +/// hwmon. Anything else (and `SENSORVIEW_SOURCE=demo`): the demo source. +/// +/// Every platform arm must also be excluded from the fallback's `not(any(…))`. +/// These are exclusive `cfg` blocks in sequence, not `else if`, so a platform +/// listed in an arm *and* left in the fallback compiles both — and the last one +/// wins, which presents as "my backend silently isn't being used". pub fn default_source() -> Box { if std::env::var("SENSORVIEW_SOURCE").as_deref() == Ok("demo") { return Box::new(demo::DemoSource::new()); @@ -67,11 +75,15 @@ pub fn default_source() -> Box { } } } + #[cfg(target_os = "macos")] + { + Box::new(macos::MacSource::new()) + } #[cfg(target_os = "linux")] { Box::new(linux::LinuxSysfsSource::new()) } - #[cfg(not(any(windows, target_os = "linux")))] + #[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] { Box::new(demo::DemoSource::new()) } diff --git a/app/src/sysinfo.rs b/app/src/sysinfo.rs index b4605bb9..6ce5328e 100644 --- a/app/src/sysinfo.rs +++ b/app/src/sysinfo.rs @@ -51,7 +51,21 @@ fn cpuid_info() -> (String, String, String) { let codename = codename_for(&vendor, family, model); (format!("{eax:08X}"), vendor, codename) } - #[cfg(not(target_arch = "x86_64"))] + // Apple Silicon has no CPUID. The nearest equivalents are the board id + // (`hw.model`, e.g. "Mac17,3") and the SoC name from the brand string, so + // report those rather than leaving the Summary window blank. + #[cfg(all(target_arch = "aarch64", target_os = "macos"))] + { + let vendor = if sysctl_string("machdep.cpu.brand_string") + .is_some_and(|b| b.starts_with("Apple")) + { + "Apple".to_string() + } else { + String::new() + }; + (String::new(), vendor, sysctl_string("hw.model").unwrap_or_default()) + } + #[cfg(not(any(target_arch = "x86_64", all(target_arch = "aarch64", target_os = "macos"))))] { (String::new(), String::new(), String::new()) } @@ -196,12 +210,96 @@ pub fn is_elevated() -> Option { } } } - #[cfg(not(windows))] + // Reported for the status badge only. Nothing on macOS *needs* root: the + // IOKit backend reads every sensor unprivileged, so no feature is gated on + // this (see ui/settings_dialog.rs). + #[cfg(target_os = "macos")] + { + Some(unsafe { libc::geteuid() } == 0) + } + #[cfg(not(any(windows, target_os = "macos")))] { None } } +// ---- sysctl helpers (macOS) --------------------------------------------- + +/// Read a string-valued sysctl by name. `None` if the key doesn't exist — +/// keys come and go between macOS releases, so every caller must tolerate it. +#[cfg(target_os = "macos")] +pub(crate) fn sysctl_string(name: &str) -> Option { + let cname = std::ffi::CString::new(name).ok()?; + let mut len = 0usize; + // First call with a null buffer asks for the required size. + if unsafe { + libc::sysctlbyname(cname.as_ptr(), std::ptr::null_mut(), &mut len, std::ptr::null_mut(), 0) + } != 0 + || len == 0 + { + return None; + } + let mut buf = vec![0u8; len]; + if unsafe { + libc::sysctlbyname( + cname.as_ptr(), + buf.as_mut_ptr().cast(), + &mut len, + std::ptr::null_mut(), + 0, + ) + } != 0 + { + return None; + } + buf.truncate(len); + // sysctl strings are NUL-terminated; drop the terminator and anything after. + if let Some(nul) = buf.iter().position(|&b| b == 0) { + buf.truncate(nul); + } + let s = String::from_utf8_lossy(&buf).trim().to_string(); + (!s.is_empty()).then_some(s) +} + +/// Read an integer-valued sysctl. Handles both the 4-byte and 8-byte widths the +/// kernel uses (`hw.ncpu` is 32-bit, `hw.memsize` is 64-bit). +#[cfg(target_os = "macos")] +pub(crate) fn sysctl_u64(name: &str) -> Option { + let cname = std::ffi::CString::new(name).ok()?; + let mut value = 0u64; + let mut len = std::mem::size_of::(); + if unsafe { + libc::sysctlbyname( + cname.as_ptr(), + (&mut value as *mut u64).cast(), + &mut len, + std::ptr::null_mut(), + 0, + ) + } != 0 + { + return None; + } + match len { + 8 => Some(value), + // The kernel wrote only the low 4 bytes; the upper half is our zeroed + // initialiser, so mask rather than trusting the whole u64. + 4 => Some(value & 0xffff_ffff), + _ => None, + } +} + +/// Synchronous `query()` for tests and diagnostics — `spawn_query` returns a +/// handle that only fills in later, which is awkward to assert against. +/// +/// Only the macOS system-profile test consumes it today; `allow(dead_code)` +/// keeps `clippy -D warnings` green on platforms that have no caller yet. +#[cfg(test)] +#[allow(dead_code)] +pub fn query_for_test() -> SystemInfo { + query() +} + /// Kick off the (slow) WMI enumeration without blocking the UI. pub fn spawn_query() -> SystemInfoHandle { let handle: SystemInfoHandle = Arc::new(RwLock::new(None)); @@ -241,7 +339,47 @@ fn cpu_features() -> Vec<(&'static str, bool)> { ("F16C", is_x86_feature_detected!("f16c")), ] } - #[cfg(not(target_arch = "x86_64"))] + // Apple Silicon: the kernel publishes ~80 `hw.optional.arm.FEAT_*` flags. + // Curated rather than enumerated so the grid stays readable and the labels + // stay `&'static str` — the full list is mostly MTE/SME sub-variants. + #[cfg(all(target_arch = "aarch64", target_os = "macos"))] + { + const FEATURES: &[(&str, &str)] = &[ + ("NEON", "hw.optional.arm.AdvSIMD"), + ("FP16", "hw.optional.arm.FEAT_FP16"), + ("BF16", "hw.optional.arm.FEAT_BF16"), + ("I8MM", "hw.optional.arm.FEAT_I8MM"), + ("DotProd", "hw.optional.arm.FEAT_DotProd"), + ("FHM", "hw.optional.arm.FEAT_FHM"), + ("CRC32", "hw.optional.arm.FEAT_CRC32"), + ("AES", "hw.optional.arm.FEAT_AES"), + ("PMULL", "hw.optional.arm.FEAT_PMULL"), + ("SHA1", "hw.optional.arm.FEAT_SHA1"), + ("SHA256", "hw.optional.arm.FEAT_SHA256"), + ("SHA3", "hw.optional.arm.FEAT_SHA3"), + ("SHA512", "hw.optional.arm.FEAT_SHA512"), + ("LSE", "hw.optional.arm.FEAT_LSE"), + ("LSE2", "hw.optional.arm.FEAT_LSE2"), + ("RDM", "hw.optional.arm.FEAT_RDM"), + ("JSCVT", "hw.optional.arm.FEAT_JSCVT"), + ("FCMA", "hw.optional.arm.FEAT_FCMA"), + ("LRCPC", "hw.optional.arm.FEAT_LRCPC"), + ("PAuth", "hw.optional.arm.FEAT_PAuth"), + ("BTI", "hw.optional.arm.FEAT_BTI"), + ("MTE", "hw.optional.arm.FEAT_MTE"), + ("DIT", "hw.optional.arm.FEAT_DIT"), + ("ECV", "hw.optional.arm.FEAT_ECV"), + ("SME", "hw.optional.arm.FEAT_SME"), + ("SME2", "hw.optional.arm.FEAT_SME2"), + ("SSBS", "hw.optional.arm.FEAT_SSBS"), + ("SPECRES", "hw.optional.arm.FEAT_SPECRES"), + ]; + FEATURES + .iter() + .map(|(label, key)| (*label, sysctl_u64(key).unwrap_or(0) != 0)) + .collect() + } + #[cfg(not(any(target_arch = "x86_64", all(target_arch = "aarch64", target_os = "macos"))))] { Vec::new() } @@ -416,6 +554,144 @@ fn read_secure_boot() -> Option { } } +#[cfg(target_os = "macos")] +fn query() -> SystemInfo { + let (cpuid, vendor, codename) = cpuid_info(); + + // Apple Silicon is heterogeneous: perflevel0 is the fast cluster (named + // "Performance" through M4, "Super" on M5 — read the name, don't hardcode + // it) and perflevel1 the efficiency cluster. Sum both for the core count, + // and report the layout in `socket` since there is no socket to speak of. + let mut cluster_desc = Vec::new(); + let mut physical = 0u32; + for level in 0..4 { + let Some(count) = sysctl_u64(&format!("hw.perflevel{level}.physicalcpu")) else { + break; + }; + physical += count as u32; + let name = sysctl_string(&format!("hw.perflevel{level}.name")) + .unwrap_or_else(|| format!("level{level}")); + cluster_desc.push(format!("{count} {name}")); + } + // Fall back to the flat count on any Mac that doesn't publish perflevels. + let cores = if physical > 0 { Some(physical) } else { sysctl_u64("hw.physicalcpu").map(|v| v as u32) }; + + let total_memory_gb = sysctl_u64("hw.memsize").map(|b| b as f64 / (1024.0 * 1024.0 * 1024.0)); + + // The SoC is soldered, so there are no per-DIMM SPD entries to enumerate; + // present the unified memory as a single honest module. + let memory_modules = total_memory_gb + .map(|gb| { + vec![MemoryModule { + bank: "Unified Memory".into(), + manufacturer: "Apple".into(), + capacity_gb: gb, + memory_type: "LPDDR (on-package)".into(), + ..Default::default() + }] + }) + .unwrap_or_default(); + + // The integrated GPU shares the SoC; name it after the chip and its core + // count rather than inventing a discrete-adapter identity. + let chip = sysctl_string("machdep.cpu.brand_string").unwrap_or_default(); + let (gpu_cores, metal_driver) = crate::source::macos::sysprofile::gpu_identity(); + let gpus = if chip.is_empty() { + Vec::new() + } else { + let name = match gpu_cores { + Some(cores) => format!("{chip} GPU ({cores} cores)"), + None => format!("{chip} GPU"), + }; + vec![GpuInfo { + name, + // Unified memory — there is no separate VRAM pool to report, and + // quoting total system RAM here would be misleading. + vram_gb: None, + driver_version: metal_driver.unwrap_or_default(), + }] + }; + + // Internal SSD(s), so the Summary's Drives panel isn't blank. + const GB: f64 = 1024.0 * 1024.0 * 1024.0; + let drives = crate::source::macos::sysprofile::drives() + .into_iter() + .map(|(model, bytes)| DriveInfo { + model, + interface: "NVMe".into(), + size_gb: bytes.map(|b| b as f64 / GB), + }) + .collect(); + + // Performance-cluster DVFS states, for the Base/Max Clock rows. + let p_states = + crate::source::macos::dvfs::frequencies_mhz(crate::source::macos::dvfs::Block::Pcpu); + + let os_version = sysctl_string("kern.osproductversion").unwrap_or_default(); + + SystemInfo { + computer_name: sysctl_string("kern.hostname").unwrap_or_default(), + user_name: std::env::var("USER").unwrap_or_default(), + cpu: CpuInfo { + name: chip, + cores, + // Apple Silicon has no SMT: one thread per physical core. + threads: sysctl_u64("hw.logicalcpu").map(|v| v as u32), + // There is no fixed "base clock" on Apple Silicon; the closest + // honest equivalents are the bottom and top of the performance + // cluster's DVFS table. + base_clock_mhz: p_states.first().map(|mhz| *mhz as u32), + max_clock_mhz: p_states.last().map(|mhz| *mhz as u32), + // hw.l2cachesize reports the *efficiency* cluster (6 MB here). + // The headline figure is the performance cluster's L2 + // (hw.perflevel0.l2cachesize, 16 MB), so prefer that. + l2_kb: sysctl_u64("hw.perflevel0.l2cachesize") + .or_else(|| sysctl_u64("hw.l2cachesize")) + .map(|b| (b / 1024) as u32), + // Apple Silicon has no per-core L3; the system-level cache is not + // published anywhere readable, so this stays honestly blank. + l3_kb: None, + socket: (!cluster_desc.is_empty()).then(|| cluster_desc.join(" + ")), + features: cpu_features(), + cpuid, + vendor, + codename, + }, + board: BoardInfo { + // Prefer the marketing name ("MacBook Air (13-inch, M5)") over the + // bare board id ("Mac17,3"), which is already shown as the codename. + product: crate::source::macos::sysprofile::product_name() + .or_else(|| sysctl_string("hw.model")) + .unwrap_or_default(), + manufacturer: "Apple Inc.".into(), + // Apple Silicon boots via iBoot, so the closest thing to a BIOS + // version is the boot firmware revision. + bios_version: crate::source::macos::sysprofile::firmware_version().unwrap_or_default(), + // No firmware build date is published anywhere in IOKit; leave it + // blank rather than guessing from the version string. + bios_date: String::new(), + }, + memory_modules, + total_memory_gb, + gpus, + drives, + os: OsInfo { + caption: if os_version.is_empty() { + "macOS".into() + } else { + format!("macOS {os_version}") + }, + build: sysctl_string("kern.osversion").unwrap_or_default(), + arch: std::env::consts::ARCH.to_string(), + // Apple Silicon boots via iBoot, not UEFI, and Secure Boot state + // lives in a different subsystem — leave both indeterminate rather + // than asserting something false. + uefi_boot: None, + secure_boot: None, + }, + } +} + #[cfg(target_os = "linux")] fn query() -> SystemInfo { use std::fs; @@ -492,7 +768,7 @@ fn query() -> SystemInfo { info } -#[cfg(not(any(windows, target_os = "linux")))] +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] fn query() -> SystemInfo { let (cpuid, vendor, codename) = cpuid_info(); SystemInfo { diff --git a/app/src/ui/hex_window.rs b/app/src/ui/hex_window.rs index 5cb17c53..9b5b5162 100644 --- a/app/src/ui/hex_window.rs +++ b/app/src/ui/hex_window.rs @@ -109,6 +109,10 @@ fn empty_state(ui: &mut egui::Ui, s: &Shared, pal: &Palette) { "No ACPI or SMBIOS tables were returned by the firmware. SPD and PCI \ configuration dumps additionally need a kernel driver — see \ Settings → Driver Management." + } else if cfg!(target_os = "macos") { + "Apple Silicon has no ACPI or SMBIOS tables — the firmware describes \ + hardware with an ARM device tree instead. Storage identity and health \ + are collected from IOKit and appear here once the slow lane has run." } else { "Firmware table enumeration is not implemented on this platform yet." }) diff --git a/app/src/ui/mod.rs b/app/src/ui/mod.rs index c04c58bb..48cb63e8 100644 --- a/app/src/ui/mod.rs +++ b/app/src/ui/mod.rs @@ -208,32 +208,58 @@ pub fn apply_theme(ctx: &egui::Context, pal: &Palette, light: bool) { }); } -/// Load Segoe UI for HWiNFO-faithful proportional text, and a real monospace -/// face for the hex dump. Silently keeps egui's defaults when unavailable -/// (non-Windows, CI). +/// Per-platform system font candidates, best first. +/// +/// Only plain `.ttf` files are listed. macOS's `.ttc` collections (Menlo, +/// Helvetica) are deliberately excluded: ab_glyph, which egui parses fonts +/// with, does not handle TrueType collections, so those load as garbage or +/// fail outright. `SFNSMono.ttf` and `Monaco.ttf` are both real single-face +/// files. +#[cfg(target_os = "macos")] +const PROPORTIONAL_FONTS: &[&str] = + &["/System/Library/Fonts/SFNS.ttf", "/System/Library/Fonts/Geneva.ttf"]; +#[cfg(target_os = "macos")] +const MONOSPACE_FONTS: &[&str] = + &["/System/Library/Fonts/SFNSMono.ttf", "/System/Library/Fonts/Monaco.ttf"]; + +#[cfg(windows)] +const PROPORTIONAL_FONTS: &[&str] = &[r"C:\Windows\Fonts\segoeui.ttf"]; +// Cascadia Mono ships with Windows 11; Consolas is the older fallback. +#[cfg(windows)] +const MONOSPACE_FONTS: &[&str] = + &[r"C:\Windows\Fonts\CascadiaMono.ttf", r"C:\Windows\Fonts\consola.ttf"]; + +#[cfg(not(any(windows, target_os = "macos")))] +const PROPORTIONAL_FONTS: &[&str] = + &["/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]; +#[cfg(not(any(windows, target_os = "macos")))] +const MONOSPACE_FONTS: &[&str] = + &["/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"]; + +/// Load the platform UI face for proportional text and a real monospace face +/// for the hex dump — both align hex columns far better than egui's bundled +/// face. Silently keeps egui's defaults when nothing loads (CI, minimal +/// container images). +/// +/// A readable file is not the same as a usable font, so each candidate is +/// parsed before being installed and a font that fails to parse falls through +/// to the next. pub fn install_fonts(ctx: &egui::Context) { let mut fonts = egui::FontDefinitions::default(); let mut changed = false; - if let Ok(bytes) = std::fs::read(r"C:\Windows\Fonts\segoeui.ttf") { + for (key, candidates, family) in [ + ("ui", PROPORTIONAL_FONTS, egui::FontFamily::Proportional), + ("mono", MONOSPACE_FONTS, egui::FontFamily::Monospace), + ] { + let Some(bytes) = candidates.iter().find_map(|p| load_font(p)) else { + continue; + }; fonts .font_data - .insert("segoe".into(), Arc::new(egui::FontData::from_owned(bytes))); - if let Some(family) = fonts.families.get_mut(&egui::FontFamily::Proportional) { - family.insert(0, "segoe".into()); - } - changed = true; - } - - // Cascadia Mono ships with Windows 11; Consolas is the older fallback. - // Both align hex columns far better than egui's bundled face. - let mono = [r"C:\Windows\Fonts\CascadiaMono.ttf", r"C:\Windows\Fonts\consola.ttf"]; - if let Some(bytes) = mono.iter().find_map(|p| std::fs::read(p).ok()) { - fonts - .font_data - .insert("mono".into(), Arc::new(egui::FontData::from_owned(bytes))); - if let Some(family) = fonts.families.get_mut(&egui::FontFamily::Monospace) { - family.insert(0, "mono".into()); + .insert(key.into(), Arc::new(egui::FontData::from_owned(bytes))); + if let Some(family) = fonts.families.get_mut(&family) { + family.insert(0, key.into()); } changed = true; } @@ -243,6 +269,24 @@ pub fn install_fonts(ctx: &egui::Context) { } } +/// Read a font file and confirm it is a *single* sfnt face. +/// +/// Existence is not enough: egui parses fonts with ab_glyph, which cannot read +/// TrueType **collections**. Handing it a `.ttc` (Menlo, Helvetica on macOS) +/// yields blank glyphs rather than a clean error, so reject by magic number +/// instead of trusting the extension. +fn load_font(path: &str) -> Option> { + let bytes = std::fs::read(path).ok()?; + let magic = bytes.get(..4)?; + match magic { + // 0x00010000 (TrueType), "true" (legacy Apple), "OTTO" (CFF outlines). + [0x00, 0x01, 0x00, 0x00] | b"true" | b"OTTO" => Some(bytes), + // "ttcf" — a collection. Unsupported; fall through to the next candidate. + _ => None, + } +} + + // ---- Viewport registration --------------------------------------------- /// Re-register every open deferred viewport. Must be called each frame from @@ -319,3 +363,36 @@ pub fn handle_close(ui: &egui::Ui, flag: &AtomicBool) { WindowFlags::close(flag); } } + +#[cfg(test)] +mod font_tests { + use super::*; + + /// The whole point of the magic check: on macOS the tempting candidates + /// (Menlo, Helvetica) are collections and must be rejected, while the ones + /// actually listed must load. + #[cfg(target_os = "macos")] + #[test] + fn collections_are_rejected_and_listed_faces_load() { + for ttc in ["/System/Library/Fonts/Menlo.ttc", "/System/Library/Fonts/Helvetica.ttc"] { + if std::path::Path::new(ttc).exists() { + assert!(load_font(ttc).is_none(), "{ttc} is a collection and must be rejected"); + } + } + // Not asserted: a stripped CI runner image may ship neither, and + // install_fonts already falls back to egui's bundled face. The load + // -bearing part of this test is the .ttc rejection above. + for (kind, candidates) in + [("proportional", PROPORTIONAL_FONTS), ("monospace", MONOSPACE_FONTS)] + { + if !candidates.iter().any(|p| load_font(p).is_some()) { + eprintln!("SKIP: no usable {kind} system font on this machine"); + } + } + } + + #[test] + fn missing_font_is_none() { + assert!(load_font("/no/such/font.ttf").is_none()); + } +} diff --git a/app/src/ui/sensors_window.rs b/app/src/ui/sensors_window.rs index b33e1a12..95baf504 100644 --- a/app/src/ui/sensors_window.rs +++ b/app/src/ui/sensors_window.rs @@ -388,7 +388,10 @@ fn find_cpu_load(tree: &[Hardware]) -> Option { fn group_title(hw: &Hardware) -> String { let prefix = match hw.hardware_type { HardwareType::Cpu => "CPU: ", - HardwareType::GpuNvidia | HardwareType::GpuAti | HardwareType::GpuIntel => "GPU: ", + HardwareType::GpuNvidia + | HardwareType::GpuAti + | HardwareType::GpuIntel + | HardwareType::GpuApple => "GPU: ", HardwareType::Ram => "", HardwareType::Storage | HardwareType::Hdd => "Drive: ", HardwareType::Network => "Network: ", diff --git a/app/src/ui/settings_dialog.rs b/app/src/ui/settings_dialog.rs index 09662d5d..7885895a 100644 --- a/app/src/ui/settings_dialog.rs +++ b/app/src/ui/settings_dialog.rs @@ -295,6 +295,7 @@ fn driver_tab(ui: &mut egui::Ui, s: &Shared, pal: &Palette) { let frame = s.frame(); let (source, diag) = (frame.source.clone(), frame.diagnostics.clone()); // App-token elevation is authoritative (independent of sidecar version). + #[cfg_attr(target_os = "macos", allow(unused_variables))] let elevated = s.elevated; ui.add_space(6.0); @@ -304,7 +305,33 @@ fn driver_tab(ui: &mut egui::Ui, s: &Shared, pal: &Palette) { ui.label(RichText::new(&diag.engine_version).size(11.0).color(pal.text_dim)); } + // macOS reads every sensor through IOKit with no kernel driver and no + // elevation, so the entire WinRing0/PawnIO apparatus below is meaningless + // there — and a button that silently does nothing is worse than no button. + #[cfg(target_os = "macos")] + { + ui.add_space(6.0); + ui.label( + RichText::new("✓ No kernel driver required — sensors are read directly via IOKit.") + .size(11.0) + .color(pal.ok_badge), + ); + ui.add_space(4.0); + ui.label( + RichText::new( + "Temperatures come from the IOHIDEventSystem sensor plane and power from \ + IOReport. Both are unprivileged, so SensorView never needs to run as root.", + ) + .size(11.0) + .color(pal.text_dim), + ); + driver_report_details(ui, pal, &diag); + return; + } + // Elevation status badge. + #[allow(unreachable_code)] + { ui.add_space(4.0); super::widgets::badge(ui, "Running as Administrator:", elevated, pal); @@ -374,22 +401,27 @@ fn driver_tab(ui: &mut egui::Ui, s: &Shared, pal: &Palette) { ui.hyperlink_to("PawnIO Website", "https://pawnio.eu/"); }); - // Raw driver report for troubleshooting. - if !diag.driver_report.is_empty() && diag.driver_report != "(no ring0 section in report)" { - ui.add_space(8.0); - ui.collapsing(RichText::new("Kernel driver report").size(11.0).color(pal.text_dim), |ui| { - egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| { - ui.label( - RichText::new(&diag.driver_report) - .size(10.0) - .monospace() - .color(pal.text_dim), - ); - }); - }); + driver_report_details(ui, pal, &diag); } } +/// The collapsible raw backend report. Shared so the macOS branch above can +/// show it without duplicating the widget. +fn driver_report_details(ui: &mut egui::Ui, pal: &Palette, diag: &crate::source::Diagnostics) { + if diag.driver_report.is_empty() || diag.driver_report == "(no ring0 section in report)" { + return; + } + let title = if cfg!(target_os = "macos") { "Sensor backend report" } else { "Kernel driver report" }; + ui.add_space(8.0); + ui.collapsing(RichText::new(title).size(11.0).color(pal.text_dim), |ui| { + egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| { + ui.label( + RichText::new(&diag.driver_report).size(10.0).monospace().color(pal.text_dim), + ); + }); + }); +} + fn stub_tab(ui: &mut egui::Ui, pal: &Palette, text: &str) { ui.add_space(10.0); ui.label(RichText::new(text).size(11.5).color(pal.text_dim)); diff --git a/app/src/ui/summary_window.rs b/app/src/ui/summary_window.rs index 75b050e1..5b1ed9fa 100644 --- a/app/src/ui/summary_window.rs +++ b/app/src/ui/summary_window.rs @@ -193,11 +193,22 @@ fn board_memory_panels(ui: &mut egui::Ui, i: &crate::sysinfo::SystemInfo, pal: & .map(|v| format!("{v} MT/s")) .unwrap_or_default(); info_row(ui, "Clock:", &clock, pal); - let mode = match i.memory_modules.len() { - 2 => "Dual-Channel", - 4 => "Quad-Channel", - 1 => "Single-Channel", - _ => "", + // Unified memory is a wide on-package bus, not a DIMM channel count — + // inferring "Single-Channel" from the one synthetic module would be + // plainly wrong. + let unified = i + .memory_modules + .first() + .is_some_and(|m| m.memory_type.contains("on-package")); + let mode = if unified { + "Unified" + } else { + match i.memory_modules.len() { + 2 => "Dual-Channel", + 4 => "Quad-Channel", + 1 => "Single-Channel", + _ => "", + } }; info_row(ui, "Mode:", mode, pal); info_row(ui, "Timings:", "", pal); // needs SPD — native engine @@ -355,7 +366,10 @@ fn gpu_live_clocks(tree: &[Hardware]) -> (Option, Option) { for hw in tree { if matches!( hw.hardware_type, - HardwareType::GpuNvidia | HardwareType::GpuAti | HardwareType::GpuIntel + HardwareType::GpuNvidia + | HardwareType::GpuAti + | HardwareType::GpuIntel + | HardwareType::GpuApple ) { for s in &hw.sensors { if s.sensor_type == SensorType::Clock { diff --git a/app/src/ui/widgets.rs b/app/src/ui/widgets.rs index 8e9385d1..57c08d91 100644 --- a/app/src/ui/widgets.rs +++ b/app/src/ui/widgets.rs @@ -47,7 +47,7 @@ pub fn hardware_icon(ui: &egui::Ui, rect: egui::Rect, t: crate::model::HardwareT let c = rect.center(); let col = match t { H::Cpu => pal.accent, - H::GpuNvidia | H::GpuAti | H::GpuIntel => pal.ok_badge, + H::GpuNvidia | H::GpuAti | H::GpuIntel | H::GpuApple => pal.ok_badge, H::Ram => pal.clockc, H::Storage | H::Hdd => pal.warn, H::Network => pal.fanc, @@ -62,7 +62,7 @@ pub fn hardware_icon(ui: &egui::Ui, rect: egui::Rect, t: crate::model::HardwareT let inner = egui::Rect::from_center_size(c, Vec2::splat(3.5)); p.rect_filled(inner, 0.0, col); } - H::GpuNvidia | H::GpuAti | H::GpuIntel => { + H::GpuNvidia | H::GpuAti | H::GpuIntel | H::GpuApple => { // Card: rectangle + fan circle. let r = egui::Rect::from_min_size(Pos2::new(c.x - 5.0, c.y - 3.5), Vec2::new(10.0, 7.0)); p.rect_stroke(r, 1.0, Stroke::new(1.2, col), StrokeKind::Inside);