Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
77 changes: 66 additions & 11 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,20 @@ pub struct AppState {
pub current_power_source: Mutex<PowerSource>,
}

fn should_try_direct_smc_writer(euid: u32) -> bool {
euid == 0
}

impl AppState {
pub fn new() -> Self {
let writer: Option<Box<dyn SmcWriteApi>> = SmcWriter::new()
.map(|w| Box::new(w) as Box<dyn SmcWriteApi>)
let euid = unsafe { libc::geteuid() };
let direct_writer = if should_try_direct_smc_writer(euid) {
SmcWriter::new().map(|w| Box::new(w) as Box<dyn SmcWriteApi>)
} else {
Err(crate::smc_writer::SmcWriteError::InsufficientPrivileges)
};

let writer: Option<Box<dyn SmcWriteApi>> = direct_writer
.or_else(|direct_err| {
warn_log!(
"[fanguard] Direct SMC writer failed: {direct_err} — trying socket client"
Expand Down Expand Up @@ -611,14 +621,11 @@ pub fn install_helper() -> Result<String, String> {
</plist>"#
);

// 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!(
Expand Down Expand Up @@ -651,6 +658,23 @@ pub fn install_helper() -> Result<String, String> {
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<std::path::PathBuf, String> {
// Production: look in the .app bundle
let app_bundle_helper = exe_path
Expand Down Expand Up @@ -686,7 +710,11 @@ pub fn reconnect_writer(state: State<'_, AppState>) -> Result<bool, String> {

#[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() {
Expand All @@ -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 = "<plist />".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}")));
}
}
125 changes: 119 additions & 6 deletions src-tauri/src/smc_writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -834,6 +857,16 @@ fn validate_mode_allows_target_write(actual_mode: u8) -> Result<(), SmcWriteErro
}
}

fn detect_fan_mode_capability(
mode_key_info: Result<SmcKeyDataKeyInfo, SmcWriteError>,
) -> Result<FanModeCapability, SmcWriteError> {
match mode_key_info {
Ok(_) => Ok(FanModeCapability::Managed),
Err(SmcWriteError::UnknownKey(_)) => Ok(FanModeCapability::Absent),
Err(error) => Err(error),
}
}

// ── Tests ────────────────────────────────────────────────────────────────────

// ── Test mock ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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");
}
}
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading