timewall 1.5.0

All-in-one tool for Apple dynamic HEIF wallpapers on GNU/Linux
use nix::errno::Errno;
use nix::sys::signal::Signal;
use nix::{sys::signal::kill, unistd::Pid};
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::Duration;
use std::{env, thread};

use anyhow::{anyhow, Context, Result};
use itertools::Itertools;
use log::debug;
use wallpape_rs as wallpaper;

use crate::config::{Config, Setter};
use crate::pidfile::SetterPidFile;

/// Set wallpaper to the image pointed by a given path. Use custom command if provided.
pub fn set_wallpaper<P: AsRef<Path>>(path: P, maybe_setter_config: Option<&Setter>) -> Result<()> {
    let setter = get_setter();
    if let Some(setter_config) = maybe_setter_config {
        setter.set_wallpaper_custom_command(path.as_ref(), setter_config)
    } else {
        setter.set_wallpaper(path.as_ref())
    }
}

/// Unset wallpaper, if any.
///
/// This will only work if tme wallpaper is set using a custom, long-running command.
/// Usetting will terminate the process.
pub fn unset_wallpaper() -> Result<bool> {
    let setter = get_setter();
    setter.cleanup()
}

fn get_setter() -> Box<dyn WallpaperSetter> {
    match env::var("TIMEWALL_DRY_RUN") {
        Err(_) => Box::new(DefaultSetter {}),
        Ok(_) => Box::new(DryRunSetter {}),
    }
}

trait WallpaperSetter {
    fn set_wallpaper(&self, path: &Path) -> Result<()>;
    fn set_wallpaper_custom_command(&self, path: &Path, setter_config: &Setter) -> Result<()>;
    fn cleanup(&self) -> Result<bool>;
}

/// Real, actual wallpaper setter.
struct DefaultSetter {}
impl WallpaperSetter for DefaultSetter {
    fn set_wallpaper(&self, path: &Path) -> Result<()> {
        let abs_path = path.canonicalize()?;

        wallpaper::set_from_path(abs_path.to_str().unwrap()).map_err(|err| {
            anyhow!(format!(
                concat!(
                    "Automated wallpaper setting failed: {}\n",
                    "This is most likely caused by an unsupported DE or WM.\n",
                    "Please configure a custom wallpaper setting command in the config file.\n",
                    "You can find it at {}"
                ),
                err,
                Config::find_path().unwrap().display()
            ))
        })
    }

    fn set_wallpaper_custom_command(&self, path: &Path, setter_config: &Setter) -> Result<()> {
        let path_str = path.to_str().unwrap();
        let expended_command = expand_command(&setter_config.command, path_str);
        let mut command = expended_command.iter();

        let mut process_command = Command::new(command.next().unwrap());
        process_command.args(command);
        debug!("running custom command: {process_command:?}");

        let wallpaper_process = process_command
            .stdout(make_output_handle(setter_config.quiet))
            .stderr(make_output_handle(setter_config.quiet))
            .spawn()
            .with_context(|| "failed to run custom command")?;

        thread::sleep(Duration::from_millis(setter_config.overlap));
        self.cleanup()?;
        SetterPidFile::find().save(wallpaper_process.id());

        Ok(())
    }

    fn cleanup(&self) -> Result<bool> {
        let pidfile = SetterPidFile::find();
        if let Some(last_pid) = pidfile.read() {
            let did_terminate = terminate_process_if_exists(last_pid)
                .context("failed to cleanup setter process")?;
            pidfile.clear();
            Ok(did_terminate)
        } else {
            Ok(false)
        }
    }
}

/// Dry run setter, mainly for use in tests.
/// Instead of actually setting the wallpaper, prints out the actions.
struct DryRunSetter;
impl WallpaperSetter for DryRunSetter {
    fn set_wallpaper(&self, path: &Path) -> Result<()> {
        println!("Set: {}", path.display());
        Ok(())
    }

    fn set_wallpaper_custom_command(&self, path: &Path, setter_config: &Setter) -> Result<()> {
        let expanded_command = expand_command(&setter_config.command, path.to_str().unwrap());
        println!("Run: {}", expanded_command.join(" "));
        Ok(())
    }

    fn cleanup(&self) -> Result<bool> {
        Ok(false)
    }
}

fn make_output_handle(quiet: bool) -> Stdio {
    if quiet {
        Stdio::null()
    } else {
        Stdio::inherit()
    }
}

/// Replace '%f' in command with file path.
fn expand_command(command_str: &[String], path_str: &str) -> Vec<String> {
    command_str
        .iter()
        .map(|item| item.replace("%f", path_str))
        .collect_vec()
}

fn terminate_process_if_exists(pid: u32) -> Result<bool> {
    debug!("Sending SIGTERM to process: {pid}");
    #[allow(clippy::cast_possible_wrap, reason = "std uses u32 because of windows")]
    let pid = Pid::from_raw(pid as i32);
    match kill(pid, Signal::SIGTERM) {
        Ok(()) => Ok(true),
        Err(Errno::ESRCH) => Ok(false),
        Err(errno) => Err(anyhow!("Failed to SIGTERM process: {pid}, errno: {errno}")),
    }
}