use std::io;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use log::warn;
use nix::errno::Errno;
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
use tokio::fs;
use tokio::process::Command as TokioCommand;
use tokio::time;
use crate::command::CommandReceiver;
use crate::producer::{MsgSender, Producer, ProducerFuture, ProducerResult};
use crate::widget::{Command, Hypridle, Msg};
const PROC_ROOT: &str = "/proc";
const HYPRIDLE_EXECUTABLE: &str = "hypridle";
const POLL_INTERVAL: Duration = Duration::from_secs(2);
const STOP_TIMEOUT: Duration = Duration::from_secs(1);
const STOP_POLL_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HypridleAction {
None,
Start,
Stop,
}
fn required_action(active: bool, desired: bool) -> HypridleAction {
match (active, desired) {
(false, true) => HypridleAction::Start,
(true, false) => HypridleAction::Stop,
_ => HypridleAction::None,
}
}
async fn discover_hypridle_processes(root: &Path) -> io::Result<Vec<i32>> {
let current_uid = fs::metadata(root.join("self"))
.await
.or_else(|_| std::fs::metadata(root))?
.uid();
let mut entries = fs::read_dir(root).await?;
let mut pids = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
let Ok(pid) = name.parse::<i32>() else {
continue;
};
let path = entry.path();
let Ok(metadata) = entry.metadata().await else {
continue;
};
if metadata.uid() != current_uid {
continue;
}
let Ok(comm) = fs::read_to_string(path.join("comm")).await else {
continue;
};
if comm.trim_end() == HYPRIDLE_EXECUTABLE {
pids.push(pid);
}
}
pids.sort_unstable();
Ok(pids)
}
async fn active(root: &Path) -> io::Result<bool> {
Ok(!discover_hypridle_processes(root).await?.is_empty())
}
pub struct HypridleProducer {
root: PathBuf,
interval: Duration,
}
impl HypridleProducer {
pub fn new() -> Self {
Self {
root: PathBuf::from(PROC_ROOT),
interval: POLL_INTERVAL,
}
}
}
impl Default for HypridleProducer {
fn default() -> Self {
Self::new()
}
}
impl Producer for HypridleProducer {
fn name(&self) -> String {
"hypridle".to_string()
}
fn run(self: Box<Self>, tx: MsgSender) -> ProducerFuture {
Box::pin(run_producer(self.root, self.interval, tx))
}
}
async fn run_producer(root: PathBuf, interval: Duration, tx: MsgSender) -> ProducerResult {
let mut previous = None;
loop {
let next = match active(&root).await {
Ok(active) => active,
Err(error) => {
warn!("hypridle: reading process state failed: {error}");
time::sleep(interval).await;
continue;
}
};
if previous != Some(next) {
previous = Some(next);
if tx.send(Msg::Hypridle(Hypridle::new(next))).is_err() {
return Ok(());
}
}
time::sleep(interval).await;
}
}
fn signal_stop(pids: &[i32]) -> io::Result<()> {
for &pid in pids {
if let Err(error) = kill(Pid::from_raw(pid), Signal::SIGTERM)
&& error != Errno::ESRCH
{
return Err(io::Error::from_raw_os_error(error as i32));
}
}
Ok(())
}
async fn wait_until_stopped(root: &Path) -> io::Result<bool> {
let deadline = time::Instant::now() + STOP_TIMEOUT;
loop {
if !active(root).await? {
return Ok(false);
}
if time::Instant::now() >= deadline {
return Ok(true);
}
time::sleep(STOP_POLL_INTERVAL).await;
}
}
async fn set_state(root: &Path, executable: &Path, desired: bool) -> io::Result<bool> {
let pids = discover_hypridle_processes(root).await?;
match required_action(!pids.is_empty(), desired) {
HypridleAction::None => Ok(desired),
HypridleAction::Start => {
TokioCommand::new(executable)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
Ok(true)
}
HypridleAction::Stop => {
signal_stop(&pids)?;
wait_until_stopped(root).await
}
}
}
pub async fn run_commands(mut commands: CommandReceiver, updates: MsgSender) -> ProducerResult {
while let Some(command) = commands.recv().await {
let Command::SetHypridle(desired) = command else {
continue;
};
let state = match set_state(
Path::new(PROC_ROOT),
Path::new(HYPRIDLE_EXECUTABLE),
desired,
)
.await
{
Ok(state) => state,
Err(error) => {
warn!("hypridle: setting active={desired} failed: {error}");
match active(Path::new(PROC_ROOT)).await {
Ok(state) => state,
Err(refresh_error) => {
warn!("hypridle: refreshing after command failed: {refresh_error}");
continue;
}
}
}
};
if updates.send(Msg::Hypridle(Hypridle::new(state))).is_err() {
return Ok(());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::fs;
use super::{HypridleAction, discover_hypridle_processes, required_action};
fn process(root: &std::path::Path, pid: i32, name: &str) {
let path = root.join(pid.to_string());
fs::create_dir(&path).unwrap();
fs::write(path.join("comm"), format!("{name}\n")).unwrap();
}
#[test]
fn discovery_matches_exact_same_user_process_names() {
let runtime = tokio::runtime::Runtime::new().unwrap();
let root = tempfile::tempdir().unwrap();
fs::create_dir(root.path().join("self")).unwrap();
process(root.path(), 42, "hypridle");
process(root.path(), 43, "hypridle-helper");
process(root.path(), 44, "Hypridle");
let pids = runtime
.block_on(discover_hypridle_processes(root.path()))
.unwrap();
assert_eq!(pids, vec![42]);
}
#[test]
fn discovery_ignores_non_process_entries_and_missing_comm_files() {
let runtime = tokio::runtime::Runtime::new().unwrap();
let root = tempfile::tempdir().unwrap();
fs::create_dir(root.path().join("self")).unwrap();
fs::create_dir(root.path().join("51")).unwrap();
process(root.path(), 52, "hypridle");
let pids = runtime
.block_on(discover_hypridle_processes(root.path()))
.unwrap();
assert_eq!(pids, vec![52]);
}
#[test]
fn desired_state_is_idempotent() {
assert_eq!(required_action(false, false), HypridleAction::None);
assert_eq!(required_action(true, true), HypridleAction::None);
assert_eq!(required_action(false, true), HypridleAction::Start);
assert_eq!(required_action(true, false), HypridleAction::Stop);
}
}