# Introducing macpow: Building a Real-Time Power Monitor for Apple Silicon in Rust

Apple Silicon packs CPU clusters, GPU, ANE, DRAM controllers, media engines, and display pipelines onto a single die — but macOS barely shows you where the watts go. Activity Monitor gives you a vague "Energy Impact" number. `powermetrics` requires `sudo` and dumps pages of unstructured text. I wanted a real-time, per-component power tree that runs without root — and I wanted to build it in Rust.

**macpow** is a terminal UI that reads from nine macOS hardware interfaces to show per-component power, temperatures, frequencies, CPU utilization, and per-process energy attribution. No `sudo`, no entitlements, no SIP workarounds.

```
cargo install macpow
```

This is what it looks like:

```
 Power Tree (9/58)
 Component                                   Freq       Temp         Power    Cumulative     History
─────────────────────────────────────────────────────────────────────────────────────────────────────
 Battery 72% (4h 31m remaining, 78.1 Wh)                34°C       -9.426 W     1.101 Wh   ▃▅▇▆▅▃▂
   AirPods 4 Case  [85%]
   AirPods 4 (#1)  [100%]
   AirPods 4 (#2)  [100%, charged]
                                                                                            
 ▾ Apple M5 Max (Mac17,7)                                            9.426 W     1.100 Wh   ▂▃▅▇▆▃▂
   ▾ SoC                                                             4.898 W   642.42 mWh   ▃▄▆▇▅▃▂
     ▸ CPU (18 cores, 15%)                    49°C (48–50)           2.357 W   347.97 mWh   ▂▃▅▇▆▄▃
     │ ▸ E-Cores (12 cores, 5%)    1556 MHz   49°C (48–50)          1.031 W    96.09 mWh   ▂▃▃▄▃▂▂
     │ ▾ P-Cores (6 cores, 36%)    2845 MHz   49°C (48–49)          1.326 W   251.88 mWh   ▃▅▇▆▅▃▂
     │   ├ PACC_0   ( 73%) ▓▓▓▓▓▓▓░           49°C                  0.256 W                ▃▅▆▇▅▃▂
     │   ├ PACC_1   ( 70%) ▓▓▓▓▓▓▓░           49°C                  0.140 W                ▂▃▅▆▅▃▂
     │   ├ PACC_2   ( 32%) ▓▓▓░░░░░           49°C                  0.081 W                ▂▂▃▄▃▂▂
     │   ├ PACC_3   ( 21%) ▓▓░░░░░░           49°C                  0.050 W                ▂▂▃▃▂▂▂
     │   ├ PACC_4   ( 16%) ▓▓░░░░░░           48°C                  0.089 W                ▂▂▃▃▂▂▂
     │   └ PACC_5   (  6%) ▓░░░░░░░           48°C                  0.043 W                ▂▂▂▂▂▂▂
     ▸ GPU (40 cores, 10%)          732 MHz   48°C (48–49)          0.146 W    14.49 mWh   ▂▂▃▃▂▂▂
     ├ ANE                                    44°C (35–49)          0.000 W     0.70 mWh
     ├ DRAM (105.6/128 GB)                    48°C (47–49)          0.481 W    56.14 mWh   ▃▃▄▄▃▃▃
     ├ GPU SRAM (SLC)                                               0.000 W     0.00 mWh
     ├ Media Engine                                                 0.012 W     1.34 mWh
     ├ Camera (ISP)                                                 0.008 W     0.56 mWh
     └ Fabric                                                       1.791 W   220.96 mWh   ▃▄▅▆▅▄▃
   ▸ SSD (APPLE SSD AP8192Z, Apple Fabric)    33°C (32–34)        ≈0.031 W    ≈2.43 mWh
   ▸ Display (31% brightness, 500/1600 nits)  31°C (27–35)         0.452 W    35.07 mWh   ▃▃▃▃▃▃▃
   ├ Keyboard (5% brightness)                                     ≈0.025 W    ≈1.95 mWh
   ├ Audio (off)                                                  ≈0.000 W    ≈2.11 mWh
   ▸ Fans                                                         ≈0.059 W    ≈4.50 mWh
   ▾ Peripherals                                                  ≈0.058 W    ≈4.04 mWh
     ├ Thunderbolt/PCIe                                             0.000 W     0.00 mWh
     ├ Ethernet (en3)
     ▸ WiFi (en0, -52 dBm, 802.11ax, ch153 (5GHz))                 0.028 W     2.15 mWh
     ▸ Bluetooth (3 devices)                                      ≈0.030 W    ≈1.89 mWh
     └ USB (no devices)
                                                                                            
 ▾ Software (filter: top 10 by total)                               95.3 mW     5.43 mWh   ▂▃▄▅▃▂▂
   ▸ Preview (pid 44672, 449.3 MB)                                   0.2 mW     2.68 mWh   ▂▂▃▃▂▂▂
   ▸ Cursor (pid 67669, 1.22 GB)                                     0.4 mW     0.54 mWh   ▂▂▃▃▂▂▂
   ├ macpow (pid 10342, 12.6 MB)                                     3.6 mW     0.31 mWh   ▂▃▄▅▃▂▂
   ▸ Paste (pid 5044, 299.8 MB)                                      0.6 mW     0.30 mWh
   ▸ Mail (pid 92428, 223.0 MB)                                      0.1 mW     0.28 mWh
───────────────────────────────────────────────────────────────────────────────────────────
 q quit  r reset  a avg:0s  l 250ms  ↑↓←→ tree  space pin  ■<1W ■<5W ■<10W ■≥10W
```

The `≈` prefix means estimated (model-based), no prefix means hardware-measured. Everything is a collapsible tree: arrow keys fold and unfold, Space pins a sparkline chart, `a` toggles time-smoothing.

This post covers the Rust patterns, the macOS internals, and what I learned building this in three days.

---

## The problem with `system_profiler`

My first version shelled out to `system_profiler` for WiFi and Bluetooth data. It worked — and it was comically slow:

```
system_profiler SPAirPortDataType    →  13,000 ms
system_profiler SPBluetoothDataType  →     157 ms
```

Thirteen seconds for WiFi info. The TUI would freeze every time the WiFi thread ran. I couldn't just cache it either — you want to see signal strength changing in real time.

The fix was to drop subprocesses entirely and call the native APIs directly from Rust. For WiFi, that meant using the ObjC runtime to talk to CoreWLAN:

```rust
extern "C" {
    fn objc_getClass(name: *const i8) -> *mut c_void;
    fn sel_registerName(name: *const i8) -> *mut c_void;
    fn objc_msgSend(obj: *mut c_void, sel: *mut c_void, ...) -> *mut c_void;
}
```

With this, `[CWWiFiClient sharedWiFiClient]` becomes:

```rust
let cls = objc_getClass(b"CWWiFiClient\0".as_ptr() as _);
let client = objc_msgSend(cls, sel_registerName(b"sharedWiFiClient\0".as_ptr() as _));
let iface = objc_msgSend(client, sel_registerName(b"interface\0".as_ptr() as _));
let rssi = objc_msgSend(iface, sel_registerName(b"rssiValue\0".as_ptr() as _)) as i32;
```

Result: **<1ms** instead of 13 seconds. Same data, 13,000x faster, zero dependencies.

For Bluetooth, I replaced `system_profiler` with `pmset -g accps` — 11ms, and it includes battery levels and charging state for every connected device. The output parses trivially with `.split()` and `.find()`.

These two changes alone made macpow usable as a real-time monitor.

---

## Talking to undocumented Apple APIs via FFI

The core of macpow is `libIOReport.dylib` — an undocumented private library that Apple's own `powermetrics` uses internally. There are no headers, no documentation, no Rust bindings. I reverse-engineered the C signatures and wrote the FFI by hand:

```rust
#[repr(C)]
struct IOReportSubscriptionRaw {
    _data: [u8; 0],
    _pin: PhantomData<(*mut u8, PhantomPinned)>,
}
type IOReportSubscriptionRef = *mut IOReportSubscriptionRaw;

#[link(name = "IOReport", kind = "dylib")]
extern "C" {
    fn IOReportCopyChannelsInGroup(
        group: CFStringRef, subgroup: CFStringRef,
        c: u64, d: u64, e: u64,
    ) -> CFDictionaryRef;

    fn IOReportCreateSubscription(
        a: CVoidRef, desired: CFMutableDictionaryRef,
        subd: *mut CFMutableDictionaryRef, channel_id: u64, e: CFTypeRef,
    ) -> IOReportSubscriptionRef;

    fn IOReportCreateSamples(
        sub: IOReportSubscriptionRef,
        subd: CFMutableDictionaryRef, a: CFTypeRef,
    ) -> CFDictionaryRef;

    fn IOReportCreateSamplesDelta(
        prev: CFDictionaryRef, cur: CFDictionaryRef, a: CFTypeRef,
    ) -> CFDictionaryRef;

    fn IOReportSimpleGetIntegerValue(ch: CFDictionaryRef, idx: i32) -> i64;
}
```

The `PhantomData<(*mut u8, PhantomPinned)>` trick creates a zero-sized, non-`Send`, non-`Unpin` opaque type — the Rust idiom for "this is a C handle, don't touch it." We then explicitly opt in with `unsafe impl Send` where we know it's safe.

A critical subtlety is CoreFoundation ownership. When creating a `CFString` to pass to IOReport, you need to transfer ownership:

```rust
fn cfstr_raw(s: &str) -> CFStringRef {
    let cf = CFString::new(s);
    let raw = cf.as_concrete_TypeRef();
    std::mem::forget(cf); // IOReport takes ownership
    raw
}
```

If you don't `forget`, Rust drops the `CFString` and IOReport gets a dangling pointer. If you forget when you shouldn't, you leak. There's no bridge that's both safe and ergonomic — you just have to get the ownership semantics right every time.

---

## From energy deltas to watts

IOReport doesn't give you power directly. It gives you cumulative energy counters — millijoules, microjoules, or nanojoules, depending on the channel and chip generation. To get watts, you take two absolute samples, compute the delta, and divide by wall-clock time:

```rust
fn energy_to_watts(val: i64, unit: &str, dt_ms: u64) -> f32 {
    let dt_s = dt_ms.max(1) as f32 / 1000.0;
    let per_sec = val as f32 / dt_s;
    match unit {
        "mJ" => per_sec / 1e3,
        "uJ" => per_sec / 1e6,
        "nJ" => per_sec / 1e9,
        _ => 0.0,
    }
}
```

The `.max(1)` prevents division by zero if the system hiccups. The unit string comes from `IOReportChannelGetUnitLabel` — it varies per channel and per chip.

This gives you real, hardware-measured power for every SoC component:

```
 Component          Source        Value
─────────────────────────────────────────
 CPU E-Cores        IOReport      1.031 W     ← measured in mJ
 CPU P-Cores        IOReport      1.326 W     ← measured in mJ
 GPU                IOReport      0.146 W     ← measured in mJ
 ANE                IOReport      0.000 W     ← measured in mJ
 DRAM               IOReport      0.481 W     ← measured in mJ
 Fabric             IOReport      1.791 W     ← measured in mJ
 System total       SMC PSTR      9.426 W     ← direct power rail
 Display backlight  SMC PDBR      0.452 W     ← direct power rail
 WiFi               SMC wiPm      0.028 W     ← direct power rail
 Keyboard           IORegistry  ≈ 0.025 W     ← PWM duty cycle model
 Fans               SMC RPM     ≈ 0.059 W     ← cubic RPM model
```

No prefix = hardware-measured. `≈` = estimated from a calibrated model. The distinction matters.

---

## Real CPU/GPU frequencies from DVFS tables

Most tools show CPU frequency as a percentage or a "max turbo" number. macpow reads the actual DVFS (Dynamic Voltage and Frequency Scaling) tables from the `pmgr` IORegistry node. These are raw binary blobs — pairs of `(frequency_u32_le, voltage_u32_le)`, 8 bytes each:

```rust
let bytes = std::slice::from_raw_parts(ptr, len as usize);
let freqs: Vec<u32> = bytes
    .chunks_exact(8)
    .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]))
    .filter(|&f| f > 0)
    .collect();
```

Then from IOReport's "CPU Stats" group, we get per-core DVFS state residency — how many nanoseconds each core spent at each frequency level. The actual frequency is a residency-weighted average:

```rust
let calc_freq = |data: &[(usize, i64)], freqs: &[u32]| -> u32 {
    let total_res: i64 = data.iter().map(|(_, r)| *r).sum();
    if total_res <= 0 { return 0; }
    let weighted: f64 = data.iter().map(|(idx, r)| {
        let freq = freqs.get(*idx).copied()
            .unwrap_or(*freqs.last().unwrap_or(&0)) as f64;
        freq * *r as f64
    }).sum();
    (weighted / total_res as f64).round() as u32
};
```

One quirk: M1/M2/M3 report frequencies in Hz (divide by 1M), but M4+ reports CPU frequencies in KHz (divide by 1K) while keeping GPU in Hz. A heuristic on the max value handles both:

```rust
fn to_mhz(freqs: &[u32]) -> Vec<u32> {
    let max = *freqs.iter().max().unwrap_or(&0);
    if max > 100_000_000 { freqs.iter().map(|f| f / 1_000_000).collect() }
    else if max > 100_000 { freqs.iter().map(|f| f / 1_000).collect() }
    else { freqs.to_vec() }
}
```

The result: you see `2845 MHz` instead of `73%` — the real frequency your P-cores are running at right now.

---

## SMC: struct-method calls to a kernel extension

The System Management Controller exposes temperatures, fan speeds, and power rails through 4-character keys. `PSTR` is system power. `Tp09` is CPU core 9 temperature. `F0Ac` is fan 0 actual RPM. The protocol is a struct-in/struct-out call via `IOConnectCallStructMethod`:

```rust
#[repr(C)]
#[derive(Default, Clone, Copy)]
struct SmcKeyData {
    key: u32,
    vers: SmcVers,
    p_limit_data: SmcPLimitData,
    key_info: SmcKeyInfoData,
    result: u8,
    status: u8,
    data8: u8,
    data32: u32,
    bytes: [u8; 32],
}
```

Every field must match the kernel layout exactly — `#[repr(C)]` is non-negotiable. Data comes back as raw bytes; the type code tells you how to decode them:

```rust
pub fn read_f32(&mut self, key_str: &str) -> Result<f32> {
    let (info, bytes) = self.read_key_raw(key_str)?;
    match info.data_type {
        TYPE_FLT | TYPE_IOF => bytes_to_f32_le(&bytes),       // IEEE 754
        TYPE_SP78 => bytes_to_sp78(&bytes),                   // signed 7.8 fixed-point
        _ => bytes_to_f32_le(&bytes),
    }
}
```

`sp78` is signed fixed-point 7.8 — divide an `i16` by 256 to get Celsius. It's the standard format for Apple temperature sensors.

Temperature key names vary across chip generations: M1–M3 use `Tp0*` (e.g., `Tp09`), M4+ use `Tex*`/`Tp1*`/`Tp2*`. Instead of hardcoding per chip, macpow enumerates all SMC keys starting with `T` at startup:

```rust
fn discover_temp_keys(&mut self) -> Vec<(String, String)> {
    let prefixes: &[(&str, &str)] = &[
        ("TPMP", "Trackpad"), ("TPSP", "Trackpad"),
        ("TD", "Display"),    ("Tp", "CPU"),      ("Te", "CPU"),
        ("Tg", "GPU"),        ("Ts", "SSD"),      ("Tm", "Memory"),
        ("TB", "Battery"),    ("Tw", "Wireless"),  ("Ta", "ANE"),
    ];
    let count = self.key_count();
    (0..count).filter_map(|i| {
        let key_fcc = self.key_at_index(i)?;
        ((key_fcc >> 24) as u8 == b'T').then_some(())?;
        let key_str = fourcc_to_str(key_fcc);
        let info = self.read_key_info(key_fcc).ok()?;
        matches!(info.data_type, TYPE_FLT | TYPE_IOF | TYPE_SP78).then_some(())?;
        let category = prefixes.iter()
            .find(|(p, _)| key_str.starts_with(p))
            .map(|(_, c)| c.to_string())
            .unwrap_or_else(|| "Other".into());
        Some((key_str, category))
    }).collect()
}
```

This scans thousands of SMC keys (~200ms). It runs in a background thread at startup, so the TUI is already rendering while discovery happens.

---

## The heap buffer overflow

On day one I had a bug that corrupted the heap on every single `proc_pid_rusage` call — for every running process, every 250ms.

The kernel function `proc_pid_rusage` fills a `rusage_info_v4` buffer: a 16-byte UUID followed by 35 `uint64_t` fields, totaling 296 bytes. My initial code allocated 288 bytes (`36 * 8`), forgetting the UUID prefix. The kernel wrote 8 bytes past the end of every allocation.

The fix:

```diff
-const BILLED_ENERGY_OFFSET: usize = 32 * 8;      // wrong field
-const RUSAGE_V4_SIZE: usize = 36 * 8;             // 288 — too small!
+const BILLED_ENERGY_OFFSET: usize = 16 + 31 * 8;  // = 264
+const RUSAGE_V4_SIZE: usize = 16 + 35 * 8;        // = 296
```

It also turned out I was reading the wrong field — `ri_cycles` (offset 256) instead of `ri_billed_energy` (offset 264). So the energy numbers were garbage on top of being UB.

This is the kind of bug that `unsafe` gives you. In debug mode it manifested as corrupted process names. In release mode it silently worked because the allocator happened to have padding. Miri would have caught it, but you can't run Miri against macOS kernel syscalls.

The lesson: when you do raw offset math against kernel structs, document the layout explicitly. macpow now has:

```rust
// rusage_info_v4: 16-byte UUID + 35 uint64_t fields = 296 bytes
// ri_billed_energy is field #31 (0-indexed) after the UUID
const BILLED_ENERGY_OFFSET: usize = 16 + 31 * 8; // = 264
const RUSAGE_V4_SIZE: usize = 16 + 35 * 8;       // = 296
```

Every offset has a comment showing the arithmetic. That comment is a contract with the next person (myself, two hours later) who touches this code.

---

## dlopen for private frameworks

Display brightness isn't available through any public API. macpow loads `DisplayServices.framework` at runtime via `dlopen`:

```rust
static DISPLAY_BRIGHTNESS_FN: OnceLock<
    Option<unsafe extern "C" fn(u32, *mut f32) -> i32>,
> = OnceLock::new();

fn load_display_brightness_fn() -> Option<unsafe extern "C" fn(u32, *mut f32) -> i32> {
    unsafe {
        let handle = libc::dlopen(
            c"/System/Library/PrivateFrameworks/\
              DisplayServices.framework/DisplayServices".as_ptr(),
            libc::RTLD_LAZY,
        );
        if handle.is_null() { return None; }
        let sym = libc::dlsym(handle,
            c"DisplayServicesGetBrightness".as_ptr());
        if sym.is_null() { return None; }
        Some(std::mem::transmute(sym))
    }
}
```

`OnceLock` ensures the `dlopen` + `dlsym` + `transmute` dance happens exactly once. The handle is intentionally never closed — we need the function pointer for the entire process lifetime.

Another gotcha: since macOS Sonoma, `CWInterface.ssid` from CoreWLAN returns `nil` for unsigned binaries — it now requires location permission. macpow falls back to parsing `ipconfig getsummary en0`.

---

## Architecture: one thread per data source

macpow spawns 12+ threads, each owning its data source and updating shared state through `Arc<Mutex<Metrics>>`:

```rust
pub struct Sampler {
    shared: Arc<Mutex<Metrics>>,
    _handles: Vec<std::thread::JoinHandle<()>>,
}
```

Each thread runs an independent sample/sleep loop:

```
 ┌──────────────────────────────────────────────────────────────────┐
 │  Thread: IOReport        Thread: SMC          Thread: Battery    │
 │  ┌─────────────┐         ┌────────────┐       ┌──────────────┐  │
 │  │ sample()    │         │ read_f32() │       │ IORegistry   │  │
 │  │ delta()     │─┐       │ PSTR, temps│─┐     │ V × I        │─┐│
 │  │ parse_power │ │       │ fans, CPU% │ │     │ charge %     │ ││
 │  └─────────────┘ │       └────────────┘ │     └──────────────┘ ││
 │                  ▼                      ▼                      ▼│
 │              ┌───────────────────────────────────────────┐      │
 │              │        Arc<Mutex<Metrics>>                │      │
 │              │  soc: SocPower    battery: BatteryInfo    │      │
 │              │  fans: Vec<Fan>   wifi: WifiInfo   ...    │      │
 │              └──────────────────┬────────────────────────┘      │
 │                                 │ .snapshot()                   │
 │                                 ▼                               │
 │              ┌───────────────────────────────────────────┐      │
 │              │          Main thread (TUI)                │      │
 │              │  rx.try_recv() → app.update() → draw()   │      │
 │              └───────────────────────────────────────────┘      │
 └──────────────────────────────────────────────────────────────────┘
```

The main thread calls `snapshot()` to get a clone of the current metrics — never blocking on a slow source. IOReport is the fastest (~1ms). SMC temperature discovery is the slowest (~200ms on first run), so it starts in a background thread and the TUI shows data as soon as each source is ready.

Static values are read in parallel at init using `std::thread::scope`:

```rust
let (gpu_cores, dram_gb, ssd_model, max_nits) = std::thread::scope(|s| {
    let h1 = s.spawn(|| catch_unwind(read_gpu_core_count).unwrap_or(0));
    let h2 = s.spawn(|| catch_unwind(read_dram_gb).unwrap_or(0));
    let h3 = s.spawn(|| catch_unwind(read_ssd_model).unwrap_or_default());
    let h4 = s.spawn(|| catch_unwind(read_display_max_nits).unwrap_or(500.0));
    (h1.join().unwrap_or(0), h2.join().unwrap_or(0),
     h3.join().unwrap_or_default(), h4.join().unwrap_or(500.0))
});
```

Each spawn is wrapped in `catch_unwind` — if `sysctl` fails or `IOServiceMatching` panics on an unexpected chip, you get a fallback value, not a dead process.

---

## Surviving panics: terminal restoration

A TUI that panics without restoring the terminal leaves the user in raw mode, no echo, no cursor. macpow wraps the event loop in `catch_unwind`:

```rust
let result = std::panic::catch_unwind(AssertUnwindSafe(|| -> Result<()> {
    let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
    let mut app = App::new();
    loop {
        while let Ok(m) = rx.try_recv() { app.update(m); }
        terminal.draw(|f| app.draw(f))?;
        // ... handle input ...
    }
}));

restore_terminal(); // ALWAYS runs — panic or not

match result {
    Ok(inner) => inner,
    Err(_) => bail!("TUI panicked. Terminal restored."),
}
```

`restore_terminal()` runs unconditionally — clean exit, error, or panic. `AssertUnwindSafe` is needed because `stdout()` isn't `UnwindSafe`, but the worst case is a torn frame, and we're about to exit.

---

## Multi-die (Ultra) support without hardcoding

IOReport channel names differ between single-die and multi-die (Ultra) chips:

```
 Single-die (M1/M2/M3/M4):       Multi-die (Ultra):

 ECPU0                            DIE_0_ECPU_CPU0
 PCPU10                           DIE_1_PCPU1_CPU3
 EACC_CPU0                        DIE_0_EACC_CPU0
 ISP                              ISP0_0
 DRAM                             DRAM0_1
```

Two rules keep this forward-compatible with any future chip:

```rust
/// Strip DIE_{N}_ prefix so all parsers see the same base format.
fn strip_die_prefix(name: &str) -> (usize, &str) {
    if let Some(rest) = name.strip_prefix("DIE_") {
        if let Some(idx) = rest.find('_') {
            if let Ok(die) = rest[..idx].parse::<usize>() {
                return (die, &rest[idx + 1..]);
            }
        }
    }
    (0, name)
}
```

And for block-level power (DRAM, ISP, ANE, etc.), `starts_with()` matching handles any suffix Apple might add:

```rust
match name.as_str() {
    n if n.starts_with("ANE")  => { soc.ane_w += watts; }
    n if n.starts_with("DRAM") => { soc.dram_w += watts; }
    n if n.starts_with("ISP")  => { soc.isp_w += watts; }
    // ...
}
```

`ISP` matches `ISP`, `ISP0_0`, `ISP0_1` — all correct. If a future M6 Ultra adds a third die, it works without code changes.

If something does break, `macpow --dump` prints every raw IOReport channel name for quick debugging.

---

## Tried and reverted

Not everything worked on the first attempt:

**25ms IOReport startup interval.** I tried using a tiny 25ms first sample interval to get SoC data on screen ~10x faster at launch. It worked but produced wildly noisy power numbers (dividing tiny energy deltas by 25ms amplifies noise). Reverted to using the standard interval everywhere.

**Layout-independent keyboard input.** I added the `kbd` crate to make keybindings work on non-QWERTY layouts (Dvorak, Russian, etc.). It added two dependencies, introduced double-action bugs, and still didn't cover all edge cases. Replaced it with plain `crossterm` key events — which already report the physical key on modern macOS.

**System total: sum vs SMC.** I went back and forth on whether to show the sum of measured components or the SMC `PSTR` system power rail. The sum can't account for everything (unmeasured peripherals, voltage regulators). PSTR is a direct measurement but sometimes lags. The current approach: show whichever is higher.

---

## What I'd do differently

**Async instead of threads.** Twelve OS threads for what's mostly sleep+syscall is fine for a TUI, but `tokio::time::interval` would be cleaner and use fewer resources. I stayed with `std::thread` to avoid any async runtime dependency — macpow currently has zero async deps.

**`objc2` crate instead of raw `objc_msgSend`.** The raw FFI works, but wrong argument types compile fine and crash at runtime. `objc2` adds compile-time safety for ObjC calls. Worth the dependency for anything larger.

**Bindgen for `rusage_info_v4`.** Reading struct fields at byte offsets is compact until it's wrong (see: the heap overflow). A generated binding catches layout changes at compile time.

---

## Install

```bash
cargo install macpow
# or
brew tap k06a/tap && brew install macpow
# or
pixi global install macpow
```

Requires macOS 12+ and Apple Silicon (M1–M5, all variants including Ultra). MIT licensed.

GitHub: [github.com/k06a/macpow](https://github.com/k06a/macpow) · Crates.io: [crates.io/crates/macpow](https://crates.io/crates/macpow)
