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#[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 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 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 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 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
367pub 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
386pub 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 assert!(detect_matugen_loop_risk().is_none());
441 }
442}