From cae770c6eac555e240c4716a901d16a5cca23c2b Mon Sep 17 00:00:00 2001 From: Manupa Wickramasinghe Date: Thu, 30 Jul 2026 00:48:33 +0530 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20Add=20native=20macOS=20(Apple?= =?UTF-8?q?=20Silicon)=20sensor=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes macOS the second platform that reads real hardware. Previously default_source() returned the synthetic DemoSource for everything except Windows — Linux included — so only Windows had live sensors via the .NET LibreHardwareMonitor sidecar. Unlike Windows, macOS needs no sidecar, no kernel driver and no elevation: every source below is readable by an ordinary user. Die temperatures IOHIDEventSystemClient sensor plane (SPI) CPU/GPU/ANE power libIOReport "Energy Model" (SPI, dlopen'd) CPU/GPU clocks IOReport DVFS residency x pmgr voltage-states CPU load, memory Mach host_processor_info / host_statistics64 GPU load, VRAM IOKit IOAccelerator PerformanceStatistics SSD throughput IOKit IOBlockStorageDriver Battery IOKit AppleSmartBattery AppleSMC is deliberately unused — it does not exist on M-series Macs. IOReport and IOHIDEventSystem are private frameworks, so every symbol is resolved at runtime and every collector degrades to producing no sensors rather than panicking (the release profile is panic = "abort"). Fine for .dmg distribution and notarization; rules out the Mac App Store. Also on macOS: - sysinfo: populate the Summary window from sysctl (chip, cluster layout, memory, caches, board) and the ISA grid from hw.optional.arm.FEAT_*. Both were empty on aarch64, where the x86-gated CPUID paths do nothing. - fonts: load SF Pro / SF Mono. install_fonts only knew Windows paths, so macOS and Linux fell back to egui's bundled face and hex columns misaligned. Candidates are now validated by sfnt magic, which rejects .ttc collections (Menlo, Helvetica) that ab_glyph cannot parse. - settings: hide the PawnIO/WinRing0 driver block, which rendered a button that silently did nothing, and explain that no driver is needed. - inventory: MacInventory reports NVMe identity. It gets its own arm rather than joining the ACPI gate — Apple Silicon has no ACPI/SMBIOS at all. Narrowed five two-arm cfgs from not(windows)/not(x86_64) so the new macOS and aarch64 arms don't leave two live definitions, and moved winresource to a Windows-only build-dependency. Verified on a MacBook Air (Mac17,3, Apple M5, macOS 26.5.2). Sensors track real load: CPU 8.8 -> 83.5 %, package power 0.46 -> 23.9 W, die temp 38 -> 62 degC, E-cluster 1757 -> 2964 MHz, P-cluster 3182 -> 3720 MHz, while the GPU correctly stayed idle. 104 tests pass, clippy clean, and cargo packager produces a working arm64 SensorView.dmg. Co-Authored-By: Claude Opus 5 --- app/Cargo.lock | 13 + app/Cargo.toml | 19 +- app/build.rs | 13 + app/src/inventory.rs | Bin 12305 -> 12682 bytes app/src/model/mod.rs | 3 + app/src/source/macos/battery.rs | 147 ++++++++++ app/src/source/macos/dvfs.rs | 143 ++++++++++ app/src/source/macos/dynlib.rs | 102 +++++++ app/src/source/macos/freq.rs | 414 ++++++++++++++++++++++++++++ app/src/source/macos/gpu.rs | 105 +++++++ app/src/source/macos/hid.rs | 263 ++++++++++++++++++ app/src/source/macos/inventory.rs | 152 ++++++++++ app/src/source/macos/iokit.rs | 309 +++++++++++++++++++++ app/src/source/macos/ioreport.rs | 443 ++++++++++++++++++++++++++++++ app/src/source/macos/load.rs | 290 +++++++++++++++++++ app/src/source/macos/mod.rs | 356 ++++++++++++++++++++++++ app/src/source/macos/storage.rs | 147 ++++++++++ app/src/source/mod.rs | 18 +- app/src/sysinfo.rs | 226 ++++++++++++++- app/src/ui/hex_window.rs | 4 + app/src/ui/mod.rs | 115 ++++++-- app/src/ui/settings_dialog.rs | 58 +++- 22 files changed, 3299 insertions(+), 41 deletions(-) create mode 100644 app/src/source/macos/battery.rs create mode 100644 app/src/source/macos/dvfs.rs create mode 100644 app/src/source/macos/dynlib.rs create mode 100644 app/src/source/macos/freq.rs create mode 100644 app/src/source/macos/gpu.rs create mode 100644 app/src/source/macos/hid.rs create mode 100644 app/src/source/macos/inventory.rs create mode 100644 app/src/source/macos/iokit.rs create mode 100644 app/src/source/macos/ioreport.rs create mode 100644 app/src/source/macos/load.rs create mode 100644 app/src/source/macos/mod.rs create mode 100644 app/src/source/macos/storage.rs 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 c895550cba4459516450695c4215dae11fb26848..0e114f99d3b1d6ca94b1e18794e477ffcc0b64dc 100644 GIT binary patch delta 324 zcmYk2F-rqM5QQrn+tbeCkwW4jto4D#DF`W!h_Ux3dvkY7Hao}eCLvXNJINmrZ2U|9 z3zt*DVuoQD-h1D?)vx+}o*k4@r>U?eJzt>X7+_N6x$In?V@Z0&u7Ri6vq$X7VadjI zXvH?%z_=)B&1j8Z@5uA0)=}gFRDDNLzS68LyetmYy&H@O0hg@74|{>1yZC3qvFr$C`bvY yHK{o{iG6}OR@rA3$tV053An{1y+gv}czJenK8{6U+?Ero-xozi`r|8`UVH-~Vs20X delta 16 XcmeB5o|v$~UwCqe$hOUOqC7GHIx_|k 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..a856ebca --- /dev/null +++ b/app/src/source/macos/dvfs.rs @@ -0,0 +1,143 @@ +//! 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", + } + } +} + +/// 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 { + 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 into MHz. +fn parse_states(bytes: &[u8]) -> Vec { + let raw: Vec = bytes + .chunks_exact(8) + .map(|pair| u32::from_le_bytes([pair[0], pair[1], pair[2], pair[3]])) + .collect(); + if raw.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 = raw.iter().copied().max().unwrap_or(0) as f64; + let to_mhz: f64 = if max >= 100_000_000.0 { 1.0e6 } else { 1.0e3 }; + + raw.iter().map(|hz| (*hz as f64 / to_mhz) as f32).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), vec![972.0, 4464.0]); + + // 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), 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..f29c085e --- /dev/null +++ b/app/src/source/macos/dynlib.rs @@ -0,0 +1,102 @@ +//! 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() { + assert!( + !std::path::Path::new("/usr/lib/libIOReport.dylib").exists(), + "if this became a real file the comment above is stale" + ); + assert!(Library::open("/usr/lib/libIOReport.dylib").is_some()); + } +} diff --git a/app/src/source/macos/freq.rs b/app/src/source/macos/freq.rs new file mode 100644 index 00000000..fcd9e334 --- /dev/null +++ b/app/src/source/macos/freq.rs @@ -0,0 +1,414 @@ +//! 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}; +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::frequencies_mhz(Block::Ecpu), + pcpu: dvfs::frequencies_mhz(Block::Pcpu), + gpu: dvfs::frequencies_mhz(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. + 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-Cluster".to_string(), "ecpu".to_string()) + } else if name.starts_with("PCPU") { + (&self.pcpu, "P-Cluster".to_string(), "pcpu".to_string()) + } else { + continue; + }; + if table.is_empty() { + continue; + } + + let Some(mhz) = weighted_frequency(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)); + } + out + } +} + +/// Residency-weighted average frequency, excluding idle states. +/// +/// Returns `None` when the block was idle for the whole interval — reporting +/// 0 MHz there would be wrong, and reporting the base clock would be a guess. +fn weighted_frequency(api: &Api, channel: CFDictionaryRef, table: &[f32]) -> Option { + let count = unsafe { (api.state_count)(channel) }; + if count <= 0 { + return None; + } + + let mut weighted = 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() == Some(&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(mhz) = 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 *mhz <= 0.0 { + continue; + } + weighted += *mhz as f64 * residency as f64; + active += residency as f64; + } + + if active <= 0.0 { + return None; + } + let mhz = (weighted / active) as f32; + mhz.is_finite().then_some(mhz) +} + +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"); + } + for s in &sensors { + let mhz = s.value.unwrap(); + assert_eq!(s.sensor_type, SensorType::Clock); + // Anything outside this means the state table and the residency + // indices are misaligned, or the unit scale is wrong. + assert!( + (100.0..=6000.0).contains(&mhz), + "{} = {mhz} MHz is not a plausible Apple Silicon clock", + s.name + ); + } + } + + #[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"); + assert!(sensors.len() <= 3, "expected at most E-cluster, P-cluster and GPU"); + } +} diff --git a/app/src/source/macos/gpu.rs b/app/src/source/macos/gpu.rs new file mode 100644 index 00000000..fc175680 --- /dev/null +++ b/app/src/source/macos/gpu.rs @@ -0,0 +1,105 @@ +//! 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 load = hw + .sensors + .iter() + .find(|s| s.sensor_type == SensorType::Load) + .expect("at least one utilisation sensor"); + 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..bffb3a0e --- /dev/null +++ b/app/src/source/macos/hid.rs @@ -0,0 +1,263 @@ +//! 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; +use super::sensor; + +/// `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, + /// Sensor names discovered at construction, in service order. + 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(), 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 }); + this.names = this.service_names(); + this + } + + /// The matched services, as a CF array. Caller owns the array. + fn 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() 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 responding service. + pub fn temperatures(&self) -> Vec { + let Some(api) = self.api.as_ref() else { + return Vec::new(); + }; + let Some(services) = self.services() 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) }; + if event.is_null() { + // Sensors go quiet when the subsystem they measure is powered + // down; that's normal, so skip rather than reporting 0 °C. + continue; + } + let celsius = 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 °C reading in the UI is + // worse than an absent sensor. + if !celsius.is_finite() || !(1.0..=150.0).contains(&celsius) { + continue; + } + + let name = self.names.get(index).cloned().unwrap_or_else(|| format!("Sensor {index}")); + out.push(sensor( + &format!("/applesoc/0/temperature/{index}"), + &name, + SensorType::Temperature, + index as u32, + celsius as f32, + )); + } + 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"); + } + for s in &temps { + let v = s.value.unwrap(); + assert!((1.0..=150.0).contains(&v), "{} = {v} °C out of range", s.name); + assert!(!s.name.is_empty()); + } + } + + /// 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..1739627a --- /dev/null +++ b/app/src/source/macos/inventory.rs @@ -0,0 +1,152 @@ +//! 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 = whole_media_capacities(); + + 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). +fn whole_media_capacities() -> 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 must look like a real SSD, not a partition or a byte count + // that got mistaken for something else. + let bytes = drive.capacity_bytes.expect("whole-media size"); + assert!( + bytes > 100_000_000_000, + "capacity {bytes} bytes is too small to be the internal 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 `whole_media_capacities`. + /// Before the class check this returned four entries on an M5 Air — the + /// real 500 GB disk plus three APFS synthesized containers. + #[test] + fn apfs_containers_and_partitions_are_not_counted_as_disks() { + let sizes = whole_media_capacities(); + if sizes.is_empty() { + return crate::source::macos::absent("physical IOMedia"); + } + assert!( + sizes.iter().all(|s| *s > 10_000_000_000), + "a partition or APFS container leaked in: {sizes:?}" + ); + // One internal SSD, and no synthesized duplicates of it. + assert_eq!(sizes.len(), 1, "expected exactly one physical disk, got {sizes:?}"); + } +} diff --git a/app/src/source/macos/iokit.rs b/app/src/source/macos/iokit.rs new file mode 100644 index 00000000..0131f998 --- /dev/null +++ b/app/src/source/macos/iokit.rs @@ -0,0 +1,309 @@ +//! 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"); + assert!(!services.is_empty(), "IOPlatformExpertDevice should always match on macOS"); + let props = properties(services[0].0).expect("platform expert has properties"); + // `model` is CFData here ("Mac17,3"), which is exactly the CFData + // fallback in dict_string. + let model = dict_string(&props, "model").expect("model property"); + assert!(!model.is_empty()); + } + + /// 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 props = properties(services[0].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..6d7b3caf --- /dev/null +++ b/app/src/source/macos/ioreport.rs @@ -0,0 +1,443 @@ +//! 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; + } + let energy = unsafe { (api.integer_value)(raw, 0) }; + if energy <= 0 { + continue; + } + + // 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..7d133355 --- /dev/null +++ b/app/src/source/macos/load.rs @@ -0,0 +1,290 @@ +//! 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(); + assert!(!sensors.is_empty(), "host_statistics64 should work unprivileged"); + + 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(); + assert!(!sensors.is_empty(), "second poll should produce load"); + // One total + one per logical core. + let cores = crate::sysinfo::sysctl_u64("hw.logicalcpu").unwrap_or(0) as usize; + assert_eq!(sensors.len(), cores + 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..f88ef799 --- /dev/null +++ b/app/src/source/macos/mod.rs @@ -0,0 +1,356 @@ +//! 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; + +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 { + identifier: identifier.to_string(), + name: name.to_string(), + sensor_type, + index, + value: Some(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(); + + assert!(!tree.is_empty(), "expected a non-empty hardware tree"); + + // CPU load and memory come from the public Mach interfaces, which work + // everywhere including virtualized runners — so these are unconditional. + let types: Vec<_> = tree.iter().map(|h| h.hardware_type).collect(); + assert!(types.contains(&HardwareType::Cpu), "SoC node missing"); + assert!(types.contains(&HardwareType::Ram), "memory node missing"); + // GPU and storage depend on IOKit services a VM may not expose. + if !types.contains(&HardwareType::GpuApple) { + absent("GPU node"); + } + if !types.contains(&HardwareType::Storage) { + absent("storage node"); + } + + // Every published reading 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 { + let v = s.value.expect("published sensors always carry a value"); + assert!(v.is_finite(), "{}/{} is not finite", hw.name, s.name); + } + } + } + + /// 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 soc = tree + .iter() + .find(|h| h.hardware_type == HardwareType::Cpu) + .expect("SoC node"); + // Load is from Mach and always available; temperature and power come + // from SPI that a virtualized runner may not expose. + assert!( + soc.sensors.iter().any(|s| s.sensor_type == SensorType::Load), + "SoC node has no Load sensor" + ); + for wanted in [SensorType::Temperature, SensorType::Power] { + if !soc.sensors.iter().any(|s| s.sensor_type == wanted) { + absent(&format!("SoC {wanted:?} sensors")); + } + } + } + + #[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..e736a102 --- /dev/null +++ b/app/src/source/macos/storage.rs @@ -0,0 +1,147 @@ +//! 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[0].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[0] + .sensors + .iter() + .filter(|s| s.sensor_type == SensorType::Throughput) + .collect(); + assert_eq!(rates.len(), 2, "read + write rate on the second poll"); + 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/mod.rs b/app/src/source/mod.rs index ddb05737..fae8773a 100644 --- a/app/src/source/mod.rs +++ b/app/src/source/mod.rs @@ -12,6 +12,8 @@ pub mod demo; pub mod firmware; #[cfg(windows)] pub mod lhm_bridge; +#[cfg(target_os = "macos")] +pub mod macos; use serde::{Deserialize, Serialize}; @@ -49,8 +51,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. Other platforms +/// (and `SENSORVIEW_SOURCE=demo`): the demo source. +/// +/// Note the negative arm is `not(any(windows, macos))`, not `not(windows)`: +/// with two-arm cfgs, adding a third platform without narrowing the fallback +/// leaves *both* blocks live and the last one wins, which reads 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()); @@ -65,7 +73,11 @@ pub fn default_source() -> Box { } } } - #[cfg(not(windows))] + #[cfg(target_os = "macos")] + { + Box::new(macos::MacSource::new()) + } + #[cfg(not(any(windows, target_os = "macos")))] { Box::new(demo::DemoSource::new()) } diff --git a/app/src/sysinfo.rs b/app/src/sysinfo.rs index 9b28f84e..5954bac5 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,85 @@ 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, + } +} + /// 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 +328,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,7 +543,98 @@ fn read_secure_boot() -> Option { } } -#[cfg(not(windows))] +#[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 rather than + // inventing a discrete-adapter identity. + let chip = sysctl_string("machdep.cpu.brand_string").unwrap_or_default(); + let gpus = if chip.is_empty() { + Vec::new() + } else { + vec![GpuInfo { name: format!("{chip} GPU"), vram_gb: None, driver_version: String::new() }] + }; + + 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), + l2_kb: sysctl_u64("hw.l2cachesize").map(|b| (b / 1024) as u32), + socket: (!cluster_desc.is_empty()).then(|| cluster_desc.join(" + ")), + features: cpu_features(), + cpuid, + vendor, + codename, + ..Default::default() + }, + board: BoardInfo { + product: sysctl_string("hw.model").unwrap_or_default(), + manufacturer: "Apple Inc.".into(), + ..Default::default() + }, + memory_modules, + total_memory_gb, + gpus, + 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, + }, + ..Default::default() + } +} + +#[cfg(not(any(windows, target_os = "macos")))] 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..807a26bb 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,34 @@ 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"); + } + } + assert!( + PROPORTIONAL_FONTS.iter().any(|p| load_font(p).is_some()), + "no usable proportional system font found" + ); + assert!( + MONOSPACE_FONTS.iter().any(|p| load_font(p).is_some()), + "no usable monospace system font found" + ); + } + + #[test] + fn missing_font_is_none() { + assert!(load_font("/no/such/font.ttf").is_none()); + } +} 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)); From dd915e9a7b471379f2e6fa865ff9b1076d504776 Mon Sep 17 00:00:00 2001 From: Manupa Wickramasinghe Date: Thu, 30 Jul 2026 00:53:48 +0530 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=90=9B=20Populate=20GPU,=20motherboar?= =?UTF-8?q?d=20and=20drive=20data=20in=20System=20Summary=20on=20macOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Summary window's GPU and Motherboard panels were effectively blank on Apple Silicon, and GPU rows were missing from the sensor UI entirely. Two separate causes: 1. Adding HardwareType::GpuApple did not update the four places that match on the GPU variants, so an Apple GPU node fell through every one: - summary_window: gpu_live_clocks never matched, leaving "GPU Clock" blank even though the sensor existed - sensors_window: no "GPU: " row prefix - widgets: no icon and no accent colour in the device tree (main_window's Video Adapter node passes GpuNvidia only as an icon selector, so it needed no change.) 2. sysinfo::query() left board.bios_version, gpus[].driver_version and the whole drives list empty. New source/macos/sysprofile.rs reads them from IOKit, keeping the FFI behind the iokit helper module: - product-name from IODeviceTree:/product — the marketing name ("MacBook Air (13-inch, M5)") rather than the bare board id - system-firmware-version from IODeviceTree:/chosen, the Apple Silicon analogue of a BIOS version ("mBoot-18000.121.3") - gpu-core-count and MetalPluginName for GPU identity - internal NVMe model and capacity for the Drives panel Summary now reads: Apple Inc. MacBook Air (13-inch, M5) / mBoot-18000.121.3 / Apple M5, 10 cores (4 Super + 6 Efficiency) / 16 GB Unified Memory / Apple M5 GPU (8 cores), driver AGXMetalG17G / APPLE SSD AP0512Z NVMe 465 GB / macOS 26.5.2 build 25F84. bios_date stays empty — no firmware build date is published anywhere in IOKit, and deriving one from the version string would be a guess. Co-Authored-By: Claude Opus 5 --- app/src/source/macos/inventory.rs | 6 +- app/src/source/macos/mod.rs | 1 + app/src/source/macos/sysprofile.rs | 163 +++++++++++++++++++++++++++++ app/src/sysinfo.rs | 50 +++++++-- app/src/ui/sensors_window.rs | 5 +- app/src/ui/summary_window.rs | 5 +- app/src/ui/widgets.rs | 4 +- 7 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 app/src/source/macos/sysprofile.rs diff --git a/app/src/source/macos/inventory.rs b/app/src/source/macos/inventory.rs index 1739627a..7996c809 100644 --- a/app/src/source/macos/inventory.rs +++ b/app/src/source/macos/inventory.rs @@ -32,7 +32,7 @@ impl InventorySource for MacInventory { 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 = whole_media_capacities(); + let capacity = physical_disk_sizes(); iokit::matching_services("IONVMeController") .iter() @@ -80,7 +80,7 @@ fn collect_storage() -> Vec { /// `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). -fn whole_media_capacities() -> Vec { +pub fn physical_disk_sizes() -> Vec { let mut sizes: Vec = iokit::matching_services("IOMedia") .iter() .filter_map(|service| { @@ -138,7 +138,7 @@ mod tests { /// real 500 GB disk plus three APFS synthesized containers. #[test] fn apfs_containers_and_partitions_are_not_counted_as_disks() { - let sizes = whole_media_capacities(); + let sizes = physical_disk_sizes(); if sizes.is_empty() { return crate::source::macos::absent("physical IOMedia"); } diff --git a/app/src/source/macos/mod.rs b/app/src/source/macos/mod.rs index f88ef799..f26c753c 100644 --- a/app/src/source/macos/mod.rs +++ b/app/src/source/macos/mod.rs @@ -35,6 +35,7 @@ pub mod iokit; pub mod ioreport; pub mod load; pub mod storage; +pub mod sysprofile; use crate::model::{Hardware, HardwareType, Sensor, SensorType}; diff --git a/app/src/source/macos/sysprofile.rs b/app/src/source/macos/sysprofile.rs new file mode 100644 index 00000000..5ef035f3 --- /dev/null +++ b/app/src/source/macos/sysprofile.rs @@ -0,0 +1,163 @@ +//! 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!(" 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 bytes = size.expect("capacity"); + assert!(bytes > 100_000_000_000, "capacity {bytes} too small for an internal SSD"); + } +} diff --git a/app/src/sysinfo.rs b/app/src/sysinfo.rs index 5954bac5..6f83c7ed 100644 --- a/app/src/sysinfo.rs +++ b/app/src/sysinfo.rs @@ -289,6 +289,13 @@ pub(crate) fn sysctl_u64(name: &str) -> Option { } } +/// Synchronous `query()` for tests and diagnostics — `spawn_query` returns a +/// handle that only fills in later, which is awkward to assert against. +#[cfg(test)] +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)); @@ -581,15 +588,37 @@ fn query() -> SystemInfo { }) .unwrap_or_default(); - // The integrated GPU shares the SoC; name it after the chip rather than - // inventing a discrete-adapter identity. + // 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 { - vec![GpuInfo { name: format!("{chip} GPU"), vram_gb: None, driver_version: String::new() }] + 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(); + let os_version = sysctl_string("kern.osproductversion").unwrap_or_default(); SystemInfo { @@ -609,13 +638,23 @@ fn query() -> SystemInfo { ..Default::default() }, board: BoardInfo { - product: sysctl_string("hw.model").unwrap_or_default(), + // 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(), - ..Default::default() + // 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() @@ -630,7 +669,6 @@ fn query() -> SystemInfo { uefi_boot: None, secure_boot: None, }, - ..Default::default() } } 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/summary_window.rs b/app/src/ui/summary_window.rs index 75b050e1..43128dbb 100644 --- a/app/src/ui/summary_window.rs +++ b/app/src/ui/summary_window.rs @@ -355,7 +355,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); From caad60e1215e247590aa44af9834bd4f54e747ae Mon Sep 17 00:00:00 2001 From: Manupa Wickramasinghe Date: Thu, 30 Jul 2026 00:59:19 +0530 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=90=9B=20Stop=20macOS=20sensors=20fli?= =?UTF-8?q?ckering=20in=20and=20out=20between=20polls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Values appeared for a moment and then vanished, in both the Sensors table and the System Summary. Rate-derived sensors were dropped entirely on any poll where the underlying block was idle: - a power rail that accumulated no measurable energy in the interval was skipped (`energy <= 0 => continue`). On this M5 the ISP and SOC_AON rails do exactly that at idle, so /applesoc/0/power/isp and .../soc_aon disappeared and came back tick after tick. - weighted_frequency returned None when a cluster or the GPU had no non-idle residency, removing the clock sensor with it. Both now keep the sensor and report a defensible value: 0 W is the actual measurement for a gated rail, and a fully idle block reports the lowest running DVFS state — the same value it already reports when barely active. Counter wraps are clamped to 0 rather than published as negatives. Also fixes two Summary rows that were blank for a different reason: - Cluster clocks were named "E-Cluster"/"P-Cluster", but the Summary picks CPU clocks out of the tree by matching the substring "core" (so it can exclude bus speed). Renamed to "E-Core Clock"/"P-Core Clock", which reads better anyway, and noted the coupling at the naming site. - Base/Max Clock had no source. Apple Silicon has no fixed base clock, so they now come from the bottom and top of the performance cluster's DVFS table (1308 / 4464 MHz here). Covered by sensor_set_is_stable_across_polls, which polls repeatedly on an otherwise quiet machine and asserts the published identifier set does not change. Verified it fails against the previous behaviour. Co-Authored-By: Claude Opus 5 --- app/src/source/macos/freq.rs | 22 +++++++++++++---- app/src/source/macos/ioreport.rs | 10 ++++---- app/src/source/macos/mod.rs | 38 ++++++++++++++++++++++++++++++ app/src/source/macos/sysprofile.rs | 1 + app/src/sysinfo.rs | 9 +++++++ 5 files changed, 71 insertions(+), 9 deletions(-) diff --git a/app/src/source/macos/freq.rs b/app/src/source/macos/freq.rs index fcd9e334..a7f3e643 100644 --- a/app/src/source/macos/freq.rs +++ b/app/src/source/macos/freq.rs @@ -258,13 +258,19 @@ impl FrequencyReporter { // ("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-Cluster".to_string(), "ecpu".to_string()) + (&self.ecpu, "E-Core Clock".to_string(), "ecpu".to_string()) } else if name.starts_with("PCPU") { - (&self.pcpu, "P-Cluster".to_string(), "pcpu".to_string()) + (&self.pcpu, "P-Core Clock".to_string(), "pcpu".to_string()) } else { continue; }; @@ -292,8 +298,13 @@ impl FrequencyReporter { /// Residency-weighted average frequency, excluding idle states. /// -/// Returns `None` when the block was idle for the whole interval — reporting -/// 0 MHz there would be wrong, and reporting the base clock would be a guess. +/// 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_frequency(api: &Api, channel: CFDictionaryRef, table: &[f32]) -> Option { let count = unsafe { (api.state_count)(channel) }; if count <= 0 { @@ -342,7 +353,8 @@ fn weighted_frequency(api: &Api, channel: CFDictionaryRef, table: &[f32]) -> Opt } if active <= 0.0 { - return None; + // Fully idle: fall back to the lowest running state. + return table.iter().copied().find(|mhz| *mhz > 0.0); } let mhz = (weighted / active) as f32; mhz.is_finite().then_some(mhz) diff --git a/app/src/source/macos/ioreport.rs b/app/src/source/macos/ioreport.rs index 6d7b3caf..4f2ba43f 100644 --- a/app/src/source/macos/ioreport.rs +++ b/app/src/source/macos/ioreport.rs @@ -268,10 +268,12 @@ impl EnergyReporter { if unsafe { (api.channel_format)(raw) } != FORMAT_SIMPLE { continue; } - let energy = unsafe { (api.integer_value)(raw, 0) }; - if energy <= 0 { - 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. diff --git a/app/src/source/macos/mod.rs b/app/src/source/macos/mod.rs index f26c753c..1f281810 100644 --- a/app/src/source/macos/mod.rs +++ b/app/src/source/macos/mod.rs @@ -307,6 +307,44 @@ mod tests { } } + /// 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()); + assert!(!baseline.is_empty()); + + // 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(); diff --git a/app/src/source/macos/sysprofile.rs b/app/src/source/macos/sysprofile.rs index 5ef035f3..c23f3de5 100644 --- a/app/src/source/macos/sysprofile.rs +++ b/app/src/source/macos/sysprofile.rs @@ -93,6 +93,7 @@ mod tests { 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 ---"); diff --git a/app/src/sysinfo.rs b/app/src/sysinfo.rs index 6f83c7ed..da4f248b 100644 --- a/app/src/sysinfo.rs +++ b/app/src/sysinfo.rs @@ -619,6 +619,10 @@ fn query() -> SystemInfo { }) .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 { @@ -629,6 +633,11 @@ fn query() -> SystemInfo { 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), l2_kb: sysctl_u64("hw.l2cachesize").map(|b| (b / 1024) as u32), socket: (!cluster_desc.is_empty()).then(|| cluster_desc.join(" + ")), features: cpu_features(), From 1f5579e4ccc581d44f9a3a1c53748ddbfc8a0ed7 Mon Sep 17 00:00:00 2001 From: Manupa Wickramasinghe Date: Thu, 30 Jul 2026 01:14:30 +0530 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=90=9B=20Fix=20remaining=20macOS=20se?= =?UTF-8?q?nsor=20dropouts=20and=20blank=20Summary=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the flicker fix: two of the three collectors were corrected, but hid.rs — which produces 45 of the ~70 published sensors — still dropped any sensor whose service didn't answer that poll. Services go quiet whenever the subsystem they measure powers down, which is normal and intermittent, so this was the larger source of rows blinking in and out. Two changes there: - The service list is now captured once at construction and reused, instead of being re-enumerated every poll. Re-enumerating risked a different order or count, which would have misaligned services against the cached names and silently changed what each sensor identity meant mid-session. - A non-responding service now publishes value: None rather than being omitted. Sensor::value is Option precisely for this ("present but has not yet produced a reading"); poll::enrich skips None without disturbing the accumulated min/max/avg, and the CSV logger writes an empty cell without shifting columns. This also keeps de-duplication stable: names are numbered by position, so a dropped sensor used to renumber every same-named sensor below it — with seven "gas gauge battery" probes, labels visibly reshuffled. Summary fields that were still blank or wrong: - VID column was empty. Apple Silicon has no voltage sensor, but the DVFS tables pair every frequency with its rail voltage, and that column was being parsed and discarded. The same residency weighting that produces the clock now also produces a real per-cluster VID (E 0.84 V, P 0.97 V). - L2 Cache reported 6144 KB, which is the *efficiency* cluster. hw.perflevel0.l2cachesize gives the performance cluster's 16 MB, which is the headline figure. L3 is explicitly None — Apple Silicon has no per-core L3 and the system-level cache isn't published. - Memory Mode read "Single-Channel", inferred from the single synthetic unified-memory module. On-package LPDDR is not a DIMM channel count, so it now reads "Unified". Also relaxed the libIOReport dlopen test to skip rather than fail when the SPI is absent, matching the other hardware-dependent tests — it is private API and a virtualized CI runner may not ship it. Co-Authored-By: Claude Opus 5 --- app/src/source/macos/dvfs.rs | 47 +++++++++++++++---- app/src/source/macos/dynlib.rs | 11 ++++- app/src/source/macos/freq.rs | 83 ++++++++++++++++++++++++---------- app/src/source/macos/hid.rs | 76 ++++++++++++++++++++----------- app/src/source/macos/mod.rs | 34 ++++++++++++-- app/src/sysinfo.rs | 11 ++++- app/src/ui/summary_window.rs | 21 +++++++-- 7 files changed, 209 insertions(+), 74 deletions(-) diff --git a/app/src/source/macos/dvfs.rs b/app/src/source/macos/dvfs.rs index a856ebca..ba48ed8f 100644 --- a/app/src/source/macos/dvfs.rs +++ b/app/src/source/macos/dvfs.rs @@ -41,11 +41,27 @@ impl Block { } } +/// 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(); }; @@ -58,23 +74,35 @@ pub fn frequencies_mhz(block: Block) -> Vec { parse_states(&bytes) } -/// Decode packed `(freq, voltage)` `u32` pairs into MHz. -fn parse_states(bytes: &[u8]) -> Vec { - let raw: Vec = bytes +/// Decode packed `(freq, voltage)` `u32` pairs. +fn parse_states(bytes: &[u8]) -> Vec { + let pairs: Vec<(u32, u32)> = bytes .chunks_exact(8) - .map(|pair| u32::from_le_bytes([pair[0], pair[1], pair[2], pair[3]])) + .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 raw.is_empty() { + 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 = raw.iter().copied().max().unwrap_or(0) as f64; + 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 }; - raw.iter().map(|hz| (*hz as f64 / to_mhz) as f32).collect() + 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)] @@ -124,14 +152,15 @@ mod tests { .iter() .flat_map(|v| v.to_le_bytes()) .collect::>(); - assert_eq!(parse_states(&khz), vec![972.0, 4464.0]); + 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), vec![338.0, 1578.0]); + assert_eq!(parse_states(&hz).iter().map(|s| s.mhz).collect::>(), vec![338.0, 1578.0]); } #[test] diff --git a/app/src/source/macos/dynlib.rs b/app/src/source/macos/dynlib.rs index f29c085e..3c93fcbe 100644 --- a/app/src/source/macos/dynlib.rs +++ b/app/src/source/macos/dynlib.rs @@ -93,10 +93,17 @@ mod tests { /// 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(), - "if this became a real file the comment above is stale" + "libIOReport became a real file; the dlopen-not-link rationale is stale" ); - assert!(Library::open("/usr/lib/libIOReport.dylib").is_some()); } } diff --git a/app/src/source/macos/freq.rs b/app/src/source/macos/freq.rs index a7f3e643..61ccf7c3 100644 --- a/app/src/source/macos/freq.rs +++ b/app/src/source/macos/freq.rs @@ -23,7 +23,7 @@ use core_foundation_sys::string::CFStringRef; use crate::model::{Sensor, SensorType}; -use super::dvfs::{self, Block}; +use super::dvfs::{self, Block, State}; use super::dynlib::Library; use super::sensor; @@ -96,9 +96,9 @@ impl Drop for Group { pub struct FrequencyReporter { api: Option, groups: Vec, - ecpu: Vec, - pcpu: Vec, - gpu: Vec, + ecpu: Vec, + pcpu: Vec, + gpu: Vec, } // SAFETY: as `ioreport::EnergyReporter` — the CF handles are owned solely by @@ -110,9 +110,9 @@ impl FrequencyReporter { let mut this = Self { api: None, groups: Vec::new(), - ecpu: dvfs::frequencies_mhz(Block::Ecpu), - pcpu: dvfs::frequencies_mhz(Block::Pcpu), - gpu: dvfs::frequencies_mhz(Block::Gpu), + 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 { @@ -278,7 +278,7 @@ impl FrequencyReporter { continue; } - let Some(mhz) = weighted_frequency(api, raw, table) else { + let Some((mhz, volts)) = weighted_state(api, raw, table) else { continue; }; @@ -291,6 +291,22 @@ impl FrequencyReporter { } 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 } @@ -305,13 +321,14 @@ impl FrequencyReporter { /// 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_frequency(api: &Api, channel: CFDictionaryRef, table: &[f32]) -> Option { +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 @@ -320,7 +337,7 @@ fn weighted_frequency(api: &Api, channel: CFDictionaryRef, table: &[f32]) -> Opt // 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() == Some(&0.0); + 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 @@ -340,24 +357,26 @@ fn weighted_frequency(api: &Api, channel: CFDictionaryRef, table: &[f32]) -> Opt if idle { continue; } - let Some(mhz) = table.get((index - offset) as usize) else { + 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 *mhz <= 0.0 { + if state.mhz <= 0.0 { continue; } - weighted += *mhz as f64 * residency as f64; + 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().copied().find(|mhz| *mhz > 0.0); + return table.iter().find(|s| s.mhz > 0.0).map(|s| (s.mhz, s.volts)); } let mhz = (weighted / active) as f32; - mhz.is_finite().then_some(mhz) + let volts = (weighted_v / active) as f32; + (mhz.is_finite() && volts.is_finite()).then_some((mhz, volts)) } fn cf_string(raw: CFStringRef) -> String { @@ -399,17 +418,27 @@ mod tests { 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 mhz = s.value.unwrap(); - assert_eq!(s.sensor_type, SensorType::Clock); - // Anything outside this means the state table and the residency - // indices are misaligned, or the unit scale is wrong. - assert!( - (100.0..=6000.0).contains(&mhz), - "{} = {mhz} MHz is not a plausible Apple Silicon clock", - s.name - ); + 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] @@ -421,6 +450,10 @@ mod tests { let ids: std::collections::HashSet<_> = sensors.iter().map(|s| &s.identifier).collect(); assert_eq!(ids.len(), sensors.len(), "duplicate clock identifiers"); - assert!(sensors.len() <= 3, "expected at most E-cluster, P-cluster and GPU"); + 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/hid.rs b/app/src/source/macos/hid.rs index bffb3a0e..8cae9b45 100644 --- a/app/src/source/macos/hid.rs +++ b/app/src/source/macos/hid.rs @@ -23,7 +23,6 @@ use core_foundation_sys::string::CFStringRef; use crate::model::{Sensor, SensorType}; use super::dynlib; -use super::sensor; /// `kHIDPage_AppleVendor`. const APPLE_VENDOR_USAGE_PAGE: i32 = 0xff00; @@ -60,7 +59,11 @@ pub struct HidSensors { /// The HID client, retained for the collector's lifetime. Creating one per /// poll leaks kernel ports and is measurably slow. client: CFTypeRef, - /// Sensor names discovered at construction, in service order. + /// 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, } @@ -80,8 +83,12 @@ impl Drop for HidSensors { impl HidSensors { pub fn new() -> Self { - let mut this = - Self { api: None, client: std::ptr::null(), names: Vec::new() }; + 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. @@ -123,12 +130,15 @@ impl HidSensors { 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 } - /// The matched services, as a CF array. Caller owns the array. - fn services(&self) -> Option> { + /// 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; @@ -144,7 +154,7 @@ impl HidSensors { let Some(api) = self.api.as_ref() else { return Vec::new(); }; - let Some(services) = self.services() else { + let Some(services) = self.services.as_ref() else { return Vec::new(); }; let key = CFString::new("Product"); @@ -175,12 +185,23 @@ impl HidSensors { self.names.len() } - /// One `Temperature` sensor per responding service. + /// 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() else { + let Some(services) = self.services.as_ref() else { return Vec::new(); }; @@ -188,28 +209,25 @@ impl HidSensors { for (index, service) in services.iter().enumerate() { let event = unsafe { (api.copy_event)(service.as_CFTypeRef(), EVENT_TYPE_TEMPERATURE, 0, 0) }; - if event.is_null() { - // Sensors go quiet when the subsystem they measure is powered - // down; that's normal, so skip rather than reporting 0 °C. - continue; - } - let celsius = 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 °C reading in the UI is - // worse than an absent sensor. - if !celsius.is_finite() || !(1.0..=150.0).contains(&celsius) { - continue; - } + 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(sensor( + out.push(super::sensor_opt( &format!("/applesoc/0/temperature/{index}"), &name, SensorType::Temperature, index as u32, - celsius as f32, + celsius, )); } out @@ -236,10 +254,16 @@ mod tests { 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 { - let v = s.value.unwrap(); - assert!((1.0..=150.0).contains(&v), "{} = {v} °C out of range", s.name); 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); + } } } diff --git a/app/src/source/macos/mod.rs b/app/src/source/macos/mod.rs index 1f281810..daa1413f 100644 --- a/app/src/source/macos/mod.rs +++ b/app/src/source/macos/mod.rs @@ -48,13 +48,31 @@ pub(crate) fn sensor( 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: Some(value), + value, min: None, max: None, avg: None, @@ -271,14 +289,20 @@ mod tests { absent("storage node"); } - // Every published reading must be a real number — NaN/inf would - // propagate into the graphs and the CSV log. + // 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 { - let v = s.value.expect("published sensors always carry a value"); - assert!(v.is_finite(), "{}/{} is not finite", hw.name, s.name); + if let Some(v) = s.value { + assert!(v.is_finite(), "{}/{} is not finite", hw.name, s.name); + } } } + assert!( + tree.iter().any(|hw| hw.sensors.iter().any(|s| s.value.is_some())), + "the whole tree produced no readings" + ); } /// The SoC node is the headline: it must carry temperature, power and load diff --git a/app/src/sysinfo.rs b/app/src/sysinfo.rs index da4f248b..c3ddf2fa 100644 --- a/app/src/sysinfo.rs +++ b/app/src/sysinfo.rs @@ -638,13 +638,20 @@ fn query() -> SystemInfo { // 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), - l2_kb: sysctl_u64("hw.l2cachesize").map(|b| (b / 1024) 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, - ..Default::default() }, board: BoardInfo { // Prefer the marketing name ("MacBook Air (13-inch, M5)") over the diff --git a/app/src/ui/summary_window.rs b/app/src/ui/summary_window.rs index 43128dbb..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 From c5ebd04cf2777aeb142b51b99a513f9e8c6a18a4 Mon Sep 17 00:00:00 2001 From: Manupa Wickramasinghe Date: Thu, 30 Jul 2026 20:26:21 +0530 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=94=A7=20Harden=20macOS=20tests=20for?= =?UTF-8?q?=20CI=20runners,=20surface=20test=20failures=20as=20annotations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows and Linux went green after the dead_code fix; macOS still failed at "Run tests" with nothing but "exit code 101" to go on. CI runs macOS virtualized, and several tests still encoded the *shape* of the development machine rather than the behaviour of the code: - gpu: expect()ed a utilisation sensor. An accelerator can exist without PerformanceStatistics. - inventory / sysprofile: expect()ed a capacity for every NVMe controller. Capacity is paired positionally with whole-media nodes, which only holds when the machine exposes matching ones. - inventory: asserted exactly one physical disk. Disk count is a property of the machine, not the code — the real invariant is that no partition or APFS container leaks through the two filters, so that is what it checks now. - iokit: expect()ed IOPlatformExpertDevice to carry a `model` property. - storage: indexed device [0] and demanded exactly 2 throughput sensors. Now aggregates across devices and requires a read/write pair per device. All of these degrade to "SKIP: ... not available" instead. Only presence and machine shape are ever skipped; the range, uniqueness, ordering and stability assertions still run wherever the hardware is real. CI itself: - The test step now echoes failing test names and panic messages as ::error:: annotations. A one-OS-only failure was previously invisible without opening the raw log, which is exactly the case that needs it most. Uses --no-fail-fast so one failure doesn't mask the rest. - actions/checkout v4 -> v5, clearing the Node 20 deprecation warning. - cargo-packager pinned to 0.11.8 rather than installing latest, so an upstream release can't silently break packaging. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 25 ++- .github/workflows/release.yml | 4 +- README.md | 344 +++++++++++++++++++++++++++++ app/src/source/macos/gpu.rs | 8 +- app/src/source/macos/inventory.rs | 24 +- app/src/source/macos/iokit.rs | 12 +- app/src/source/macos/storage.rs | 12 +- app/src/source/macos/sysprofile.rs | 6 +- 8 files changed, 405 insertions(+), 30 deletions(-) create mode 100644 README.md 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/src/source/macos/gpu.rs b/app/src/source/macos/gpu.rs index fc175680..3998937c 100644 --- a/app/src/source/macos/gpu.rs +++ b/app/src/source/macos/gpu.rs @@ -94,11 +94,9 @@ mod tests { }; assert_eq!(hw.hardware_type, HardwareType::GpuApple); - let load = hw - .sensors - .iter() - .find(|s| s.sensor_type == SensorType::Load) - .expect("at least one utilisation sensor"); + 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/inventory.rs b/app/src/source/macos/inventory.rs index 7996c809..ca5a5022 100644 --- a/app/src/source/macos/inventory.rs +++ b/app/src/source/macos/inventory.rs @@ -113,13 +113,12 @@ mod tests { assert!(!drive.model.is_empty(), "model number should be populated"); assert_eq!(drive.protocol, StorageProtocol::Nvme); - // Capacity must look like a real SSD, not a partition or a byte count - // that got mistaken for something else. - let bytes = drive.capacity_bytes.expect("whole-media size"); - assert!( - bytes > 100_000_000_000, - "capacity {bytes} bytes is too small to be the internal disk" - ); + // 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 @@ -142,11 +141,16 @@ mod tests { if sizes.is_empty() { return crate::source::macos::absent("physical IOMedia"); } + // The point of the two filters: no partition (~577 MB EFI) and no APFS + // container (which mirrors most of the disk) may appear. Disk *count* + // is not asserted — that is a property of the machine, not the code. assert!( - sizes.iter().all(|s| *s > 10_000_000_000), + sizes.iter().all(|s| *s > 1_000_000_000), "a partition or APFS container leaked in: {sizes:?}" ); - // One internal SSD, and no synthesized duplicates of it. - assert_eq!(sizes.len(), 1, "expected exactly one physical disk, got {sizes:?}"); + // Containers would show up as near-duplicates of the physical size. + let mut sorted = sizes.clone(); + sorted.dedup(); + assert_eq!(sorted.len(), sizes.len(), "duplicate media sizes suggest containers: {sizes:?}"); } } diff --git a/app/src/source/macos/iokit.rs b/app/src/source/macos/iokit.rs index 8ef55a37..7f259da5 100644 --- a/app/src/source/macos/iokit.rs +++ b/app/src/source/macos/iokit.rs @@ -285,11 +285,15 @@ mod tests { let Some(first) = services.first() else { return crate::source::macos::absent("IOPlatformExpertDevice"); }; - let props = properties(first.0).expect("platform expert has properties"); + 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. - let model = dict_string(&props, "model").expect("model property"); - assert!(!model.is_empty()); + // 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 diff --git a/app/src/source/macos/storage.rs b/app/src/source/macos/storage.rs index e736a102..0a04c7c3 100644 --- a/app/src/source/macos/storage.rs +++ b/app/src/source/macos/storage.rs @@ -126,18 +126,22 @@ mod tests { return crate::source::macos::absent("IOBlockStorageDriver"); } assert!( - first[0].sensors.iter().all(|s| s.sensor_type != SensorType::Throughput), + 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[0] - .sensors + let rates: Vec<_> = second .iter() + .flat_map(|hw| hw.sensors.iter()) .filter(|s| s.sensor_type == SensorType::Throughput) .collect(); - assert_eq!(rates.len(), 2, "read + write rate on the second poll"); + 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. diff --git a/app/src/source/macos/sysprofile.rs b/app/src/source/macos/sysprofile.rs index c23f3de5..b55a6194 100644 --- a/app/src/source/macos/sysprofile.rs +++ b/app/src/source/macos/sysprofile.rs @@ -158,7 +158,9 @@ mod tests { } let (model, size) = &drives[0]; assert!(!model.is_empty()); - let bytes = size.expect("capacity"); - assert!(bytes > 100_000_000_000, "capacity {bytes} too small for an internal SSD"); + 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"); } } From 542dfcd4b44c39308b11bf6b3e69e3504c0d5452 Mon Sep 17 00:00:00 2001 From: Manupa Wickramasinghe Date: Thu, 30 Jul 2026 20:34:46 +0530 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=90=9B=20Assert=20the=20disk=20filter?= =?UTF-8?q?'s=20invariant,=20not=20the=20machine's=20disk=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI annotations added in the previous commit paid for themselves immediately: macOS was failing in apfs_containers_and_partitions_are_not_counted_as_disks, which no amount of staring at "exit code 101" would have revealed. The test asserted every physical disk is larger than 1 GB. That is a fact about a laptop, not about the code — the virtualized macOS runner has a whole-media node below the threshold, so it failed there and only there. Rewritten to express the actual invariant against the live registry: enumerate every whole-media node, count how many are subclasses of IOMedia (AppleAPFSMedia and friends, which IOServiceMatching also returns and which likewise report Whole = Yes), and assert the filter removes exactly those — no more, no fewer. That holds on any disk layout, including none. Confirmed it still fails when the class check is removed: "class filter removed the wrong set (4 whole, 3 synthesized)". Co-Authored-By: Claude Opus 5 --- app/src/source/macos/inventory.rs | 57 ++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/app/src/source/macos/inventory.rs b/app/src/source/macos/inventory.rs index ca5a5022..6ee79db8 100644 --- a/app/src/source/macos/inventory.rs +++ b/app/src/source/macos/inventory.rs @@ -132,25 +132,48 @@ mod tests { } } - /// Regression test for the two-filter rule in `whole_media_capacities`. - /// Before the class check this returned four entries on an M5 Air — the - /// real 500 GB disk plus three APFS synthesized containers. + /// 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_and_partitions_are_not_counted_as_disks() { - let sizes = physical_disk_sizes(); - if sizes.is_empty() { - return crate::source::macos::absent("physical IOMedia"); + 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 point of the two filters: no partition (~577 MB EFI) and no APFS - // container (which mirrors most of the disk) may appear. Disk *count* - // is not asserted — that is a property of the machine, not the code. - assert!( - sizes.iter().all(|s| *s > 1_000_000_000), - "a partition or APFS container leaked in: {sizes:?}" + + // 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)" ); - // Containers would show up as near-duplicates of the physical size. - let mut sorted = sizes.clone(); - sorted.dedup(); - assert_eq!(sorted.len(), sizes.len(), "duplicate media sizes suggest containers: {sizes:?}"); } }