188 lines
5.8 KiB
Rust
188 lines
5.8 KiB
Rust
// 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());
|
|
}
|
|
}
|