#![allow(unexpected_cfgs)]
use anyhow::{Context, Result, bail};
use libc::{HOST_CPU_LOAD_INFO, host_cpu_load_info_data_t, host_statistics64, integer_t};
use mach2::mach_init::mach_host_self;
use std::io::{BufRead, BufReader};
use std::mem::{MaybeUninit, size_of};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Instant;
use crate::cpu::{AppCPUUtilization, AppMonitor, CPUUtilization, ProcessCPUUtilization};
use crate::energy::{CPUEnergy, GPUEnergy, PlatformEnergy};
pub struct MacOSCpu {
monitor: Arc<Mutex<Option<MacOSMonitor>>>,
}
impl CPUEnergy for MacOSCpu {
fn get_power(&self) -> f64 {
self.monitor
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_mut()
.and_then(|monitor| monitor.read_power().ok())
.unwrap_or(0.0)
}
}
pub struct MacOSGPU {
monitor: Arc<Mutex<Option<MacOSMonitor>>>,
}
impl GPUEnergy for MacOSGPU {
fn get_power(&self) -> f64 {
self.monitor
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_mut()
.and_then(|monitor| monitor.read_detailed().ok())
.map(|reading| reading.gpu_power)
.unwrap_or(0.0)
}
}
pub struct MacOSPlatform {
monitor: Arc<Mutex<Option<MacOSMonitor>>>,
allow_elevation: bool,
}
impl Default for MacOSPlatform {
fn default() -> Self {
Self::new(false)
}
}
impl MacOSPlatform {
pub fn new(allow_elevation: bool) -> Self {
MacOSPlatform {
monitor: Arc::new(Mutex::new(None)),
allow_elevation,
}
}
fn fallback_monitor(error: anyhow::Error) -> MacOSMonitor {
crate::logging::print_warning(&format!(
"powermetrics unavailable: {}. Continuing without CPU/GPU power data",
error
));
MacOSMonitor {
is_apple_silicon: false,
last_reading: Arc::new(Mutex::new(None)),
child: None,
askpass_path: None,
}
}
fn ensure_power_monitor(&self) {
let mut monitor = self.monitor.lock().unwrap_or_else(|e| e.into_inner());
if monitor.is_none() {
*monitor = Some(
MacOSMonitor::new(self.allow_elevation).unwrap_or_else(Self::fallback_monitor),
);
}
}
}
impl PlatformEnergy for MacOSPlatform {
fn cpu(&self) -> Box<dyn CPUEnergy> {
self.ensure_power_monitor();
Box::new(MacOSCpu {
monitor: self.monitor.clone(),
})
}
fn gpu(&self) -> Box<dyn GPUEnergy> {
self.ensure_power_monitor();
Box::new(MacOSGPU {
monitor: self.monitor.clone(),
})
}
fn cpu_usage(&self) -> Box<dyn CPUUtilization> {
Box::new(MacOSCpuUsage::new())
}
fn process_cpu_usage(&self) -> Option<Box<dyn ProcessCPUUtilization>> {
Some(Box::new(MacOSProcessUtil::new()))
}
fn app_cpu_usage(
&self,
refresh_interval: std::time::Duration,
) -> Option<Box<dyn AppCPUUtilization>> {
Some(Box::new(AppMonitor::new(
MacOSProcessUtil::new,
refresh_interval,
)))
}
}
const CPU_STATE_USER: usize = 0;
const CPU_STATE_SYSTEM: usize = 1;
const CPU_STATE_IDLE: usize = 2;
const CPU_STATE_NICE: usize = 3;
pub struct CpuState {
prev_user: u64,
prev_system: u64,
prev_idle: u64,
prev_nice: u64,
}
impl CpuState {
fn new() -> Self {
Self {
prev_user: 0,
prev_system: 0,
prev_idle: 0,
prev_nice: 0,
}
}
}
pub struct MacOSCpuUsage {
state: Mutex<CpuState>,
}
impl Default for MacOSCpuUsage {
fn default() -> Self {
Self::new()
}
}
impl MacOSCpuUsage {
pub fn new() -> Self {
Self {
state: Mutex::new(CpuState::new()),
}
}
}
impl CPUUtilization for MacOSCpuUsage {
fn get_cpu_utilization(&self) -> f64 {
unsafe {
let mut cpu_info = MaybeUninit::<host_cpu_load_info_data_t>::uninit();
let mut count: u32 =
size_of::<host_cpu_load_info_data_t>() as u32 / size_of::<integer_t>() as u32;
let result = host_statistics64(
mach_host_self(),
HOST_CPU_LOAD_INFO,
cpu_info.as_mut_ptr() as *mut _,
&mut count,
);
if result != 0 {
return 0.0;
}
let cpu_info = cpu_info.assume_init();
let user = cpu_info.cpu_ticks[CPU_STATE_USER] as u64;
let system = cpu_info.cpu_ticks[CPU_STATE_SYSTEM] as u64;
let idle = cpu_info.cpu_ticks[CPU_STATE_IDLE] as u64;
let nice = cpu_info.cpu_ticks[CPU_STATE_NICE] as u64;
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
if state.prev_user == 0 {
state.prev_user = user;
state.prev_system = system;
state.prev_idle = idle;
state.prev_nice = nice;
return 0.0;
}
let user_delta = user.saturating_sub(state.prev_user);
let system_delta = system.saturating_sub(state.prev_system);
let idle_delta = idle.saturating_sub(state.prev_idle);
let nice_delta = nice.saturating_sub(state.prev_nice);
let total_delta = user_delta + system_delta + idle_delta + nice_delta;
let usage = if total_delta > 0 {
(user_delta + system_delta + nice_delta) as f64 / total_delta as f64
} else {
0.0
};
state.prev_user = user;
state.prev_system = system;
state.prev_idle = idle;
state.prev_nice = nice;
usage
}
}
}
const PROC_PIDTASKINFO: i32 = 4;
#[repr(C)]
#[derive(Default)]
struct proc_taskinfo {
pti_virtual_size: u64,
pti_resident_size: u64,
pti_total_user: u64,
pti_total_system: u64,
pti_threads_user: u64,
pti_threads_system: u64,
pti_policy: i32,
pti_faults: i32,
pti_pageins: i32,
pti_cow_faults: i32,
pti_messages_sent: i32,
pti_messages_received: i32,
pti_syscalls_mach: i32,
pti_syscalls_unix: i32,
pti_csw: i32,
pti_threadnum: i32,
pti_numrunning: i32,
pti_priority: i32,
}
#[repr(C)]
pub struct mach_timebase_info_data_t {
pub numer: u32,
pub denom: u32,
}
unsafe extern "C" {
fn proc_pidinfo(
pid: i32,
flavor: i32,
arg: u64,
buffer: *mut libc::c_void,
buffersize: i32,
) -> i32;
fn mach_timebase_info(info: *mut mach_timebase_info_data_t) -> libc::c_int;
}
pub struct MacOSProcessUtil {
before_process_time: u64,
before_timestamp: Instant,
timebase_numer: u32,
timebase_denom: u32,
num_cores: u32,
}
impl Default for MacOSProcessUtil {
fn default() -> Self {
Self::new()
}
}
impl MacOSProcessUtil {
pub fn new() -> Self {
let mut timebase = mach_timebase_info_data_t { numer: 0, denom: 0 };
unsafe {
mach_timebase_info(&mut timebase);
}
Self {
before_process_time: 0,
before_timestamp: Instant::now(),
timebase_numer: if timebase.denom > 0 {
timebase.numer
} else {
1
},
timebase_denom: if timebase.denom > 0 {
timebase.denom
} else {
1
},
num_cores: Self::get_num_cores(),
}
}
fn get_num_cores() -> u32 {
let mut num_cores: i32 = 0;
let mut size = std::mem::size_of::<i32>();
unsafe {
let name = std::ffi::CString::new("hw.logicalcpu").unwrap();
if libc::sysctlbyname(
name.as_ptr(),
&mut num_cores as *mut _ as *mut libc::c_void,
&mut size,
std::ptr::null_mut(),
0,
) == 0
{
num_cores as u32
} else {
1
}
}
}
fn get_process_time(&self, pid: u32) -> Option<u64> {
let mut info = proc_taskinfo::default();
let size = std::mem::size_of::<proc_taskinfo>() as i32;
unsafe {
let result = proc_pidinfo(
pid as i32,
PROC_PIDTASKINFO,
0,
&mut info as *mut _ as *mut libc::c_void,
size,
);
if result == size {
Some(info.pti_total_user + info.pti_total_system)
} else {
None
}
}
}
}
impl ProcessCPUUtilization for MacOSProcessUtil {
fn get_process_cpu_utilization(&mut self, pid: u32) -> f64 {
let current_process_time = match self.get_process_time(pid) {
Some(t) => t,
None => return 0.0,
};
let current_timestamp = Instant::now();
if self.before_process_time == 0 {
self.before_process_time = current_process_time;
self.before_timestamp = current_timestamp;
return 0.0;
}
let process_delta_mach = current_process_time.saturating_sub(self.before_process_time);
let time_delta = current_timestamp.duration_since(self.before_timestamp);
self.before_process_time = current_process_time;
self.before_timestamp = current_timestamp;
let process_delta_ns = (process_delta_mach as u128 * self.timebase_numer as u128)
/ self.timebase_denom as u128;
let time_delta_ns = time_delta.as_nanos();
if time_delta_ns == 0 {
return 0.0;
}
(process_delta_ns as f64) / (time_delta_ns as f64 * self.num_cores as f64)
}
}
pub struct MacOSMonitor {
#[allow(dead_code)]
is_apple_silicon: bool,
last_reading: Arc<Mutex<Option<PowerReading>>>,
child: Option<Child>,
askpass_path: Option<std::path::PathBuf>,
}
#[derive(Debug, Clone)]
struct PowerReading {
cpu_power: f64,
gpu_power: Option<f64>,
ane_power: Option<f64>, }
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct PowerBreakdown {
pub cpu_power: f64,
pub gpu_power: f64,
pub ane_power: f64,
pub total_power: f64,
}
impl MacOSMonitor {
pub fn new(allow_elevation: bool) -> Result<Self> {
let is_apple_silicon = Self::is_apple_silicon()?;
let last_reading = Arc::new(Mutex::new(None));
let is_root = Self::is_root();
if !is_root && !allow_elevation {
Self::authenticate_cli_sudo()?;
}
let askpass_path = if allow_elevation && !is_root {
Some(Self::setup_askpass()?)
} else {
None
};
let mut command =
Self::build_powermetrics_command(allow_elevation, askpass_path.as_deref(), is_root);
let mut child = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to spawn powermetrics")?;
let stdout = child.stdout.take().context("Failed to open stdout")?;
let stderr = child.stderr.take().context("Failed to open stderr")?;
let reading_clone = last_reading.clone();
thread::spawn(move || {
let reader = BufReader::new(stdout);
let mut current_block = String::new();
for line in reader.lines() {
if let Ok(line) = line {
if line.starts_with("*** Sample") && !current_block.is_empty() {
match Self::parse_block(¤t_block, is_apple_silicon) {
Ok(Some(new_reading)) => {
let mut last =
reading_clone.lock().unwrap_or_else(|e| e.into_inner());
*last = Some(new_reading);
tracing::debug!("Successfully parsed powermetrics sample");
}
Ok(None) => {
}
Err(e) => {
crate::logging::print_error(&format!(
"Failed to parse powermetrics block: {}",
e
));
}
}
current_block.clear();
}
current_block.push_str(&line);
current_block.push('\n');
} else {
crate::logging::print_error("Failed to read line from powermetrics stdout");
break;
}
}
});
thread::spawn(move || {
let reader = BufReader::new(stderr);
for line in reader.lines().map_while(Result::ok) {
crate::logging::print_error(&format!("powermetrics error: {}", line));
}
});
Ok(Self {
is_apple_silicon,
last_reading,
child: Some(child),
askpass_path,
})
}
fn sudo_powermetrics_args() -> [&'static str; 8] {
[
"env",
"LC_ALL=C",
"LANG=C",
"powermetrics",
"--samplers",
"cpu_power,gpu_power",
"-i",
"1000",
]
}
fn powermetrics_env_args() -> [&'static str; 7] {
[
"LC_ALL=C",
"LANG=C",
"powermetrics",
"--samplers",
"cpu_power,gpu_power",
"-i",
"1000",
]
}
fn parse_block(block: &str, is_apple_silicon: bool) -> Result<Option<PowerReading>> {
if is_apple_silicon {
if !block.contains("Power:") {
return Ok(None);
}
let cpu_power = Self::extract_cpu_power(block)?;
let gpu_power = Self::extract_gpu_power(block);
let ane_power = Self::extract_ane_power(block);
Ok(Some(PowerReading {
cpu_power,
gpu_power,
ane_power,
}))
} else {
if !block.contains("package power") && !block.contains("Package Power") {
return Ok(None);
}
let cpu_power = Self::extract_intel_package_power(block)?;
Ok(Some(PowerReading {
cpu_power,
gpu_power: Some(0.0),
ane_power: Some(0.0),
}))
}
}
fn build_powermetrics_command(
allow_elevation: bool,
askpass_path: Option<&Path>,
is_root: bool,
) -> Command {
if is_root {
let mut command = Command::new("env");
command.args(Self::powermetrics_env_args());
command
} else {
let mut command = Command::new("sudo");
if allow_elevation {
if let Some(askpass_path) = askpass_path {
command.env("SUDO_ASKPASS", askpass_path).arg("-A");
}
} else {
command.arg("-n");
}
command.args(Self::sudo_powermetrics_args());
command
}
}
fn authenticate_cli_sudo() -> Result<()> {
let status = Command::new("sudo")
.arg("-v")
.status()
.context("Failed to request sudo authentication for powermetrics")?;
if status.success() {
Ok(())
} else {
bail!("powermetrics requires sudo authentication")
}
}
fn is_root() -> bool {
unsafe { libc::getuid() == 0 }
}
fn setup_askpass() -> Result<std::path::PathBuf> {
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let temp_dir = std::env::temp_dir();
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let askpass_path = temp_dir.join(format!(
"joular_askpass_{}_{}.sh",
std::process::id(),
timestamp
));
let _ = std::fs::remove_file(&askpass_path);
let script_content = r#"#!/bin/bash
osascript -e 'display dialog "Joular Core requires administrative privileges to measure power data from powermetrics. Please enter your password:" default answer "" with title "Joular Core Elevation" with icon caution with hidden answer' -e 'text returned of result'
"#;
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o700)
.open(&askpass_path)
.context("Failed to create askpass script")?;
file.write_all(script_content.as_bytes())
.context("Failed to write to askpass script")?;
Ok(askpass_path)
}
fn is_apple_silicon() -> Result<bool> {
let output = Command::new("sysctl")
.arg("-n")
.arg("machdep.cpu.brand_string")
.output()
.context("Failed to detect CPU type")?;
let cpu_brand = String::from_utf8_lossy(&output.stdout);
Ok(cpu_brand.contains("Apple"))
}
pub fn read_power(&mut self) -> Result<f64> {
let last = self.last_reading.lock().unwrap_or_else(|e| e.into_inner());
if let Some(ref reading) = *last {
Ok(reading.cpu_power)
} else {
Ok(0.0)
}
}
#[allow(dead_code)]
fn extract_cpu_power(output: &str) -> Result<f64> {
let mut total_power = 0.0;
for line in output.lines() {
let line = line.trim();
if line.starts_with("CPU Power:")
&& let Some(power_str) = line.split(':').nth(1)
&& let Some(value) = power_str.split_whitespace().next()
&& let Ok(mw) = value.parse::<f64>()
{
return Ok(mw / 1000.0);
}
if (line.starts_with("E-Cluster Power:") || line.starts_with("P-Cluster Power:"))
&& let Some(power_str) = line.split(':').nth(1)
&& let Some(value) = power_str.split_whitespace().next()
&& let Ok(mw) = value.parse::<f64>()
{
total_power += mw / 1000.0;
}
}
if total_power > 0.0 {
return Ok(total_power);
}
bail!("Could not parse CPU power")
}
#[allow(dead_code)]
fn extract_gpu_power(output: &str) -> Option<f64> {
for line in output.lines() {
let line = line.trim();
if line.starts_with("GPU Power:")
&& let Some(power_str) = line.split(':').nth(1)
&& let Some(value) = power_str.split_whitespace().next()
&& let Ok(mw) = value.parse::<f64>()
{
return Some(mw / 1000.0);
}
}
None
}
#[allow(dead_code)]
fn extract_ane_power(output: &str) -> Option<f64> {
for line in output.lines() {
let line = line.trim();
if line.starts_with("ANE Power:")
&& let Some(power_str) = line.split(':').nth(1)
&& let Some(value) = power_str.split_whitespace().next()
&& let Ok(mw) = value.parse::<f64>()
{
return Some(mw / 1000.0);
}
}
None
}
fn extract_intel_package_power(output: &str) -> Result<f64> {
for line in output.lines() {
let line = line.trim();
if line.contains("package power") || line.contains("Package Power") {
let parts: Vec<&str> = line.split_whitespace().collect();
for (i, part) in parts.iter().enumerate() {
if part.ends_with('W') && !part.ends_with("mW") {
let value_str = part.trim_end_matches('W');
if let Ok(watts) = value_str.parse::<f64>() {
return Ok(watts);
}
} else if part.ends_with("mW") {
let value_str = part.trim_end_matches("mW");
if let Ok(mw) = value_str.parse::<f64>() {
return Ok(mw / 1000.0);
}
}
if *part == "W"
&& i > 0
&& let Ok(watts) = parts[i - 1].parse::<f64>()
{
return Ok(watts);
}
}
}
}
bail!("Could not parse Intel package power")
}
fn read_detailed(&mut self) -> Result<PowerBreakdown> {
let last = self.last_reading.lock().unwrap_or_else(|e| e.into_inner());
if let Some(ref reading) = *last {
Ok(PowerBreakdown {
cpu_power: reading.cpu_power,
gpu_power: reading.gpu_power.unwrap_or(0.0),
ane_power: reading.ane_power.unwrap_or(0.0),
total_power: reading.cpu_power
+ reading.gpu_power.unwrap_or(0.0)
+ reading.ane_power.unwrap_or(0.0),
})
} else {
Ok(PowerBreakdown {
cpu_power: 0.0,
gpu_power: 0.0,
ane_power: 0.0,
total_power: 0.0,
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
fn args(command: &Command) -> Vec<String> {
command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect()
}
#[test]
fn cli_mode_uses_terminal_sudo_prompt() {
let command = MacOSMonitor::build_powermetrics_command(false, None, false);
let args = args(&command);
assert_eq!(command.get_program(), OsStr::new("sudo"));
assert!(!args.iter().any(|arg| arg == "-A"));
assert!(args.iter().any(|arg| arg == "-n"));
assert!(command.get_envs().all(|(key, _)| key != "SUDO_ASKPASS"));
assert!(
args.windows(2)
.any(|window| window[0] == "-n" && window[1] == "env")
);
}
#[test]
fn gui_mode_uses_sudo_askpass() {
let askpass_path = Path::new("/tmp/joular_askpass_test.sh");
let command = MacOSMonitor::build_powermetrics_command(true, Some(askpass_path), false);
let args = args(&command);
assert_eq!(command.get_program(), OsStr::new("sudo"));
assert!(args.iter().any(|arg| arg == "-A"));
assert!(command.get_envs().any(|(key, value)| {
key == "SUDO_ASKPASS" && value == Some(askpass_path.as_os_str())
}));
assert!(
args.windows(2)
.any(|window| window[0] == "-A" && window[1] == "env")
);
}
#[test]
fn root_mode_runs_powermetrics_without_sudo_or_askpass() {
let command = MacOSMonitor::build_powermetrics_command(true, None, true);
let args = args(&command);
assert_eq!(command.get_program(), OsStr::new("env"));
assert!(!args.iter().any(|arg| arg == "-A"));
assert!(command.get_envs().all(|(key, _)| key != "SUDO_ASKPASS"));
assert_eq!(args.first().map(String::as_str), Some("LC_ALL=C"));
}
}
impl Drop for MacOSMonitor {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
if let Some(path) = self.askpass_path.take() {
let _ = std::fs::remove_file(&path);
}
}
}