// ABOUTME: Power actions — lock, logout, hibernate, reboot, shutdown. // ABOUTME: Wrappers around system commands for the session power menu. use std::fmt; use std::io::Read; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; const POWER_TIMEOUT: Duration = Duration::from_secs(30); #[derive(Debug)] pub enum PowerError { CommandFailed { action: &'static str, message: String }, Timeout { action: &'static str }, } impl fmt::Display for PowerError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { PowerError::CommandFailed { action, message } => { write!(f, "{action} failed: {message}") } PowerError::Timeout { action } => { write!(f, "{action} timed out") } } } } impl std::error::Error for PowerError {} /// Run a command with timeout and return a PowerError on failure. /// /// Uses blocking `child.wait()` with a separate timeout thread that sends /// SIGKILL after POWER_TIMEOUT. This runs inside `gio::spawn_blocking`, /// so blocking is expected. fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> { log::debug!("Power action: {action} ({program} {args:?})"); let mut child = Command::new(program) .args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| PowerError::CommandFailed { action, message: e.to_string(), })?; let child_pid = nix::unistd::Pid::from_raw(child.id() as i32); let done = Arc::new(AtomicBool::new(false)); let done_clone = done.clone(); let timeout_thread = std::thread::spawn(move || { // Sleep in short intervals so we can exit early when the child finishes let interval = Duration::from_millis(100); let mut elapsed = Duration::ZERO; while elapsed < POWER_TIMEOUT { std::thread::sleep(interval); if done_clone.load(Ordering::Relaxed) { return; } elapsed += interval; } // ESRCH if the process already exited — harmless let _ = nix::sys::signal::kill(child_pid, nix::sys::signal::Signal::SIGKILL); }); let status = child.wait().map_err(|e| PowerError::CommandFailed { action, message: e.to_string(), })?; done.store(true, Ordering::Relaxed); let _ = timeout_thread.join(); if status.success() { log::debug!("Power action {action} completed"); Ok(()) } else { // Check if killed by our timeout (SIGKILL = signal 9) #[cfg(unix)] { use std::os::unix::process::ExitStatusExt; if status.signal() == Some(9) { return Err(PowerError::Timeout { action }); } } let mut stderr_buf = String::new(); if let Some(mut stderr) = child.stderr.take() { let _ = stderr.read_to_string(&mut stderr_buf); } Err(PowerError::CommandFailed { action, message: format!("exit code {}: {}", status, stderr_buf.trim()), }) } } /// Lock the current session by launching moonlock. /// Spawns moonlock as a detached process and returns immediately — /// moonlock runs independently until the user unlocks. pub fn lock() -> Result<(), PowerError> { log::debug!("Power action: lock (spawning moonlock)"); Command::new("/usr/bin/moonlock") .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .map_err(|e| PowerError::CommandFailed { action: "lock", message: e.to_string(), })?; // Child handle is dropped here — moonlock continues running independently. Ok(()) } /// Quit the Niri compositor (logout). pub fn logout() -> Result<(), PowerError> { run_command("logout", "/usr/bin/niri", &["msg", "action", "quit"]) } /// Hibernate the system via systemctl. pub fn hibernate() -> Result<(), PowerError> { run_command("hibernate", "/usr/bin/systemctl", &["hibernate"]) } /// Reboot the system via systemctl. pub fn reboot() -> Result<(), PowerError> { run_command("reboot", "/usr/bin/systemctl", &["reboot"]) } /// Shut down the system via systemctl. pub fn shutdown() -> Result<(), PowerError> { run_command("shutdown", "/usr/bin/systemctl", &["poweroff"]) } #[cfg(test)] mod tests { use super::*; #[test] fn power_error_command_failed_display() { let err = PowerError::CommandFailed { action: "lock", message: "No such file or directory".to_string(), }; assert_eq!(err.to_string(), "lock failed: No such file or directory"); } #[test] fn power_error_timeout_display() { let err = PowerError::Timeout { action: "shutdown" }; assert_eq!(err.to_string(), "shutdown timed out"); } #[test] fn run_command_returns_error_for_missing_binary() { let result = run_command("test", "nonexistent-binary-xyz", &[]); assert!(result.is_err()); let err = result.unwrap_err(); assert!(matches!(err, PowerError::CommandFailed { action: "test", .. })); } #[test] fn run_command_returns_error_on_nonzero_exit() { let result = run_command("test", "false", &[]); assert!(result.is_err()); let err = result.unwrap_err(); assert!(matches!(err, PowerError::CommandFailed { action: "test", .. })); } #[test] fn run_command_succeeds_for_true() { let result = run_command("test", "true", &[]); assert!(result.is_ok()); } #[test] fn run_command_passes_args() { // "echo" with args should succeed let result = run_command("test", "echo", &["hello", "world"]); assert!(result.is_ok()); } }