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
11const THEME_TIMEOUT: Duration = Duration::from_secs(120);
14const HOOK_TIMEOUT: Duration = Duration::from_secs(60);
17const 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#[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 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 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 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 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 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
431pub 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
449pub 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 #[cfg(unix)]
479 {
480 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 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 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}