Skip to main content

wallr_core/theme/
mod.rs

1use crate::config::{MatugenConfig, ThemeProvider};
2use std::fs;
3use std::path::Path;
4use std::process::{Command, Stdio};
5
6#[derive(Debug, thiserror::Error)]
7pub enum ThemeError {
8    #[error("failed to spawn theme provider: {0}")]
9    SpawnError(#[from] std::io::Error),
10    #[error("theme provider exited with code {0}: {1}")]
11    NonZeroExit(i32, String),
12}
13
14pub fn dispatch_theme(
15    provider: &ThemeProvider,
16    image_path: &Path,
17    matugen_config: &MatugenConfig,
18) -> Result<(), ThemeError> {
19    match provider {
20        ThemeProvider::Matugen if !matugen_config.enabled => Ok(()),
21        ThemeProvider::Matugen => run_matugen(image_path, matugen_config),
22        ThemeProvider::Wallust => run_wallust(image_path),
23        ThemeProvider::Pywal => run_pywal(image_path),
24        ThemeProvider::None => Ok(()),
25    }
26}
27
28fn run_matugen(image_path: &Path, config: &MatugenConfig) -> Result<(), ThemeError> {
29    let mut cmd = Command::new("matugen");
30    cmd.arg("image")
31        .arg(image_path)
32        .arg("--mode")
33        .arg(&config.mode)
34        .arg("--type")
35        .arg(&config.scheme)
36        .arg("--contrast")
37        .arg(config.contrast.to_string())
38        .arg("--source-color-index")
39        .arg("0")
40        .stdout(Stdio::null())
41        .stderr(Stdio::null());
42
43    for arg in &config.args {
44        cmd.arg(arg);
45    }
46
47    let mut child = cmd.spawn().map_err(ThemeError::SpawnError)?;
48
49    if config.wait {
50        let status = child.wait().map_err(ThemeError::SpawnError)?;
51        if !status.success() {
52            return Err(ThemeError::NonZeroExit(
53                status.code().unwrap_or(-1),
54                "matugen failed".to_string(),
55            ));
56        }
57    }
58
59    Ok(())
60}
61
62fn run_wallust(image_path: &Path) -> Result<(), ThemeError> {
63    let mut cmd = Command::new("wallust");
64    cmd.arg("run")
65        .arg(image_path)
66        .stdout(Stdio::null())
67        .stderr(Stdio::null());
68
69    let status = cmd.status().map_err(ThemeError::SpawnError)?;
70    if !status.success() {
71        return Err(ThemeError::NonZeroExit(
72            status.code().unwrap_or(-1),
73            "wallust failed".to_string(),
74        ));
75    }
76
77    Ok(())
78}
79
80fn run_pywal(image_path: &Path) -> Result<(), ThemeError> {
81    let mut cmd = Command::new("wal");
82    cmd.arg("-i")
83        .arg(image_path)
84        .stdout(Stdio::null())
85        .stderr(Stdio::null());
86
87    let status = cmd.status().map_err(ThemeError::SpawnError)?;
88    if !status.success() {
89        return Err(ThemeError::NonZeroExit(
90            status.code().unwrap_or(-1),
91            "pywal failed".to_string(),
92        ));
93    }
94
95    Ok(())
96}
97
98pub fn check_provider_available(provider: &ThemeProvider) -> bool {
99    let binary = match provider {
100        ThemeProvider::Matugen => "matugen",
101        ThemeProvider::Wallust => "wallust",
102        ThemeProvider::Pywal => "wal",
103        ThemeProvider::None => return true,
104    };
105
106    Command::new("which")
107        .arg(binary)
108        .stdout(Stdio::null())
109        .stderr(Stdio::null())
110        .status()
111        .map(|out| out.success())
112        .unwrap_or(false)
113}
114
115pub fn detect_matugen_loop_risk() -> Option<String> {
116    if let Ok(status) = fs::read_to_string("/proc/self/status")
117        && let Some(ppid_line) = status.lines().find(|l| l.starts_with("PPid:"))
118        && let Some(ppid) = ppid_line.split_whitespace().nth(1)
119    {
120        let cmdline_path = format!("/proc/{}/cmdline", ppid);
121        if let Ok(cmdline) = fs::read_to_string(&cmdline_path)
122            && cmdline.contains("matugen")
123        {
124            return Some("Detected matugen as parent process. This might cause an infinite loop if wallr is triggered by matugen.".to_string());
125        }
126    }
127    None
128}
129
130/// Runs hook commands sequentially. User hooks output is preserved.
131pub fn run_hooks(hooks: &[String]) -> Result<(), ThemeError> {
132    for hook in hooks {
133        let status = Command::new("sh")
134            .arg("-c")
135            .arg(hook)
136            .status()
137            .map_err(ThemeError::SpawnError)?;
138
139        if !status.success() {
140            return Err(ThemeError::NonZeroExit(
141                status.code().unwrap_or(-1),
142                format!("hook failed: {}", hook),
143            ));
144        }
145    }
146    Ok(())
147}
148
149/// Runs reload commands, via shell or pkill quietly (swallows failure if app isn't running).
150pub fn run_reload_list(commands: &[String]) -> Result<(), ThemeError> {
151    for cmd in commands {
152        if cmd.contains(' ') {
153            let _ = Command::new("sh")
154                .arg("-c")
155                .arg(cmd)
156                .stdout(Stdio::null())
157                .stderr(Stdio::null())
158                .status();
159        } else {
160            let _ = Command::new("pkill")
161                .arg("-SIGUSR2")
162                .arg(cmd)
163                .stdout(Stdio::null())
164                .stderr(Stdio::null())
165                .status();
166        }
167    }
168    Ok(())
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_check_provider_available_none() {
177        assert!(check_provider_available(&ThemeProvider::None));
178    }
179
180    #[test]
181    fn test_dispatch_none() {
182        let matugen_cfg = MatugenConfig {
183            enabled: false,
184            mode: "dark".to_string(),
185            scheme: "scheme-tonal-spot".to_string(),
186            contrast: 0,
187            wait: false,
188            args: vec![],
189        };
190        let res = dispatch_theme(&ThemeProvider::None, Path::new("test.jpg"), &matugen_cfg);
191        assert!(res.is_ok());
192    }
193
194    #[test]
195    fn test_run_hooks_empty() {
196        let res = run_hooks(&[]);
197        assert!(res.is_ok());
198    }
199
200    #[test]
201    fn test_detect_loop_risk() {
202        // Should not detect a loop when wallr is not invoked by matugen
203        assert!(detect_matugen_loop_risk().is_none());
204    }
205}