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