Skip to content

✨ Add native macOS (Apple Silicon) sensor backend - #22

Merged
manupawickramasinghe merged 7 commits into
masterfrom
feat/macos-apple-silicon-backend
Jul 30, 2026
Merged

✨ Add native macOS (Apple Silicon) sensor backend#22
manupawickramasinghe merged 7 commits into
masterfrom
feat/macos-apple-silicon-backend

Conversation

@manupawickramasinghe

Copy link
Copy Markdown
Member

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.

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>
Copilot AI review requested due to automatic review settings July 29, 2026 19:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Manupa Wickramasinghe and others added 6 commits July 30, 2026 00:53
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>
@manupawickramasinghe
manupawickramasinghe merged commit 542dfcd into master Jul 30, 2026
3 checks passed
@manupawickramasinghe
manupawickramasinghe deleted the feat/macos-apple-silicon-backend branch July 30, 2026 19:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants