✨ Add native macOS (Apple Silicon) sensor backend - #22
Merged
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Resolves the conflict with PR #23, which added a Linux sysfs/hwmon backend in the same two places this branch touched. Both changes had the identical shape — add a platform arm, narrow the fallback — so the resolution keeps both arms and widens each fallback to exclude all three platforms: source/mod.rs default_source() sysinfo.rs query() -> #[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] Getting this wrong is silent: these are sequential exclusive cfg blocks, not else-if, so a platform named in an arm *and* left in the fallback compiles both and the last one wins. CI was failing on all three legs: - Windows and Linux failed at Clippy. sysinfo::query_for_test is a #[cfg(test)] helper consumed only by the macOS system-profile test, so on other platforms it tripped dead_code under -D warnings. Marked allow(dead_code) with the reason. - macOS failed at Run tests (exit 101). CI runs macOS virtualized, where the IOHID sensor plane, IOReport, an integrated GPU, an NVMe controller and the system fonts may all be absent. Several new tests still asserted that hardware into existence, including two that indexed services[0] and would panic on an empty list. Every environment-dependent assertion now reports "SKIP: ... not available" instead. Only *presence* is ever skipped — the range, uniqueness, stability and unit assertions still run wherever the hardware is real, which is where the coverage actually comes from. Verified by cross-checking the other two targets locally rather than guessing: cargo clippy --all-targets -D warnings is clean for x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc and the macOS host, and cargo check --no-default-features passes for Linux too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ions 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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.