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::{Child, Command, ExitStatus, Stdio};
6use std::time::{Duration, Instant, UNIX_EPOCH};
7
8#[cfg(unix)]
9use libc;
10
11/// Upper bound for a theme provider run. A provider that hangs must not pin
12/// a daemon blocking thread forever.
13const THEME_TIMEOUT: Duration = Duration::from_secs(120);
14/// Upper bound for a user hook. Hooks commonly launch desktop utilities, so
15/// allow more than a provider but still bound the run.
16const HOOK_TIMEOUT: Duration = Duration::from_secs(60);
17/// Reload helpers should return quickly; a stuck `pkill`/shell is ignored
18/// after this deadline.
19const RELOAD_TIMEOUT: Duration = Duration::from_secs(15);
20
21#[derive(Debug, thiserror::Error)]
22pub enum ThemeError {
23    #[error("failed to spawn theme provider: {0}")]
24    SpawnError(#[from] std::io::Error),
25    #[error("theme provider exited with code {0}: {1}")]
26    NonZeroExit(i32, String),
27    #[error("command timed out after {0:?}: {1}")]
28    Timeout(Duration, String),
29}
30
31pub fn dispatch_theme(
32    provider: &ThemeProvider,
33    image_path: &Path,
34    matugen_config: &MatugenConfig,
35) -> Result<(), ThemeError> {
36    let effective_path = resolve_theme_image(image_path);
37    let effective_ref: &Path = effective_path.as_deref().unwrap_or(image_path);
38    match provider {
39        ThemeProvider::Matugen if !matugen_config.enabled => Ok(()),
40        ThemeProvider::Matugen => run_matugen(effective_ref, matugen_config),
41        ThemeProvider::Wallust => run_wallust(effective_ref),
42        ThemeProvider::Pywal => run_pywal(effective_ref),
43        ThemeProvider::None => Ok(()),
44    }
45}
46
47/// Returns a static image suitable for theme providers.
48///
49/// Video files (`mp4`, `webm`, `mkv`, `mov`, `avi`, `m4v`) and GIFs cannot be
50/// consumed directly by `matugen`/`wallust`/`pywal`. We extract the first
51/// frame to a cached PNG under `~/.cache/wallr/theme/` (hashed on source path
52/// + mtime) and return that path. On any error we log and fall back to the
53/// original path so wallpaper setting still succeeds.
54#[allow(clippy::doc_markdown, clippy::doc_lazy_continuation)]
55fn resolve_theme_image(image_path: &Path) -> Option<PathBuf> {
56    let ext = image_path
57        .extension()
58        .and_then(|e| e.to_str())
59        .map(|s| s.to_ascii_lowercase())
60        .unwrap_or_default();
61
62    let is_video = matches!(ext.as_str(), "mp4" | "webm" | "mkv" | "mov" | "avi" | "m4v");
63    let is_gif = ext == "gif";
64
65    if !is_video && !is_gif {
66        return None;
67    }
68
69    let cache_dir = dirs::cache_dir()
70        .unwrap_or_else(std::env::temp_dir)
71        .join("wallr")
72        .join("theme");
73
74    if let Err(e) = fs::create_dir_all(&cache_dir) {
75        tracing::warn!("theme frame cache dir create failed: {e}");
76        return None;
77    }
78
79    let key = match theme_cache_key(image_path) {
80        Ok(k) => k,
81        Err(e) => {
82            tracing::warn!("theme cache key failed for {}: {e}", image_path.display());
83            return None;
84        }
85    };
86
87    let dest = cache_dir.join(format!("{key}.png"));
88
89    // Reuse cached frame if it is newer than the source.
90    if dest.exists() {
91        if let (Ok(dest_meta), Ok(src_meta)) = (fs::metadata(&dest), fs::metadata(image_path)) {
92            if let (Ok(dest_mtime), Ok(src_mtime)) = (dest_meta.modified(), src_meta.modified()) {
93                if dest_mtime >= src_mtime {
94                    tracing::debug!("reusing cached theme frame {}", dest.display());
95                    return Some(dest);
96                }
97            } else {
98                return Some(dest);
99            }
100        } else {
101            return Some(dest);
102        }
103    }
104
105    let extract_res = if is_video {
106        extract_video_first_frame(image_path, &dest)
107    } else {
108        extract_gif_first_frame(image_path, &dest)
109    };
110
111    match extract_res {
112        Ok(()) => {
113            tracing::info!(
114                "extracted theme frame {} -> {}",
115                image_path.display(),
116                dest.display()
117            );
118            Some(dest)
119        }
120        Err(e) => {
121            tracing::warn!(
122                "failed to extract first frame from {}: {e}; falling back to original",
123                image_path.display()
124            );
125            None
126        }
127    }
128}
129
130fn theme_cache_key(path: &Path) -> Result<String, String> {
131    let meta = fs::metadata(path).map_err(|e| e.to_string())?;
132    let modified = meta.modified().map_err(|e| e.to_string())?;
133    let nanos = modified
134        .duration_since(UNIX_EPOCH)
135        .map_err(|e| e.to_string())?
136        .as_nanos();
137    let mut hasher = Sha256::new();
138    // Hash the OS string bytes rather than lossy UTF-8: distinct non-UTF-8
139    // paths must not share a cache entry.
140    hasher.update(path.as_os_str().as_encoded_bytes());
141    hasher.update(b"\0");
142    hasher.update(nanos.to_le_bytes());
143    hasher.update(b"\0");
144    hasher.update(meta.len().to_le_bytes());
145    Ok(hex::encode(hasher.finalize()))
146}
147
148fn extract_gif_first_frame(path: &Path, dest: &Path) -> Result<(), String> {
149    const MAX_THEME_GIF_BYTES: u64 = 256 * 1024 * 1024;
150    let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
151    if meta.len() > MAX_THEME_GIF_BYTES {
152        return Err(format!(
153            "GIF {} exceeds the {} MiB input limit",
154            path.display(),
155            MAX_THEME_GIF_BYTES / (1024 * 1024)
156        ));
157    }
158    let img = image::ImageReader::open(path)
159        .map_err(|e| e.to_string())?
160        .with_guessed_format()
161        .map_err(|e| e.to_string())?
162        .decode()
163        .map_err(|e| e.to_string())?;
164    if u64::from(img.width()) * u64::from(img.height()) > 4096 * 4096 {
165        return Err(format!(
166            "GIF dimensions {}x{} exceed the theme frame safety limit",
167            img.width(),
168            img.height()
169        ));
170    }
171    if let Some(parent) = dest.parent() {
172        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
173    }
174    let parent = dest
175        .parent()
176        .ok_or_else(|| "cache path has no parent".to_string())?;
177    fs::create_dir_all(parent).map_err(|e| e.to_string())?;
178    let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
179    img.save_with_format(tmp.path(), image::ImageFormat::Png)
180        .map_err(|e| e.to_string())?;
181    tmp.persist(dest).map_err(|e| e.error.to_string())?;
182    Ok(())
183}
184
185fn extract_video_first_frame(path: &Path, dest: &Path) -> Result<(), String> {
186    use ffmpeg_next as ffmpeg;
187
188    ffmpeg::init().map_err(|e| format!("ffmpeg init failed: {e}"))?;
189
190    let mut ictx = ffmpeg::format::input(path).map_err(|e| format!("open input: {e}"))?;
191
192    let stream = ictx
193        .streams()
194        .best(ffmpeg::media::Type::Video)
195        .ok_or_else(|| "no video stream".to_string())?;
196    let video_idx = stream.index();
197    let ctx = ffmpeg::codec::context::Context::from_parameters(stream.parameters())
198        .map_err(|e| format!("codec context: {e}"))?;
199    let mut decoder = ctx.decoder().video().map_err(|e| format!("decoder: {e}"))?;
200
201    let mut frame = ffmpeg::frame::Video::empty();
202    let mut rgb = ffmpeg::frame::Video::empty();
203    let mut scaler: Option<ffmpeg::software::scaling::context::Context> = None;
204
205    // Try to decode the first decodable frame
206    let mut found = false;
207    let mut packed: Vec<u8> = Vec::new();
208    let mut out_w: u32 = 0;
209    let mut out_h: u32 = 0;
210
211    'outer: for (s, packet) in ictx.packets() {
212        if s.index() != video_idx {
213            continue;
214        }
215        decoder
216            .send_packet(&packet)
217            .map_err(|e| format!("send_packet: {e}"))?;
218        if decoder.receive_frame(&mut frame).is_ok() {
219            let (w, h, data) = convert_frame_to_rgb24(&mut frame, &mut rgb, &mut scaler)?;
220            packed = data;
221            out_w = w;
222            out_h = h;
223            found = true;
224            break 'outer;
225        }
226    }
227
228    if !found {
229        // Flush decoder and try drained frames (e.g. single-frame video)
230        let _ = decoder.send_eof();
231        if decoder.receive_frame(&mut frame).is_ok() {
232            let (w, h, data) = convert_frame_to_rgb24(&mut frame, &mut rgb, &mut scaler)?;
233            packed = data;
234            out_w = w;
235            out_h = h;
236            found = true;
237        }
238    }
239
240    if !found {
241        return Err("no frame decoded from video".to_string());
242    }
243
244    let parent = dest
245        .parent()
246        .ok_or_else(|| "cache path has no parent".to_string())?;
247    fs::create_dir_all(parent).map_err(|e| e.to_string())?;
248
249    let img = image::RgbImage::from_raw(out_w, out_h, packed)
250        .ok_or_else(|| "failed to create image buffer".to_string())?;
251    let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
252    img.save_with_format(tmp.path(), image::ImageFormat::Png)
253        .map_err(|e| e.to_string())?;
254    tmp.persist(dest).map_err(|e| e.error.to_string())?;
255    Ok(())
256}
257
258fn convert_frame_to_rgb24(
259    frame: &mut ffmpeg_next::frame::Video,
260    rgb: &mut ffmpeg_next::frame::Video,
261    scaler: &mut Option<ffmpeg_next::software::scaling::context::Context>,
262) -> Result<(u32, u32, Vec<u8>), String> {
263    use ffmpeg_next as ffmpeg;
264    const MAX_THEME_FRAME_PIXELS: u64 = 4096 * 4096;
265    let w = frame.width();
266    let h = frame.height();
267    if w == 0 || h == 0 {
268        return Err("empty video frame".to_string());
269    }
270    if u64::from(w) * u64::from(h) > MAX_THEME_FRAME_PIXELS {
271        return Err(format!(
272            "theme frame {w}x{h} exceeds the {}-pixel safety limit",
273            MAX_THEME_FRAME_PIXELS
274        ));
275    }
276    let fmt = frame.format();
277    if scaler.is_none()
278        || scaler
279            .as_ref()
280            .map(|s| s.input().format != fmt || s.input().width != w || s.input().height != h)
281            .unwrap_or(true)
282    {
283        *scaler = Some(
284            ffmpeg::software::scaling::context::Context::get(
285                fmt,
286                w,
287                h,
288                ffmpeg::format::Pixel::RGB24,
289                w,
290                h,
291                ffmpeg::software::scaling::flag::Flags::BILINEAR,
292            )
293            .map_err(|e| format!("scaler init: {e}"))?,
294        );
295    }
296    scaler
297        .as_mut()
298        .unwrap()
299        .run(frame, rgb)
300        .map_err(|e| format!("scaler run: {e}"))?;
301
302    let stride = rgb.stride(0);
303    let row_bytes = (w as usize)
304        .checked_mul(3)
305        .ok_or_else(|| "theme frame width overflow".to_string())?;
306    let data = rgb.data(0);
307    let needed = row_bytes
308        .checked_mul(h as usize)
309        .ok_or_else(|| "theme frame size overflow".to_string())?;
310    let mut packed = Vec::with_capacity(needed);
311    for row in data.chunks(stride).take(h as usize) {
312        if row.len() < row_bytes {
313            return Err("theme frame row truncated".to_string());
314        }
315        packed.extend_from_slice(&row[..row_bytes]);
316    }
317    if packed.len() != needed {
318        return Err("theme frame row count mismatch".to_string());
319    }
320    Ok((w, h, packed))
321}
322
323fn run_matugen(image_path: &Path, config: &MatugenConfig) -> Result<(), ThemeError> {
324    let mut cmd = Command::new("matugen");
325    cmd.arg("image")
326        .arg(image_path)
327        .arg("--mode")
328        .arg(&config.mode)
329        .arg("--type")
330        .arg(&config.scheme)
331        .arg("--contrast")
332        .arg(config.contrast.to_string())
333        .arg("--source-color-index")
334        .arg("0")
335        .stdout(Stdio::null())
336        .stderr(Stdio::null());
337
338    for arg in &config.args {
339        cmd.arg(arg);
340    }
341
342    let mut child = cmd.spawn().map_err(ThemeError::SpawnError)?;
343
344    if config.wait {
345        let status = wait_with_timeout(&mut child, THEME_TIMEOUT, "matugen")?;
346        if !status.success() {
347            return Err(ThemeError::NonZeroExit(
348                status.code().unwrap_or(-1),
349                "matugen failed".to_string(),
350            ));
351        }
352    } else {
353        // `Child` does not reap on drop. A daemon is long-lived, so spawn a
354        // small reaper thread instead of accumulating zombie processes.
355        std::thread::spawn(move || reap_with_timeout(&mut child, THEME_TIMEOUT));
356    }
357
358    Ok(())
359}
360
361fn run_wallust(image_path: &Path) -> Result<(), ThemeError> {
362    let mut cmd = Command::new("wallust");
363    cmd.arg("run")
364        .arg(image_path)
365        .stdout(Stdio::null())
366        .stderr(Stdio::null());
367
368    let mut child = cmd.spawn().map_err(ThemeError::SpawnError)?;
369    let status = wait_with_timeout(&mut child, THEME_TIMEOUT, "wallust")?;
370    if !status.success() {
371        return Err(ThemeError::NonZeroExit(
372            status.code().unwrap_or(-1),
373            "wallust failed".to_string(),
374        ));
375    }
376
377    Ok(())
378}
379
380fn run_pywal(image_path: &Path) -> Result<(), ThemeError> {
381    let mut cmd = Command::new("wal");
382    cmd.arg("-i")
383        .arg(image_path)
384        .stdout(Stdio::null())
385        .stderr(Stdio::null());
386
387    let mut child = cmd.spawn().map_err(ThemeError::SpawnError)?;
388    let status = wait_with_timeout(&mut child, THEME_TIMEOUT, "pywal")?;
389    if !status.success() {
390        return Err(ThemeError::NonZeroExit(
391            status.code().unwrap_or(-1),
392            "pywal failed".to_string(),
393        ));
394    }
395
396    Ok(())
397}
398
399pub fn check_provider_available(provider: &ThemeProvider) -> bool {
400    let binary = match provider {
401        ThemeProvider::Matugen => "matugen",
402        ThemeProvider::Wallust => "wallust",
403        ThemeProvider::Pywal => "wal",
404        ThemeProvider::None => return true,
405    };
406
407    Command::new("which")
408        .arg(binary)
409        .stdout(Stdio::null())
410        .stderr(Stdio::null())
411        .status()
412        .map(|out| out.success())
413        .unwrap_or(false)
414}
415
416pub fn detect_matugen_loop_risk() -> Option<String> {
417    if let Ok(status) = fs::read_to_string("/proc/self/status")
418        && let Some(ppid_line) = status.lines().find(|l| l.starts_with("PPid:"))
419        && let Some(ppid) = ppid_line.split_whitespace().nth(1)
420    {
421        let cmdline_path = format!("/proc/{}/cmdline", ppid);
422        if let Ok(cmdline) = fs::read_to_string(&cmdline_path)
423            && cmdline.contains("matugen")
424        {
425            return Some("Detected matugen as parent process. This might cause an infinite loop if wallr is triggered by matugen.".to_string());
426        }
427    }
428    None
429}
430
431/// Runs hook commands sequentially. User hooks output is preserved.
432pub fn run_hooks(hooks: &[String]) -> Result<(), ThemeError> {
433    for hook in hooks {
434        let mut command = Command::new("sh");
435        command.arg("-c").arg(hook);
436        let mut child = command.spawn().map_err(ThemeError::SpawnError)?;
437        let status = wait_with_timeout(&mut child, HOOK_TIMEOUT, hook)?;
438
439        if !status.success() {
440            return Err(ThemeError::NonZeroExit(
441                status.code().unwrap_or(-1),
442                format!("hook failed: {}", hook),
443            ));
444        }
445    }
446    Ok(())
447}
448
449/// Runs reload commands, via shell or pkill quietly (swallows failure if app isn't running).
450pub fn run_reload_list(commands: &[String]) -> Result<(), ThemeError> {
451    for cmd in commands {
452        let mut command = if cmd.contains(' ') {
453            let mut command = Command::new("sh");
454            command.arg("-c").arg(cmd);
455            command
456        } else {
457            let mut command = Command::new("pkill");
458            command.arg("-SIGUSR2").arg(cmd);
459            command
460        };
461        command.stdout(Stdio::null()).stderr(Stdio::null());
462        if let Ok(mut child) = command.spawn() {
463            let _ = wait_with_timeout(&mut child, RELOAD_TIMEOUT, cmd);
464        }
465    }
466    Ok(())
467}
468
469fn wait_with_timeout(
470    child: &mut Child,
471    timeout: Duration,
472    label: &str,
473) -> Result<ExitStatus, ThemeError> {
474    let started = Instant::now();
475    // Put the child in its own process group so descendants (sh -c, matugen's
476    // subprocesses, etc.) are terminated alongside it on timeout instead of
477    // orphaning into the daemon's session.
478    #[cfg(unix)]
479    {
480        // put in own process group (best effort)
481        unsafe {
482            libc::setpgid(child.id() as i32, child.id() as i32);
483        }
484    }
485    loop {
486        match child.try_wait() {
487            Ok(Some(status)) => return Ok(status),
488            Ok(None) if started.elapsed() >= timeout => {
489                #[cfg(unix)]
490                {
491                    // Kill the whole process group so descendants cannot survive.
492                    unsafe {
493                        libc::killpg(child.id() as i32, libc::SIGKILL);
494                    }
495                }
496                let _ = child.kill();
497                let _ = child.wait();
498                return Err(ThemeError::Timeout(timeout, label.to_string()));
499            }
500            Ok(None) => std::thread::sleep(Duration::from_millis(10)),
501            Err(err) => return Err(ThemeError::SpawnError(err)),
502        }
503    }
504}
505
506fn reap_with_timeout(child: &mut Child, timeout: Duration) {
507    let _ = wait_with_timeout(child, timeout, "detached theme provider");
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use image::GenericImageView;
514
515    #[test]
516    fn test_check_provider_available_none() {
517        assert!(check_provider_available(&ThemeProvider::None));
518    }
519
520    #[test]
521    fn test_dispatch_none() {
522        let matugen_cfg = MatugenConfig {
523            enabled: false,
524            mode: "dark".to_string(),
525            scheme: "scheme-tonal-spot".to_string(),
526            contrast: 0,
527            wait: false,
528            args: vec![],
529        };
530        let res = dispatch_theme(&ThemeProvider::None, Path::new("test.jpg"), &matugen_cfg);
531        assert!(res.is_ok());
532    }
533
534    #[test]
535    fn test_run_hooks_empty() {
536        let res = run_hooks(&[]);
537        assert!(res.is_ok());
538    }
539
540    #[test]
541    fn test_detect_loop_risk() {
542        // Should not detect a loop when wallr is not invoked by matugen
543        assert!(detect_matugen_loop_risk().is_none());
544    }
545
546    #[test]
547    fn cache_key_uses_subsecond_mtime_and_size() {
548        let dir = tempfile::tempdir().unwrap();
549        let path = dir.path().join("wall.gif");
550        std::fs::write(&path, b"one").unwrap();
551        let first = theme_cache_key(&path).unwrap();
552        let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
553        file.set_modified(UNIX_EPOCH + Duration::from_millis(1_000))
554            .unwrap();
555        let second = theme_cache_key(&path).unwrap();
556        assert_ne!(first, second, "sub-second mtime changes must invalidate");
557
558        file.set_modified(UNIX_EPOCH + Duration::from_millis(1_500))
559            .unwrap();
560        std::fs::write(&path, b"two").unwrap();
561        let third = theme_cache_key(&path).unwrap();
562        assert_ne!(second, third, "size changes must invalidate");
563    }
564
565    #[test]
566    fn animated_wallpapers_resolve_to_cached_theme_frames() {
567        let dir = tempfile::tempdir().unwrap();
568        let path = dir.path().join("animated.gif");
569        let mut bytes = Vec::new();
570        {
571            let palette = [0, 0, 0, 255, 0, 0];
572            let mut encoder = gif::Encoder::new(&mut bytes, 1, 1, &palette).unwrap();
573            encoder.set_repeat(gif::Repeat::Infinite).unwrap();
574            let mut frame = gif::Frame::from_indexed_pixels(1, 1, [1], None);
575            frame.delay = 5;
576            encoder.write_frame(&frame).unwrap();
577        }
578        std::fs::write(&path, bytes).unwrap();
579
580        let cached = resolve_theme_image(&path).expect("gif should produce a theme frame");
581        assert!(cached.is_file());
582        assert_eq!(image::open(cached).unwrap().dimensions(), (1, 1));
583    }
584
585    #[test]
586    fn hung_commands_are_killed_and_reported() {
587        let mut child = Command::new("sh").arg("-c").arg("sleep 5").spawn().unwrap();
588        let started = Instant::now();
589        let result = wait_with_timeout(&mut child, Duration::from_millis(50), "test");
590        assert!(matches!(result, Err(ThemeError::Timeout(..))));
591        assert!(started.elapsed() < Duration::from_secs(2));
592    }
593}