Skip to main content

wallr_core/theme/
mod.rs

1use crate::config::{MatugenConfig, ThemeProvider};
2use sha2::{Digest, Sha256};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::time::UNIX_EPOCH;
7
8#[derive(Debug, thiserror::Error)]
9pub enum ThemeError {
10    #[error("failed to spawn theme provider: {0}")]
11    SpawnError(#[from] std::io::Error),
12    #[error("theme provider exited with code {0}: {1}")]
13    NonZeroExit(i32, String),
14}
15
16pub fn dispatch_theme(
17    provider: &ThemeProvider,
18    image_path: &Path,
19    matugen_config: &MatugenConfig,
20) -> Result<(), ThemeError> {
21    let effective_path = resolve_theme_image(image_path);
22    let effective_ref: &Path = effective_path.as_deref().unwrap_or(image_path);
23    match provider {
24        ThemeProvider::Matugen if !matugen_config.enabled => Ok(()),
25        ThemeProvider::Matugen => run_matugen(effective_ref, matugen_config),
26        ThemeProvider::Wallust => run_wallust(effective_ref),
27        ThemeProvider::Pywal => run_pywal(effective_ref),
28        ThemeProvider::None => Ok(()),
29    }
30}
31
32/// Returns a static image suitable for theme providers.
33///
34/// Video files (`mp4`, `webm`, `mkv`, `mov`, `avi`, `m4v`) and GIFs cannot be
35/// consumed directly by `matugen`/`wallust`/`pywal`. We extract the first
36/// frame to a cached PNG under `~/.cache/wallr/theme/` (hashed on source path
37/// + mtime) and return that path. On any error we log and fall back to the
38/// original path so wallpaper setting still succeeds.
39#[allow(clippy::doc_markdown, clippy::doc_lazy_continuation)]
40fn resolve_theme_image(image_path: &Path) -> Option<PathBuf> {
41    let ext = image_path
42        .extension()
43        .and_then(|e| e.to_str())
44        .map(|s| s.to_ascii_lowercase())
45        .unwrap_or_default();
46
47    let is_video = matches!(ext.as_str(), "mp4" | "webm" | "mkv" | "mov" | "avi" | "m4v");
48    let is_gif = ext == "gif";
49
50    if !is_video && !is_gif {
51        return None;
52    }
53
54    let cache_dir = dirs::cache_dir()
55        .unwrap_or_else(std::env::temp_dir)
56        .join("wallr")
57        .join("theme");
58
59    if let Err(e) = fs::create_dir_all(&cache_dir) {
60        tracing::warn!("theme frame cache dir create failed: {e}");
61        return None;
62    }
63
64    let key = match theme_cache_key(image_path) {
65        Ok(k) => k,
66        Err(e) => {
67            tracing::warn!("theme cache key failed for {}: {e}", image_path.display());
68            return None;
69        }
70    };
71
72    let dest = cache_dir.join(format!("{key}.png"));
73
74    // Reuse cached frame if it is newer than the source.
75    if dest.exists() {
76        if let (Ok(dest_meta), Ok(src_meta)) = (fs::metadata(&dest), fs::metadata(image_path)) {
77            if let (Ok(dest_mtime), Ok(src_mtime)) = (dest_meta.modified(), src_meta.modified()) {
78                if dest_mtime >= src_mtime {
79                    tracing::debug!("reusing cached theme frame {}", dest.display());
80                    return Some(dest);
81                }
82            } else {
83                return Some(dest);
84            }
85        } else {
86            return Some(dest);
87        }
88    }
89
90    let extract_res = if is_video {
91        extract_video_first_frame(image_path, &dest)
92    } else {
93        extract_gif_first_frame(image_path, &dest)
94    };
95
96    match extract_res {
97        Ok(()) => {
98            tracing::info!(
99                "extracted theme frame {} -> {}",
100                image_path.display(),
101                dest.display()
102            );
103            Some(dest)
104        }
105        Err(e) => {
106            tracing::warn!(
107                "failed to extract first frame from {}: {e}; falling back to original",
108                image_path.display()
109            );
110            None
111        }
112    }
113}
114
115fn theme_cache_key(path: &Path) -> Result<String, String> {
116    let meta = fs::metadata(path).map_err(|e| e.to_string())?;
117    let mtime = meta
118        .modified()
119        .map_err(|e| e.to_string())?
120        .duration_since(UNIX_EPOCH)
121        .unwrap_or_default()
122        .as_secs();
123    let mut hasher = Sha256::new();
124    hasher.update(path.to_string_lossy().as_bytes());
125    hasher.update(mtime.to_string().as_bytes());
126    Ok(hex::encode(hasher.finalize()))
127}
128
129fn extract_gif_first_frame(path: &Path, dest: &Path) -> Result<(), String> {
130    let img = image::ImageReader::open(path)
131        .map_err(|e| e.to_string())?
132        .with_guessed_format()
133        .map_err(|e| e.to_string())?
134        .decode()
135        .map_err(|e| e.to_string())?;
136
137    if let Some(parent) = dest.parent() {
138        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
139    }
140
141    // Write to a temp file then atomically rename to avoid partial writes
142    let tmp = dest.with_extension("tmp.png");
143    img.save_with_format(&tmp, image::ImageFormat::Png)
144        .map_err(|e| e.to_string())?;
145    fs::rename(&tmp, dest).map_err(|e| e.to_string())?;
146    Ok(())
147}
148
149fn extract_video_first_frame(path: &Path, dest: &Path) -> Result<(), String> {
150    use ffmpeg_next as ffmpeg;
151
152    ffmpeg::init().map_err(|e| format!("ffmpeg init failed: {e}"))?;
153
154    let mut ictx = ffmpeg::format::input(path).map_err(|e| format!("open input: {e}"))?;
155
156    let stream = ictx
157        .streams()
158        .best(ffmpeg::media::Type::Video)
159        .ok_or_else(|| "no video stream".to_string())?;
160    let video_idx = stream.index();
161    let ctx = ffmpeg::codec::context::Context::from_parameters(stream.parameters())
162        .map_err(|e| format!("codec context: {e}"))?;
163    let mut decoder = ctx.decoder().video().map_err(|e| format!("decoder: {e}"))?;
164
165    let mut frame = ffmpeg::frame::Video::empty();
166    let mut rgb = ffmpeg::frame::Video::empty();
167    let mut scaler: Option<ffmpeg::software::scaling::context::Context> = None;
168
169    // Try to decode the first decodable frame
170    let mut found = false;
171    let mut packed: Vec<u8> = Vec::new();
172    let mut out_w: u32 = 0;
173    let mut out_h: u32 = 0;
174
175    'outer: for (s, packet) in ictx.packets() {
176        if s.index() != video_idx {
177            continue;
178        }
179        decoder
180            .send_packet(&packet)
181            .map_err(|e| format!("send_packet: {e}"))?;
182        if decoder.receive_frame(&mut frame).is_ok() {
183            let (w, h, data) = convert_frame_to_rgb24(&mut frame, &mut rgb, &mut scaler)?;
184            packed = data;
185            out_w = w;
186            out_h = h;
187            found = true;
188            break 'outer;
189        }
190    }
191
192    if !found {
193        // Flush decoder and try drained frames (e.g. single-frame video)
194        let _ = decoder.send_eof();
195        if decoder.receive_frame(&mut frame).is_ok() {
196            let (w, h, data) = convert_frame_to_rgb24(&mut frame, &mut rgb, &mut scaler)?;
197            packed = data;
198            out_w = w;
199            out_h = h;
200            found = true;
201        }
202    }
203
204    if !found {
205        return Err("no frame decoded from video".to_string());
206    }
207
208    if let Some(parent) = dest.parent() {
209        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
210    }
211
212    let img = image::RgbImage::from_raw(out_w, out_h, packed)
213        .ok_or_else(|| "failed to create image buffer".to_string())?;
214    let tmp = dest.with_extension("tmp.png");
215    img.save_with_format(&tmp, image::ImageFormat::Png)
216        .map_err(|e| e.to_string())?;
217    fs::rename(&tmp, dest).map_err(|e| e.to_string())?;
218    Ok(())
219}
220
221fn convert_frame_to_rgb24(
222    frame: &mut ffmpeg_next::frame::Video,
223    rgb: &mut ffmpeg_next::frame::Video,
224    scaler: &mut Option<ffmpeg_next::software::scaling::context::Context>,
225) -> Result<(u32, u32, Vec<u8>), String> {
226    use ffmpeg_next as ffmpeg;
227    let w = frame.width();
228    let h = frame.height();
229    let fmt = frame.format();
230    if scaler.is_none()
231        || scaler
232            .as_ref()
233            .map(|s| s.input().format != fmt || s.input().width != w || s.input().height != h)
234            .unwrap_or(true)
235    {
236        *scaler = Some(
237            ffmpeg::software::scaling::context::Context::get(
238                fmt,
239                w,
240                h,
241                ffmpeg::format::Pixel::RGB24,
242                w,
243                h,
244                ffmpeg::software::scaling::flag::Flags::BILINEAR,
245            )
246            .map_err(|e| format!("scaler init: {e}"))?,
247        );
248    }
249    scaler
250        .as_mut()
251        .unwrap()
252        .run(frame, rgb)
253        .map_err(|e| format!("scaler run: {e}"))?;
254
255    let stride = rgb.stride(0);
256    let row_bytes = w as usize * 3;
257    let data = rgb.data(0);
258    let mut packed = Vec::with_capacity(row_bytes * h as usize);
259    for row in data.chunks(stride).take(h as usize) {
260        packed.extend_from_slice(&row[..row_bytes]);
261    }
262    Ok((w, h, packed))
263}
264
265fn run_matugen(image_path: &Path, config: &MatugenConfig) -> Result<(), ThemeError> {
266    let mut cmd = Command::new("matugen");
267    cmd.arg("image")
268        .arg(image_path)
269        .arg("--mode")
270        .arg(&config.mode)
271        .arg("--type")
272        .arg(&config.scheme)
273        .arg("--contrast")
274        .arg(config.contrast.to_string())
275        .arg("--source-color-index")
276        .arg("0")
277        .stdout(Stdio::null())
278        .stderr(Stdio::null());
279
280    for arg in &config.args {
281        cmd.arg(arg);
282    }
283
284    let mut child = cmd.spawn().map_err(ThemeError::SpawnError)?;
285
286    if config.wait {
287        let status = child.wait().map_err(ThemeError::SpawnError)?;
288        if !status.success() {
289            return Err(ThemeError::NonZeroExit(
290                status.code().unwrap_or(-1),
291                "matugen failed".to_string(),
292            ));
293        }
294    }
295
296    Ok(())
297}
298
299fn run_wallust(image_path: &Path) -> Result<(), ThemeError> {
300    let mut cmd = Command::new("wallust");
301    cmd.arg("run")
302        .arg(image_path)
303        .stdout(Stdio::null())
304        .stderr(Stdio::null());
305
306    let status = cmd.status().map_err(ThemeError::SpawnError)?;
307    if !status.success() {
308        return Err(ThemeError::NonZeroExit(
309            status.code().unwrap_or(-1),
310            "wallust failed".to_string(),
311        ));
312    }
313
314    Ok(())
315}
316
317fn run_pywal(image_path: &Path) -> Result<(), ThemeError> {
318    let mut cmd = Command::new("wal");
319    cmd.arg("-i")
320        .arg(image_path)
321        .stdout(Stdio::null())
322        .stderr(Stdio::null());
323
324    let status = cmd.status().map_err(ThemeError::SpawnError)?;
325    if !status.success() {
326        return Err(ThemeError::NonZeroExit(
327            status.code().unwrap_or(-1),
328            "pywal failed".to_string(),
329        ));
330    }
331
332    Ok(())
333}
334
335pub fn check_provider_available(provider: &ThemeProvider) -> bool {
336    let binary = match provider {
337        ThemeProvider::Matugen => "matugen",
338        ThemeProvider::Wallust => "wallust",
339        ThemeProvider::Pywal => "wal",
340        ThemeProvider::None => return true,
341    };
342
343    Command::new("which")
344        .arg(binary)
345        .stdout(Stdio::null())
346        .stderr(Stdio::null())
347        .status()
348        .map(|out| out.success())
349        .unwrap_or(false)
350}
351
352pub fn detect_matugen_loop_risk() -> Option<String> {
353    if let Ok(status) = fs::read_to_string("/proc/self/status")
354        && let Some(ppid_line) = status.lines().find(|l| l.starts_with("PPid:"))
355        && let Some(ppid) = ppid_line.split_whitespace().nth(1)
356    {
357        let cmdline_path = format!("/proc/{}/cmdline", ppid);
358        if let Ok(cmdline) = fs::read_to_string(&cmdline_path)
359            && cmdline.contains("matugen")
360        {
361            return Some("Detected matugen as parent process. This might cause an infinite loop if wallr is triggered by matugen.".to_string());
362        }
363    }
364    None
365}
366
367/// Runs hook commands sequentially. User hooks output is preserved.
368pub fn run_hooks(hooks: &[String]) -> Result<(), ThemeError> {
369    for hook in hooks {
370        let status = Command::new("sh")
371            .arg("-c")
372            .arg(hook)
373            .status()
374            .map_err(ThemeError::SpawnError)?;
375
376        if !status.success() {
377            return Err(ThemeError::NonZeroExit(
378                status.code().unwrap_or(-1),
379                format!("hook failed: {}", hook),
380            ));
381        }
382    }
383    Ok(())
384}
385
386/// Runs reload commands, via shell or pkill quietly (swallows failure if app isn't running).
387pub fn run_reload_list(commands: &[String]) -> Result<(), ThemeError> {
388    for cmd in commands {
389        if cmd.contains(' ') {
390            let _ = Command::new("sh")
391                .arg("-c")
392                .arg(cmd)
393                .stdout(Stdio::null())
394                .stderr(Stdio::null())
395                .status();
396        } else {
397            let _ = Command::new("pkill")
398                .arg("-SIGUSR2")
399                .arg(cmd)
400                .stdout(Stdio::null())
401                .stderr(Stdio::null())
402                .status();
403        }
404    }
405    Ok(())
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn test_check_provider_available_none() {
414        assert!(check_provider_available(&ThemeProvider::None));
415    }
416
417    #[test]
418    fn test_dispatch_none() {
419        let matugen_cfg = MatugenConfig {
420            enabled: false,
421            mode: "dark".to_string(),
422            scheme: "scheme-tonal-spot".to_string(),
423            contrast: 0,
424            wait: false,
425            args: vec![],
426        };
427        let res = dispatch_theme(&ThemeProvider::None, Path::new("test.jpg"), &matugen_cfg);
428        assert!(res.is_ok());
429    }
430
431    #[test]
432    fn test_run_hooks_empty() {
433        let res = run_hooks(&[]);
434        assert!(res.is_ok());
435    }
436
437    #[test]
438    fn test_detect_loop_risk() {
439        // Should not detect a loop when wallr is not invoked by matugen
440        assert!(detect_matugen_loop_risk().is_none());
441    }
442}