feat: add Intel Mac support for CPU, SMC, and chassis metrics (#306)
## Summary
Intel Macs already compiled for `x86_64-apple-darwin`, but at runtime they got an empty GPU list, a chassis reader that always returned `None`, and a thin CPU path built on slow `system_profiler` text parsing with hardcoded frequency constants, no per-core data, and no temperature. This wires up everything that does not need Apple Silicon hardware (CPU, SMC, chassis) and adds the `x86_64-apple-darwin` release target on the self-hosted Intel builder.
The Apple Silicon path is deliberately untouched in structure: `NativeMetricsManager` still hard-fails without IOReport, and the new Intel readers talk to the SMC and NSProcessInfo modules directly rather than partially initializing it.
## What changed, by scope group
### A. Runtime correctness
- `src/device/platform_detection.rs`: `detect_apple_silicon` no longer `.expect()`s on the `uname` probe; a failed probe now degrades to "not Apple Silicon", which every caller already handles. It also recognizes Rosetta 2 via `sysctl.proc_translated`, so an `x86_64` build running on Apple Silicon reports the hardware it is actually on rather than claiming to be an Intel Mac. That probe is compiled out on `aarch64` builds, which can only run natively, so the Apple Silicon path still costs exactly one subprocess. Adds `is_intel_mac()`.
- `src/device/cpu_macos.rs`: the second private copy of the `uname -m` probe now delegates to the shared cached detector, so there is one answer per process.
- `src/client.rs`: the library path gated `initialize_native_metrics_manager` on `is_apple_silicon()`, matching what the three binary entry points in `main.rs` already did. On Intel this init always failed inside `IOReport::new` because the "Energy Model" channel group does not exist there.
- `src/device/readers/chassis/mod.rs`: the factory routes to the new Intel reader instead of unconditionally constructing `AppleSiliconNativeChassisReader`.
- `src/device/reader_factory.rs`: comment recording that the empty Intel GPU list is intentional and tracked in #307.
### B. Intel CPU path
- New `src/device/cpu_macos_intel.rs` owns Intel hardware discovery. sysctl is the primary source (`machdep.cpu.brand_string`, `hw.packages`, `hw.physicalcpu`, `hw.logicalcpu`, `hw.cpufrequency`, `hw.cpufrequency_max`, `hw.l3cachesize`), read in a single batched call.
- `system_profiler SPHardwareDataType` is kept, not deleted, as a gap filler for the L3 cache size and the marketing processor name, which sysctl does not supply on every model. It only runs when sysctl actually left a gap.
- `hw.cpufrequency` is treated as optional because it was removed on later Intel Macs. The fallback chain is `hw.cpufrequency`, then the nominal clock parsed out of the brand string, then `system_profiler`'s "Processor Speed". Max frequency comes from `hw.cpufrequency_max` and falls back to the base clock.
- The `total_threads = cores * 2` hyperthreading assumption is gone. Threads come from `hw.logicalcpu`; if that is unreadable the fallback is the kernel's logical CPU count from sysinfo, and if that also fails it is one thread per physical core. It never doubles.
- Per-core utilization is populated via the existing `get_per_core_utilization_no_refresh` with `CoreType::Standard`, so the Activity panel renders core bars instead of falling to the Individual strategy with no data.
- CPU temperature and package power now come from the SMC.
### C. SMC layer
- Key discovery filtered to type `flt ` **and** to names `Tp*`/`Te*`/`Tg*`. See "Findings" below: extending the type filter alone would not have been enough. Both filters were extended, as two disjoint families each pinned to its own data type.
- Fan speeds (`FNum` / `F<i>Ac`, plus `F<i>Mx` for the rated maximum) and total system power (`PSTR`) were already collected and then discarded. They are now exposed through `get_fan_readings()` and a validated `get_system_power()`, along with a new best-effort `get_cpu_package_power()` trying `PCPT`, `PCPC`, `PC0C`.
- All three reads are plausibility-bounded so a missing or differently-typed key cannot surface as a metric.
- New `SmcConnection` type: a lazily opened, reusable connection that remembers an unreachable SMC instead of retrying an IOKit handshake on every poll. Both the CPU reader and the chassis reader hold one. `SMCMetrics::collect()` still reconnects per call, which is fine because the native metrics manager caches its results.
- The `smc` module and `get_thermal_state` are now public so the Intel readers can use them without going through the manager.
### D. Chassis (new `IntelMacChassisReader`)
- Total power from SMC `PSTR`, documented in the payload as an approximation via a `power_source` detail rather than left to look metered. `powermetrics` would be metered but requires sudo, which this project does not ask for on macOS.
- CPU package power where the model exposes it, omitted cleanly when absent.
- Fan RPMs with their rated maximums, and thermal pressure from NSProcessInfo. Thermal pressure needs no SMC connection, so a chassis block is always produced even if the SMC is unreachable.
- Everything flows through the existing chassis metric family, so `all_smi_chassis_power_watts`, `all_smi_chassis_fan_speed_rpm`, `all_smi_chassis_thermal_pressure_info`, and `all_smi_chassis_cpu_power_watts` are exported with no exporter changes. Help strings no longer hardcode "(CPU+GPU+ANE)" and "(Apple Silicon)".
### E. Release packaging
- `x86_64-apple-darwin` matrix entry on the self-hosted Intel builder, with `artifact_name` `all-smi`, `asset_name` `all-smi-macos-x86_64`, `archive_ext` `.zip`, `protoc_platform` `osx-x86_64`. Its `runs-on` is carried as a `{group, labels}` object in the matrix JSON while every other entry stays a label string; `runs-on` is evaluated per matrix job instance, so the two shapes do not interact.
- Signing and notarization are gated on `runner.os == 'macOS'` and apply automatically, but the self-hosted runner is persistent, so the Developer ID key now imports into a keychain named after the run and an `always()` step deletes it. Without that, the private key would survive the job on disk and every run would append another certificate to the same fixed-name keychain. Deleting a keychain also drops it from every search list, so no separate `security list-keychains` reset is needed, and the step tolerates the keychain not existing when import never ran.
- The Actions cargo cache is skipped for this target as it is for the Windows self-hosted box: the machine keeps its workspace and `~/.cargo` between jobs, so the cache would only mirror state it already has.
- Fork-PR safety: the workflow triggers only on `release: published` and `workflow_dispatch`, both of which need write access. `ci.yml` is the PR-triggered workflow and runs on `ubuntu-latest` only. This is now documented in a comment at the trigger block so nobody adds a PR trigger later.
- The `targets` input description records that `macos` now covers both arches.
- Docs: platform support table in `src/lib.rs` split into Apple Silicon and Intel rows, plus a new "macOS (Intel)" section and a supported-hardware bullet in the README.
### Out of the issue's checklist, but directly adjacent
The `apple.smc` doctor check reported a euid test and advised "re-run with sudo". Nothing on this path needs sudo, and the advice was actively misleading for Intel Macs whose temperature, fans, and chassis power all depend on that connection. It now probes the SMC and reports what it found.
## Findings that contradict the issue's premises
**Group C's "accept sp78 in discovery so TC0P/TC0D/TG0P/TG0D are found" is not sufficient on its own.** Discovery filtered on two axes, not one: data type `flt `, *and* key name prefix `Tp*`/`Te*`/`Tg*`. The Intel keys are `TC*` and `TG*`, so accepting `sp78` while leaving the name filter alone would still have discovered nothing. Both filters were extended together.
**On the regression risk that flagged this as the highest-risk change:** the two families cannot collide, because the Apple Silicon names use a lowercase second character (`p`/`e`/`g`) and the Intel names use uppercase (`C`/`G`), and each family is now pinned to its own data type in the classifier (`flt ` accepts only lowercase, `sp78` only uppercase). Beyond that, discovery is only reached as a fallback when the static key list produced no readings, and the static CPU list already contains `TC0P`/`TC0D` and the GPU list `TG0P`/`TG0D`. So on an Apple Silicon machine the extension cannot introduce a sensor family that the static path was not already probing, and where it could fire at all it would be on a machine currently reporting no temperature. The existing `10.0..=120.0` range filter remains as the last line of defense. A unit test asserts the families do not cross data types.
**Fan speeds on Apple Silicon are deliberately left unexposed.** `SMCMetrics` collects them there too and `AppleSiliconNativeChassisReader` still hardcodes an empty fan list. Surfacing them would start emitting `all_smi_chassis_fan_speed_rpm` for every existing Apple Silicon host, which is an output change for current users that this issue does not ask for. It is a clean follow-up if wanted.
**`all_smi_cpu_frequency_mhz` and the P/E cluster frequency metrics report 0 on this M1 Ultra.** That is pre-existing and unrelated: the released v0.25.0 Homebrew binary was run side by side and reports the same zeros. Noting it so it is not mistaken for a regression from this PR.
## Test plan
- [x] `cargo check --target x86_64-apple-darwin --lib --bins` passes. This is the primary Intel-side verification.
- [x] `cargo clippy --lib --tests -- -D warnings` and `cargo clippy --bins -- -D warnings` clean.
- [x] `cargo fmt` clean.
- [x] New unit tests pass for the parts testable without Intel hardware: SMC key type and name classification including the cross-family negative cases, sp78 fixed-point decoding, sysctl key/value parsing, brand-string frequency extraction, Hz to MHz rounding, the system_profiler fallback leaving `logical_cores` unset, sysctl-wins-over-fallback merging, the no-hyperthreading and non-zero-socket defaults, arch detection being total and stable, and chassis reader selection following the architecture.
- [x] Apple Silicon regression, `api` mode: built and ran on this M1 Ultra, then diffed `/metrics`. GPU utilization/temperature/power, CPU utilization/temperature/power, and the full chassis family (`power_watts`, `thermal_pressure_info`, `cpu_power_watts`, `gpu_power_watts`, `ane_power_watts`) are all still present with `platform="Apple Silicon"`. The Intel reader is correctly not selected.
- [x] Apple Silicon regression, `local` TUI mode: ran in a pty for 8 seconds. CPU model, P-CPU and E-CPU cluster bars, the ANE row, thermal pressure "Nominal", and the GPU Metrics panel all render. No panics.
- [x] `all-smi doctor` reports `PASS apple.smc SMC reachable (no sudo required)`.
- [x] The release workflow YAML parses, and the matrix JSON round-trips through the same `jq` filter the setup job uses, producing both macOS entries for `targets: macos`.
## Not verified
- **No physical Intel Mac was available.** Nothing here has been run on Intel hardware. The issue's acceptance criteria phrased as "on a physical Intel Mac" are left unchecked on the issue, and the runtime SMC key availability (`PSTR`, the `PCPT`/`PCPC`/`PC0C` package power family, `F<i>Mx`) is best-effort by construction and model-dependent by nature.
- **The self-hosted Intel runner path has not executed.** Whether the base64-encoded packaging secrets decode there, and whether the temporary keychain import and cleanup behave on that specific machine, can only be confirmed by a real release run or a maintainer `workflow_dispatch`. The notarization step's base64 handling and its openssl sanity check are architecture-independent and unchanged.
- `cargo build --release` for `x86_64-apple-darwin` was not run to completion; `cargo check` for that target was used instead to keep the build bounded.
Closes #306 J
Jeongkyu Shin committed
5cc7807b51ea99e3d2cdd197bb661faf05dee494
Parent: fd43424
Committed by GitHub <noreply@github.com>
on 8/5/2026, 5:09:53 AM