diff --git a/package.json b/package.json index af732a9..d8b8527 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "type": "module", "scripts": { "dev": "vite", + "dev:prepare-helper": "\"$HOME/.cargo/bin/cargo\" build --manifest-path src-tauri/Cargo.toml --bin fanguard-helper", "build": "vite build", "preview": "vite preview", "tauri": "tauri", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6dbf14a..bb17969 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -36,10 +36,20 @@ pub struct AppState { pub current_power_source: Mutex, } +fn should_try_direct_smc_writer(euid: u32) -> bool { + euid == 0 +} + impl AppState { pub fn new() -> Self { - let writer: Option> = SmcWriter::new() - .map(|w| Box::new(w) as Box) + let euid = unsafe { libc::geteuid() }; + let direct_writer = if should_try_direct_smc_writer(euid) { + SmcWriter::new().map(|w| Box::new(w) as Box) + } else { + Err(crate::smc_writer::SmcWriteError::InsufficientPrivileges) + }; + + let writer: Option> = direct_writer .or_else(|direct_err| { warn_log!( "[fanguard] Direct SMC writer failed: {direct_err} — trying socket client" @@ -611,14 +621,11 @@ pub fn install_helper() -> Result { "# ); - // Build shell commands for osascript - let shell_commands = format!( - "mkdir -p '{}' && cp '{}' '{}' && chmod 755 '{}' && chown root:wheel '{}' && /bin/cat > '{}' << 'PLISTEOF'\n{}\nPLISTEOF\nchown root:wheel '{}' && chmod 644 '{}' && launchctl bootout system/{} 2>/dev/null; launchctl bootstrap system '{}'", - HELPER_INSTALL_DIR, - helper_binary.to_string_lossy(), install_path, install_path, install_path, - plist_path, plist_content, - plist_path, plist_path, - DAEMON_LABEL, plist_path + let shell_commands = build_helper_install_shell_commands( + &helper_binary, + &install_path, + &plist_path, + &plist_content, ); let script = format!( @@ -651,6 +658,23 @@ pub fn install_helper() -> Result { Err("Helper installed but socket not found after 5 seconds".to_string()) } +fn build_helper_install_shell_commands( + helper_binary: &std::path::Path, + install_path: &str, + plist_path: &str, + plist_content: &str, +) -> String { + format!( + "mkdir -p '{helper_dir}' && cp '{helper_binary}' '{install_path}' && chmod 755 '{install_path}' && chown root:wheel '{install_path}' && /bin/cat > '{plist_path}' << 'PLISTEOF'\n{plist_content}\nPLISTEOF\nchown root:wheel '{plist_path}' && chmod 644 '{plist_path}' && launchctl bootout system '{plist_path}' 2>/dev/null || true\nlaunchctl enable system/{label}\nlaunchctl bootstrap system '{plist_path}'\nlaunchctl kickstart -k system/{label}", + helper_dir = HELPER_INSTALL_DIR, + helper_binary = helper_binary.to_string_lossy(), + install_path = install_path, + plist_path = plist_path, + plist_content = plist_content, + label = DAEMON_LABEL, + ) +} + fn find_helper_binary(exe_path: &std::path::Path) -> Result { // Production: look in the .app bundle let app_bundle_helper = exe_path @@ -686,7 +710,11 @@ pub fn reconnect_writer(state: State<'_, AppState>) -> Result { #[cfg(test)] mod tests { - use super::ping_backend; + use super::{ + build_helper_install_shell_commands, ping_backend, should_try_direct_smc_writer, + DAEMON_LABEL, HELPER_INSTALL_DIR, LAUNCHDAEMON_DIR, + }; + use std::path::Path; #[test] fn ping_backend_returns_expected_payload() { @@ -700,4 +728,31 @@ mod tests { let result = ping_backend(String::new()); assert!(result.is_err()); } + + #[test] + fn should_try_direct_smc_writer_when_running_as_root() { + assert!(should_try_direct_smc_writer(0)); + } + + #[test] + fn should_not_try_direct_smc_writer_when_running_unprivileged() { + assert!(!should_try_direct_smc_writer(501)); + } + + #[test] + fn helper_install_commands_enable_service_before_bootstrap() { + let helper_binary = Path::new("/tmp/fanguard-helper"); + let install_path = format!("{HELPER_INSTALL_DIR}/{DAEMON_LABEL}"); + let plist_path = format!("{LAUNCHDAEMON_DIR}/{DAEMON_LABEL}.plist"); + let plist_content = "".to_string(); + + let commands = build_helper_install_shell_commands( + helper_binary, + &install_path, + &plist_path, + &plist_content, + ); + + assert!(commands.contains(&format!("launchctl enable system/{DAEMON_LABEL}"))); + } } diff --git a/src-tauri/src/smc_writer.rs b/src-tauri/src/smc_writer.rs index 24a5b92..1ed0641 100644 --- a/src-tauri/src/smc_writer.rs +++ b/src-tauri/src/smc_writer.rs @@ -86,6 +86,12 @@ const FAN_MODE_SYSTEM: u8 = 3; const MODE_POLL_INTERVAL: Duration = Duration::from_millis(100); const MODE_TRANSITION_RETRY_COUNT: u32 = 100; +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum FanModeCapability { + Managed, + Absent, +} + #[link(name = "IOKit", kind = "framework")] extern "C" { fn IOServiceMatching(name: *const u8) -> CFMutableDictionaryRef; @@ -473,12 +479,21 @@ impl SmcWriter { } } - self.wait_for_system_mode_handoff(fan_index)?; + let mode_capability = + detect_fan_mode_capability(self.read_key_info(fan_key(fan_index, b"Md")))?; - // Step 2: Set forced mode - debug_log!("[smc_writer] Setting F{fan_index}Md=1 (forced)"); - self.set_fan_mode(fan_index, true)?; - self.verify_mode_allows_target_write(fan_index)?; + if mode_capability == FanModeCapability::Managed { + self.wait_for_system_mode_handoff(fan_index)?; + + // Step 2: Set forced mode + debug_log!("[smc_writer] Setting F{fan_index}Md=1 (forced)"); + self.set_fan_mode(fan_index, true)?; + self.verify_mode_allows_target_write(fan_index)?; + } else { + debug_log!( + "[smc_writer] F{fan_index}Md key absent — skipping mode transition and writing F{fan_index}Tg directly" + ); + } // Step 3: Write target RPM let key = fan_key(fan_index, b"Tg"); @@ -539,7 +554,15 @@ impl SmcWriter { /// no fans remain in forced mode. fn set_fan_auto_impl(&self, fan_index: u8) -> Result<(), SmcWriteError> { debug_log!("[smc_writer] set_fan_auto: fan={fan_index}"); - self.set_fan_mode(fan_index, false) + let mode_capability = + detect_fan_mode_capability(self.read_key_info(fan_key(fan_index, b"Md")))?; + + if mode_capability == FanModeCapability::Managed { + self.set_fan_mode(fan_index, false) + } else { + debug_log!("[smc_writer] F{fan_index}Md key absent — no auto mode to restore"); + Ok(()) + } } /// Sets the fan mode flag: `false` = Auto, `true` = Forced. @@ -834,6 +857,16 @@ fn validate_mode_allows_target_write(actual_mode: u8) -> Result<(), SmcWriteErro } } +fn detect_fan_mode_capability( + mode_key_info: Result, +) -> Result { + match mode_key_info { + Ok(_) => Ok(FanModeCapability::Managed), + Err(SmcWriteError::UnknownKey(_)) => Ok(FanModeCapability::Absent), + Err(error) => Err(error), + } +} + // ── Tests ──────────────────────────────────────────────────────────────────── // ── Test mock ──────────────────────────────────────────────────────────────── @@ -1031,4 +1064,84 @@ mod tests { }) )); } + + #[test] + fn detect_fan_mode_capability_uses_mode_control_when_key_exists() { + let result = detect_fan_mode_capability(Ok(SmcKeyDataKeyInfo::default())); + + assert!(matches!(result, Ok(FanModeCapability::Managed))); + } + + #[test] + fn detect_fan_mode_capability_skips_mode_control_when_key_is_missing() { + let result = detect_fan_mode_capability(Err(SmcWriteError::UnknownKey("F0Md".to_string()))); + + assert!(matches!(result, Ok(FanModeCapability::Absent))); + } + + #[test] + fn detect_fan_mode_capability_propagates_non_missing_errors() { + let result = detect_fan_mode_capability(Err(SmcWriteError::InsufficientPrivileges)); + + assert!(matches!(result, Err(SmcWriteError::InsufficientPrivileges))); + } + + #[test] + #[ignore = "hardware-dependent smoke test for Apple Silicon Macs without F*Md"] + fn writes_target_rpm_when_mode_key_is_missing() { + let writer = SmcWriter::new().expect("SMC writer should connect on supported Macs"); + let fan_index = 0; + let mode_capability = + detect_fan_mode_capability(writer.read_key_info(fan_key(fan_index, b"Md"))); + + if !matches!(mode_capability, Ok(FanModeCapability::Absent)) { + return; + } + + let target_info = writer + .read_key_info(fan_key(fan_index, b"Tg")) + .expect("target key should exist"); + let target_type = target_info.data_type.to_be_bytes(); + let target_bytes = writer + .read_key_bytes(fan_key(fan_index, b"Tg"), target_info.data_size) + .expect("target value should be readable"); + let current_target = decode_rpm(&target_bytes, &target_type); + + let min_info = writer + .read_key_info(fan_key(fan_index, b"Mn")) + .expect("min key should exist"); + let min_type = min_info.data_type.to_be_bytes(); + let min_bytes = writer + .read_key_bytes(fan_key(fan_index, b"Mn"), min_info.data_size) + .expect("min value should be readable"); + let min_rpm = decode_rpm(&min_bytes, &min_type); + + let max_info = writer + .read_key_info(fan_key(fan_index, b"Mx")) + .expect("max key should exist"); + let max_type = max_info.data_type.to_be_bytes(); + let max_bytes = writer + .read_key_bytes(fan_key(fan_index, b"Mx"), max_info.data_size) + .expect("max value should be readable"); + let max_rpm = decode_rpm(&max_bytes, &max_type); + let safe_target = current_target.clamp(min_rpm, max_rpm); + + writer + .set_fan_target_rpm_impl(fan_index, safe_target, min_rpm, max_rpm) + .expect("target write should succeed without F*Md"); + + let readback = writer + .read_key_bytes(fan_key(fan_index, b"Tg"), target_info.data_size) + .expect("target readback should succeed"); + let readback_rpm = decode_rpm(&readback, &target_type); + + assert!( + (readback_rpm - safe_target).abs() <= 50.0, + "expected target readback near {safe_target}, got {readback_rpm}" + ); + + writer + .set_fan_auto_impl(fan_index) + .expect("auto should be a no-op when F*Md is absent"); + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 78b2b95..31c4596 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -4,7 +4,7 @@ "version": "0.1.0-beta.2", "identifier": "io.github.naufaldi.fanguard", "build": { - "beforeDevCommand": "pnpm run dev", + "beforeDevCommand": "pnpm run dev:prepare-helper && pnpm run dev", "beforeBuildCommand": "pnpm run build", "devUrl": "http://localhost:5173", "frontendDist": "../dist"