Rust · 38641 bytes Raw Blame History
1 //! Daemon state management
2
3 use anyhow::{Context, Result};
4 use std::collections::HashMap;
5 use std::time::Duration;
6 use tokio::net::UnixStream;
7 use tokio::signal::unix::{signal, SignalKind};
8
9 use crate::cache::DiskCache;
10 use crate::config::{Config, ScaleMode};
11 use crate::ipc::{Command, GarEvent, GarIpcClient, IpcServer, Response};
12 use crate::ipc::server::IpcClient;
13 use crate::media::{scale_image, AnimatedGif, AnimatedPng, AnimatedWebP, AnimationFrame, ImageLoader};
14 #[cfg(feature = "video")]
15 use crate::media::{VideoDecoder, is_video_file};
16 use crate::state::{detect_source_type, PlaylistState};
17 use crate::x11::{AnimationRenderer, Connection};
18
19 use super::pid;
20
21 /// Current wallpaper state for a monitor
22 #[derive(Debug, Clone)]
23 pub struct MonitorWallpaper {
24 /// Monitor name
25 pub name: String,
26 /// Current wallpaper source
27 pub source: String,
28 /// Scale mode
29 pub mode: ScaleMode,
30 }
31
32 /// Main daemon state
33 pub struct DaemonState {
34 /// Current wallpaper per monitor
35 pub monitors: HashMap<String, MonitorWallpaper>,
36
37 /// Current workspace
38 pub current_workspace: usize,
39
40 /// Whether slideshow/animations are paused
41 pub paused: bool,
42
43 /// Configuration
44 pub config: Config,
45
46 /// Current playlist state (if any)
47 pub playlist: Option<PlaylistState>,
48
49 /// Current slideshow interval (None = no auto-rotation)
50 pub slideshow_interval: Option<Duration>,
51 }
52
53 impl DaemonState {
54 pub fn new(config: Config) -> Self {
55 // Try to load existing playlist state
56 let playlist = PlaylistState::load().ok().flatten();
57
58 // Get initial slideshow interval from config
59 let slideshow_interval = config.default.slideshow
60 .as_ref()
61 .filter(|s| s.enabled)
62 .map(|s| s.interval);
63
64 Self {
65 monitors: HashMap::new(),
66 current_workspace: 1,
67 paused: false,
68 config,
69 playlist,
70 slideshow_interval,
71 }
72 }
73 }
74
75 /// Active animation state (works with GIF, WebP, or other animated formats)
76 pub struct ActiveAnimation {
77 /// Pre-scaled frames for quick rendering
78 scaled_frames: Vec<image::RgbaImage>,
79 /// Frame delays (parallel to scaled_frames)
80 frame_delays: Vec<Duration>,
81 /// Animation renderer (double-buffered)
82 renderer: AnimationRenderer,
83 /// Current frame index
84 current_frame: usize,
85 /// Max FPS
86 max_fps: u32,
87 /// Scale mode (for status)
88 #[allow(dead_code)]
89 scale_mode: ScaleMode,
90 /// Source URI (for status)
91 #[allow(dead_code)]
92 source: String,
93 }
94
95 impl ActiveAnimation {
96 /// Create from animation frames
97 fn from_frames(
98 frames: &[AnimationFrame],
99 renderer: AnimationRenderer,
100 max_fps: u32,
101 scale_mode: ScaleMode,
102 source: String,
103 screen_width: u32,
104 screen_height: u32,
105 ) -> Self {
106 let scaled_frames: Vec<image::RgbaImage> = frames
107 .iter()
108 .map(|frame| scale_image(&frame.image, screen_width, screen_height, scale_mode))
109 .collect();
110
111 let frame_delays: Vec<Duration> = frames
112 .iter()
113 .map(|frame| frame.delay)
114 .collect();
115
116 Self {
117 scaled_frames,
118 frame_delays,
119 renderer,
120 current_frame: 0,
121 max_fps,
122 scale_mode,
123 source,
124 }
125 }
126
127 /// Get the delay for the current frame
128 fn current_delay(&self) -> Duration {
129 let frame_delay = self.frame_delays[self.current_frame];
130 let min_delay = if self.max_fps > 0 {
131 Duration::from_secs_f64(1.0 / self.max_fps as f64)
132 } else {
133 Duration::ZERO
134 };
135 frame_delay.max(min_delay)
136 }
137
138 /// Advance to next frame, returning true if looped
139 fn advance(&mut self) -> bool {
140 self.current_frame += 1;
141 if self.current_frame >= self.scaled_frames.len() {
142 self.current_frame = 0;
143 true
144 } else {
145 false
146 }
147 }
148
149 /// Get frame count
150 #[allow(dead_code)]
151 fn frame_count(&self) -> usize {
152 self.scaled_frames.len()
153 }
154 }
155
156 /// Main daemon struct
157 pub struct Daemon {
158 /// X11 connection
159 conn: Connection,
160
161 /// Daemon state
162 state: DaemonState,
163
164 /// Current animation (if playing)
165 animation: Option<ActiveAnimation>,
166
167 /// Disk cache for remote images
168 cache: Option<DiskCache>,
169 }
170
171 impl Daemon {
172 /// Create a new daemon
173 ///
174 /// Establishes X11 connection and initializes state.
175 /// Fails early with helpful error messages if X11 is unavailable.
176 pub fn new(config: Config) -> Result<Self> {
177 let conn = Connection::new()
178 .context("Failed to initialize X11 connection for daemon")?;
179
180 // Verify connection is working
181 if !conn.is_alive() {
182 anyhow::bail!("X11 connection established but not responding");
183 }
184
185 let (width, height) = conn.screen_dimensions();
186 tracing::info!("X11 connection established (screen: {}x{})", width, height);
187
188 // Initialize disk cache
189 let cache = match DiskCache::default_dir() {
190 Some(cache_dir) => {
191 let max_size_mb = config.cache.max_size_mb;
192 match DiskCache::new(cache_dir.clone(), max_size_mb) {
193 Ok(cache) => {
194 tracing::info!("Disk cache initialized: {} (max {}MB)", cache_dir.display(), max_size_mb);
195 Some(cache)
196 }
197 Err(e) => {
198 tracing::warn!("Failed to initialize disk cache: {}", e);
199 None
200 }
201 }
202 }
203 None => {
204 tracing::warn!("No cache directory available, caching disabled");
205 None
206 }
207 };
208
209 let state = DaemonState::new(config);
210
211 Ok(Self {
212 conn,
213 state,
214 animation: None,
215 cache,
216 })
217 }
218
219 /// Run the daemon event loop
220 pub async fn run(&mut self) -> Result<()> {
221 // Check for stale PID file and clean up
222 if let Some(existing_pid) = pid::check_stale_pid()? {
223 anyhow::bail!("Another daemon is already running (PID: {})", existing_pid);
224 }
225
226 // Write our PID file
227 pid::write_pid_file()?;
228
229 // Ensure cleanup on exit (both normal and panic)
230 let _pid_guard = PidFileGuard;
231
232 let server = IpcServer::new().await?;
233 tracing::info!("Listening on {}", server.path().display());
234
235 // Set initial wallpaper from config if specified
236 if !self.state.config.default.source.is_empty() {
237 if let Err(e) = self.apply_default_wallpaper() {
238 tracing::warn!("Failed to set initial wallpaper: {}", e);
239 }
240 }
241
242 // Try to connect to gar (optional)
243 let mut gar_client = self.try_connect_gar().await;
244
245 // Track next slideshow time
246 let mut next_slideshow: Option<tokio::time::Instant> = self.state.slideshow_interval
247 .map(|d| tokio::time::Instant::now() + d);
248
249 tracing::info!("Daemon started");
250 if let Some(interval) = self.state.slideshow_interval {
251 tracing::info!("Slideshow enabled: {:?} interval", interval);
252 }
253
254 // Track next animation frame time
255 let mut next_animation_frame: Option<tokio::time::Instant> = None;
256
257 // Set up signal handlers
258 let mut sigterm = signal(SignalKind::terminate())?;
259 let mut sigint = signal(SignalKind::interrupt())?;
260 let mut sighup = signal(SignalKind::hangup())?;
261
262 // Main event loop
263 loop {
264 tokio::select! {
265 // SIGTERM - graceful shutdown
266 _ = sigterm.recv() => {
267 tracing::info!("Received SIGTERM, shutting down...");
268 break;
269 }
270
271 // SIGINT (Ctrl+C) - graceful shutdown
272 _ = sigint.recv() => {
273 tracing::info!("Received SIGINT, shutting down...");
274 break;
275 }
276
277 // SIGHUP - reload configuration
278 _ = sighup.recv() => {
279 tracing::info!("Received SIGHUP, reloading configuration...");
280 if let Err(e) = self.reload_config() {
281 tracing::error!("Failed to reload config: {}", e);
282 }
283 }
284
285 // IPC client connection
286 result = server.accept() => {
287 match result {
288 Ok(stream) => {
289 if let Err(e) = self.handle_client(stream).await {
290 tracing::debug!("Client error: {}", e);
291 }
292 // Update slideshow timer if interval changed
293 next_slideshow = self.state.slideshow_interval
294 .map(|d| tokio::time::Instant::now() + d);
295 // Update animation timer if animation started
296 if self.animation.is_some() && next_animation_frame.is_none() {
297 next_animation_frame = Some(tokio::time::Instant::now());
298 } else if self.animation.is_none() {
299 next_animation_frame = None;
300 }
301 }
302 Err(e) => {
303 tracing::warn!("Accept error: {}", e);
304 }
305 }
306 }
307
308 // Animation frame timer (highest priority when active)
309 _ = async {
310 match (next_animation_frame, self.state.paused, &self.animation) {
311 (Some(deadline), false, Some(_)) => {
312 tokio::time::sleep_until(deadline).await;
313 }
314 _ => {
315 std::future::pending::<()>().await;
316 }
317 }
318 } => {
319 if let Err(e) = self.render_animation_frame() {
320 tracing::warn!("Animation frame render failed: {}", e);
321 // Stop animation on error
322 self.animation = None;
323 next_animation_frame = None;
324 } else if let Some(ref anim) = self.animation {
325 // Schedule next frame
326 let delay = anim.current_delay();
327 next_animation_frame = Some(tokio::time::Instant::now() + delay);
328 }
329 }
330
331 // Slideshow timer (only if enabled, not paused, and no animation)
332 _ = async {
333 match (next_slideshow, self.state.paused, &self.animation) {
334 (Some(deadline), false, None) => {
335 tokio::time::sleep_until(deadline).await;
336 }
337 _ => {
338 std::future::pending::<()>().await;
339 }
340 }
341 } => {
342 if let Err(e) = self.advance_slideshow() {
343 tracing::warn!("Slideshow advance failed: {}", e);
344 }
345 // Reset timer using current interval
346 next_slideshow = self.state.slideshow_interval
347 .map(|d| tokio::time::Instant::now() + d);
348 }
349
350 // gar workspace events (only if connected)
351 event = async {
352 if let Some(ref mut client) = gar_client {
353 client.read_event().await
354 } else {
355 std::future::pending().await
356 }
357 } => {
358 match event {
359 Ok(event) => {
360 if let Err(e) = self.handle_gar_event(event) {
361 tracing::warn!("gar event handling failed: {}", e);
362 }
363 }
364 Err(e) => {
365 tracing::debug!("gar connection lost: {}", e);
366 gar_client = None;
367 }
368 }
369 }
370 }
371 }
372
373 // Graceful shutdown complete
374 // PID file will be removed by PidFileGuard drop
375 tracing::info!("Daemon shutdown complete");
376 Ok(())
377 }
378
379 /// Render the next animation frame
380 fn render_animation_frame(&mut self) -> Result<()> {
381 let anim = self.animation.as_mut()
382 .ok_or_else(|| anyhow::anyhow!("No active animation"))?;
383
384 // Render current frame
385 let frame = &anim.scaled_frames[anim.current_frame];
386 anim.renderer.render_and_present(&mut self.conn, frame)?;
387
388 // Advance to next frame
389 anim.advance();
390
391 Ok(())
392 }
393
394 /// Handle a single client connection
395 async fn handle_client(&mut self, stream: UnixStream) -> Result<()> {
396 let mut client = IpcClient::new(stream);
397
398 // Single request-response per connection (stateless)
399 if let Some(cmd) = client.read_command().await? {
400 let response = self.handle_command(cmd);
401 client.send_response(&response).await?;
402 }
403
404 Ok(())
405 }
406
407 /// Handle an IPC command
408 fn handle_command(&mut self, cmd: Command) -> Response {
409 match cmd {
410 Command::Set { source, mode, monitor: _, interval_secs, shuffle, animate, max_fps } => {
411 let scale_mode = mode.unwrap_or(self.state.config.general.mode);
412
413 // Stop any existing animation first
414 self.animation = None;
415
416 // Check if we should animate (GIF, WebP, or APNG with animate flag)
417 let source_lower = source.to_lowercase();
418 let is_animatable = source_lower.ends_with(".gif")
419 || source_lower.contains(".gif?")
420 || source_lower.contains("/gif/")
421 || source_lower.ends_with(".webp")
422 || source_lower.contains(".webp?")
423 || source_lower.contains("/webp/")
424 || source_lower.ends_with(".apng")
425 || source_lower.ends_with(".png") // PNG might be APNG
426 || source_lower.contains(".apng?")
427 // Video formats
428 || source_lower.ends_with(".mp4")
429 || source_lower.ends_with(".webm")
430 || source_lower.ends_with(".mkv")
431 || source_lower.ends_with(".avi")
432 || source_lower.ends_with(".mov")
433 || source_lower.ends_with(".m4v");
434
435 // Videos should always be animated (no sense displaying a single frame)
436 let is_video = source_lower.ends_with(".mp4")
437 || source_lower.ends_with(".webm")
438 || source_lower.ends_with(".mkv")
439 || source_lower.ends_with(".avi")
440 || source_lower.ends_with(".mov")
441 || source_lower.ends_with(".m4v");
442
443 // Auto-animate videos, or animate if flag is set for other formats
444 let should_animate = is_video || (animate && is_animatable);
445
446 if should_animate {
447 // Try to start animation
448 match self.start_animation(&source, scale_mode, max_fps) {
449 Ok(_) => {
450 tracing::info!("Animation started: {}", source);
451 return Response::ok();
452 }
453 Err(e) => {
454 tracing::warn!("Failed to start animation: {}, falling back to static", e);
455 // Fall through to static handling
456 }
457 }
458 }
459
460 // Set up slideshow with the new source (static image)
461 match self.set_wallpaper_with_options(&source, scale_mode, shuffle, interval_secs) {
462 Ok(_) => {
463 // Update slideshow interval
464 self.state.slideshow_interval = interval_secs.map(Duration::from_secs);
465
466 if let Some(secs) = interval_secs {
467 tracing::info!("Slideshow started: {} second interval", secs);
468 }
469
470 Response::ok()
471 }
472 Err(e) => Response::error(e.to_string()),
473 }
474 }
475 Command::SetWorkspace { workspace, source, mode } => {
476 let scale_mode = mode.unwrap_or(self.state.config.general.mode);
477 // Only set if we're on this workspace
478 if self.state.current_workspace == workspace {
479 match self.set_wallpaper_from_source(&source, scale_mode, false) {
480 Ok(_) => Response::ok(),
481 Err(e) => Response::error(e.to_string()),
482 }
483 } else {
484 Response::ok() // Ignore, we're not on this workspace
485 }
486 }
487 Command::Next { .. } => {
488 match self.advance_slideshow() {
489 Ok(_) => Response::ok(),
490 Err(e) => Response::error(e.to_string()),
491 }
492 }
493 Command::Prev { .. } => {
494 match self.prev_slideshow() {
495 Ok(_) => Response::ok(),
496 Err(e) => Response::error(e.to_string()),
497 }
498 }
499 Command::Random { .. } => {
500 match self.random_wallpaper() {
501 Ok(_) => Response::ok(),
502 Err(e) => Response::error(e.to_string()),
503 }
504 }
505 Command::Status => {
506 Response::ok_with_data(self.get_status())
507 }
508 Command::Pause => {
509 self.state.paused = true;
510 tracing::info!("Slideshow paused");
511 Response::ok()
512 }
513 Command::Resume => {
514 self.state.paused = false;
515 tracing::info!("Slideshow resumed");
516 Response::ok()
517 }
518 Command::Toggle => {
519 self.state.paused = !self.state.paused;
520 tracing::info!("Slideshow {}", if self.state.paused { "paused" } else { "resumed" });
521 Response::ok()
522 }
523 Command::Reload => {
524 match self.reload_config() {
525 Ok(_) => Response::ok(),
526 Err(e) => Response::error(e.to_string()),
527 }
528 }
529 Command::ClearCache => {
530 if let Some(ref mut cache) = self.cache {
531 match cache.clear() {
532 Ok(_) => {
533 tracing::info!("Cache cleared");
534 Response::ok()
535 }
536 Err(e) => Response::error(format!("Failed to clear cache: {}", e)),
537 }
538 } else {
539 Response::ok() // No cache to clear
540 }
541 }
542 Command::List { source } => {
543 match self.list_source(&source) {
544 Ok(images) => Response::ok_with_data(serde_json::json!(images)),
545 Err(e) => Response::error(e.to_string()),
546 }
547 }
548 Command::Subscribe { .. } | Command::Unsubscribe { .. } => {
549 // Subscriptions not yet implemented
550 Response::error("Subscriptions not yet implemented")
551 }
552 }
553 }
554
555 /// Handle a gar event
556 fn handle_gar_event(&mut self, event: GarEvent) -> Result<()> {
557 match event {
558 GarEvent::Workspace { current, previous } => {
559 tracing::debug!("Workspace changed: {} -> {}", previous, current);
560 self.on_workspace_change(current)?;
561 }
562 GarEvent::Monitor { name, action } => {
563 tracing::debug!("Monitor {}: {}", action, name);
564 // TODO: Handle monitor changes
565 }
566 GarEvent::Focus { .. } => {
567 // Ignore focus events
568 }
569 GarEvent::Unknown => {
570 // Ignore unknown events
571 }
572 }
573 Ok(())
574 }
575
576 /// Try to connect to gar IPC
577 async fn try_connect_gar(&self) -> Option<GarIpcClient> {
578 match GarIpcClient::connect().await {
579 Ok(mut client) => {
580 if client.subscribe(&["workspace"]).await.is_ok() {
581 tracing::info!("Connected to gar IPC");
582 Some(client)
583 } else {
584 tracing::debug!("Failed to subscribe to gar events");
585 None
586 }
587 }
588 Err(_) => {
589 tracing::debug!("gar not running, workspace integration disabled");
590 None
591 }
592 }
593 }
594
595 /// Apply the default wallpaper from config
596 fn apply_default_wallpaper(&mut self) -> Result<()> {
597 let source = self.state.config.default.source.clone();
598 let mode = self.state.config.default.mode;
599 let shuffle = self.state.config.default.slideshow
600 .as_ref()
601 .map(|s| s.shuffle)
602 .unwrap_or(false);
603
604 if source.is_empty() {
605 return Ok(());
606 }
607
608 self.set_wallpaper_from_source(&source, mode, shuffle)
609 }
610
611 /// Start an animated image playback (GIF, WebP, APNG, or video)
612 fn start_animation(&mut self, source: &str, mode: ScaleMode, max_fps: u32) -> Result<()> {
613 let is_remote = source.starts_with("http://") || source.starts_with("https://");
614 let source_lower = source.to_lowercase();
615
616 // Detect format from extension/URL
617 let is_webp = source_lower.ends_with(".webp")
618 || source_lower.contains(".webp?")
619 || source_lower.contains("/webp/");
620 let is_apng = source_lower.ends_with(".apng")
621 || source_lower.contains(".apng?");
622 let is_png = source_lower.ends_with(".png");
623
624 // Check for video formats
625 #[cfg(feature = "video")]
626 let is_video = {
627 let expanded = shellexpand::tilde(source);
628 !is_remote && is_video_file(expanded.as_ref())
629 };
630 #[cfg(not(feature = "video"))]
631 let is_video = false;
632
633 // Handle video separately (can't load into memory efficiently)
634 #[cfg(feature = "video")]
635 if is_video {
636 return self.start_video_animation(source, mode, max_fps);
637 }
638
639 // Load animation data for image formats
640 let bytes = if is_remote {
641 tracing::info!("Fetching remote animation: {}", source);
642 self.fetch_bytes(source)?
643 } else {
644 let expanded = shellexpand::tilde(source);
645 std::fs::read(expanded.as_ref())
646 .with_context(|| format!("Failed to read: {}", source))?
647 };
648
649 // Load frames based on format - clone frames to avoid lifetime issues
650 let (frames, frame_count, avg_fps, format_name): (Vec<AnimationFrame>, usize, f64, &str) = if is_webp {
651 let webp = AnimatedWebP::load_from_bytes(&bytes)?;
652 if !webp.is_animated() {
653 anyhow::bail!("WebP is not animated (single frame)");
654 }
655 let fc = webp.frame_count();
656 let fps = webp.average_fps();
657 (webp.frames().to_vec(), fc, fps, "WebP")
658 } else if is_apng || is_png {
659 // Try APNG first for .png files (might be animated)
660 match AnimatedPng::load_from_bytes(&bytes) {
661 Ok(apng) if apng.is_animated() => {
662 let fc = apng.frame_count();
663 let fps = apng.average_fps();
664 (apng.frames().to_vec(), fc, fps, "APNG")
665 }
666 Ok(_) => {
667 anyhow::bail!("PNG is not animated");
668 }
669 Err(e) if is_apng => {
670 // .apng extension but failed to load as APNG
671 anyhow::bail!("Failed to load APNG: {}", e);
672 }
673 Err(_) => {
674 // .png extension but not an APNG, try as static
675 anyhow::bail!("PNG is not animated (use without --animate)");
676 }
677 }
678 } else {
679 // Default to GIF
680 let gif = AnimatedGif::load_from_bytes(&bytes)?;
681 if !gif.is_animated() {
682 anyhow::bail!("GIF is not animated (single frame)");
683 }
684 let fc = gif.frame_count();
685 let fps = gif.average_fps();
686 (gif.frames().to_vec(), fc, fps, "GIF")
687 };
688
689 // Suppress warning when video feature is disabled
690 let _ = is_video;
691
692 // Create animation renderer
693 let renderer = AnimationRenderer::new(&self.conn)?;
694 let (width, height) = self.conn.screen_dimensions();
695
696 tracing::info!(
697 "Animation loaded: {} frames, {:.1} FPS ({})",
698 frame_count,
699 avg_fps,
700 format_name
701 );
702
703 self.animation = Some(ActiveAnimation::from_frames(
704 &frames,
705 renderer,
706 max_fps,
707 mode,
708 source.to_string(),
709 width as u32,
710 height as u32,
711 ));
712
713 Ok(())
714 }
715
716 /// Start video animation playback
717 #[cfg(feature = "video")]
718 fn start_video_animation(&mut self, source: &str, mode: ScaleMode, max_fps: u32) -> Result<()> {
719 let expanded = shellexpand::tilde(source);
720 let path = std::path::Path::new(expanded.as_ref());
721
722 tracing::info!("Opening video: {}", path.display());
723
724 let mut decoder = VideoDecoder::open(path)?;
725 let info = decoder.info().clone();
726 let frame_delay = decoder.frame_delay();
727
728 tracing::info!(
729 "Video: {}x{}, {:.1} FPS, {:.1}s duration, ~{} frames ({})",
730 info.width,
731 info.height,
732 info.frame_rate,
733 info.duration,
734 info.frame_count,
735 info.codec
736 );
737
738 // Limit frames for memory efficiency (max ~30 seconds at target fps)
739 let max_frames = (30.0 * info.frame_rate.min(max_fps as f64)) as usize;
740 let frame_limit = max_frames.max(100).min(info.frame_count);
741
742 // Extract frames
743 let mut frames = Vec::with_capacity(frame_limit);
744 while let Some(decoded) = decoder.next_frame()? {
745 frames.push(AnimationFrame {
746 image: decoded.image,
747 delay: frame_delay,
748 });
749
750 if frames.len() >= frame_limit {
751 tracing::debug!("Reached frame limit ({}), stopping decode", frame_limit);
752 break;
753 }
754 }
755
756 if frames.is_empty() {
757 anyhow::bail!("Video has no decodable frames");
758 }
759
760 let frame_count = frames.len();
761 let avg_fps = info.frame_rate;
762
763 // Create animation renderer
764 let renderer = AnimationRenderer::new(&self.conn)?;
765 let (width, height) = self.conn.screen_dimensions();
766
767 tracing::info!(
768 "Video loaded: {} frames, {:.1} FPS",
769 frame_count,
770 avg_fps
771 );
772
773 self.animation = Some(ActiveAnimation::from_frames(
774 &frames,
775 renderer,
776 max_fps,
777 mode,
778 source.to_string(),
779 width as u32,
780 height as u32,
781 ));
782
783 Ok(())
784 }
785
786 /// Fetch raw bytes from a URL (with caching)
787 fn fetch_bytes(&mut self, url: &str) -> Result<Vec<u8>> {
788 // Check cache first
789 if let Some(ref mut cache) = self.cache {
790 if let Some(cached_path) = cache.get(url) {
791 tracing::debug!("Cache hit: {}", url);
792 return std::fs::read(&cached_path)
793 .context("Failed to read cached file");
794 }
795 }
796
797 // Fetch from network
798 tracing::debug!("Cache miss, fetching: {}", url);
799 let client = reqwest::blocking::Client::builder()
800 .user_agent("garbg/0.1")
801 .build()?;
802
803 let response = client.get(url).send()?;
804 let status = response.status();
805
806 if !status.is_success() {
807 anyhow::bail!("HTTP error {}: {}", status, url);
808 }
809
810 // Get ETag for conditional requests
811 let etag = response.headers()
812 .get("etag")
813 .and_then(|v| v.to_str().ok())
814 .map(String::from);
815
816 let bytes = response.bytes()?.to_vec();
817
818 // Store in cache
819 if let Some(ref mut cache) = self.cache {
820 if let Err(e) = cache.store(url, &bytes, etag) {
821 tracing::warn!("Failed to cache {}: {}", url, e);
822 } else {
823 tracing::debug!("Cached: {}", url);
824 }
825 }
826
827 Ok(bytes)
828 }
829
830 /// Set wallpaper with full options (used by IPC Set command)
831 fn set_wallpaper_with_options(
832 &mut self,
833 source: &str,
834 mode: ScaleMode,
835 shuffle: bool,
836 _interval_secs: Option<u64>,
837 ) -> Result<()> {
838 self.set_wallpaper_from_source(source, mode, shuffle)
839 }
840
841 /// Set wallpaper from a source (file, directory, or URL)
842 fn set_wallpaper_from_source(&mut self, source: &str, mode: ScaleMode, shuffle: bool) -> Result<()> {
843 // Expand path
844 let expanded = shellexpand::tilde(source);
845 let path = std::path::Path::new(expanded.as_ref());
846
847 // Check if it's a directory
848 if path.is_dir() {
849 // Create a playlist from the directory
850 let images = self.list_local_directory(&expanded)?;
851 if images.is_empty() {
852 anyhow::bail!("No images found in directory: {}", source);
853 }
854
855 let mut playlist = PlaylistState::new(
856 source.to_string(),
857 detect_source_type(source),
858 images,
859 shuffle,
860 mode,
861 );
862
863 if shuffle {
864 playlist.reshuffle();
865 }
866
867 let first = playlist.current().unwrap_or("").to_string();
868 playlist.save()?;
869 self.state.playlist = Some(playlist);
870
871 self.set_wallpaper(&first, mode)?;
872
873 tracing::info!(
874 "Playlist loaded: {} images{}",
875 self.state.playlist.as_ref().map(|p| p.len()).unwrap_or(0),
876 if shuffle { " (shuffled)" } else { "" }
877 );
878 } else if source.starts_with("http://") || source.starts_with("https://") {
879 // Remote URL
880 let image = self.fetch_image(source)?;
881 let (width, height) = self.conn.screen_dimensions();
882 let scaled = scale_image(&image, width as u32, height as u32, mode);
883 self.conn.set_wallpaper(&scaled)?;
884 tracing::info!("Wallpaper set: {} (mode: {})", source, mode);
885 } else {
886 // Single file
887 self.set_wallpaper(source, mode)?;
888 }
889
890 Ok(())
891 }
892
893 /// Set wallpaper from a local file
894 pub fn set_wallpaper(&mut self, source: &str, mode: ScaleMode) -> Result<()> {
895 let expanded = shellexpand::tilde(source);
896 let image = ImageLoader::load_file(expanded.as_ref())?;
897 let (width, height) = self.conn.screen_dimensions();
898 let scaled = scale_image(&image, width as u32, height as u32, mode);
899 self.conn.set_wallpaper(&scaled)?;
900
901 tracing::info!("Wallpaper set: {} (mode: {})", source, mode);
902
903 Ok(())
904 }
905
906 /// Fetch image from URL (with caching)
907 fn fetch_image(&mut self, url: &str) -> Result<image::RgbaImage> {
908 // Use cached bytes
909 let bytes = self.fetch_bytes(url)?;
910 ImageLoader::load_bytes(&bytes, None)
911 }
912
913 /// Advance to the next wallpaper in the slideshow
914 fn advance_slideshow(&mut self) -> Result<()> {
915 // Reload state in case it was modified externally
916 if let Some(ref mut playlist) = self.state.playlist {
917 playlist.reload()?;
918 } else if let Some(playlist) = PlaylistState::load()? {
919 self.state.playlist = Some(playlist);
920 }
921
922 // Extract what we need from the playlist first
923 let (next, mode, current_index, total) = {
924 let playlist = self.state.playlist.as_mut()
925 .ok_or_else(|| anyhow::anyhow!("No active playlist"))?;
926
927 let next = playlist.next().to_string();
928 let mode = playlist.mode;
929 let current_index = playlist.current_index;
930 let total = playlist.len();
931 playlist.save()?;
932
933 (next, mode, current_index, total)
934 };
935
936 self.set_wallpaper(&next, mode)?;
937
938 tracing::info!(
939 "Slideshow [{}/{}]: {}",
940 current_index + 1,
941 total,
942 next
943 );
944
945 Ok(())
946 }
947
948 /// Go to the previous wallpaper in the slideshow
949 fn prev_slideshow(&mut self) -> Result<()> {
950 // Reload state in case it was modified externally
951 if let Some(ref mut playlist) = self.state.playlist {
952 playlist.reload()?;
953 } else if let Some(playlist) = PlaylistState::load()? {
954 self.state.playlist = Some(playlist);
955 }
956
957 // Extract what we need from the playlist first
958 let (prev, mode, current_index, total) = {
959 let playlist = self.state.playlist.as_mut()
960 .ok_or_else(|| anyhow::anyhow!("No active playlist"))?;
961
962 let prev = playlist.prev().to_string();
963 let mode = playlist.mode;
964 let current_index = playlist.current_index;
965 let total = playlist.len();
966 playlist.save()?;
967
968 (prev, mode, current_index, total)
969 };
970
971 self.set_wallpaper(&prev, mode)?;
972
973 tracing::info!(
974 "Slideshow [{}/{}]: {}",
975 current_index + 1,
976 total,
977 prev
978 );
979
980 Ok(())
981 }
982
983 /// Set a random wallpaper from the current playlist
984 fn random_wallpaper(&mut self) -> Result<()> {
985 if let Some(ref mut playlist) = self.state.playlist {
986 use rand::Rng;
987 let idx = rand::thread_rng().gen_range(0..playlist.len());
988 playlist.current_index = idx;
989 let img = playlist.images[idx].clone();
990 let mode = playlist.mode;
991 playlist.save()?;
992
993 self.set_wallpaper(&img, mode)?;
994 } else {
995 anyhow::bail!("No active playlist");
996 }
997
998 Ok(())
999 }
1000
1001 /// Handle workspace change
1002 pub fn on_workspace_change(&mut self, workspace: usize) -> Result<()> {
1003 self.state.current_workspace = workspace;
1004
1005 // Check if this workspace has a specific wallpaper
1006 let ws_config = self.state.config.workspaces
1007 .iter()
1008 .find(|w| w.id == workspace)
1009 .cloned();
1010
1011 if let Some(config) = ws_config {
1012 let mode = config.mode.unwrap_or(self.state.config.general.mode);
1013 self.set_wallpaper_from_source(&config.source, mode, false)?;
1014 }
1015
1016 Ok(())
1017 }
1018
1019 /// Reload configuration
1020 fn reload_config(&mut self) -> Result<()> {
1021 let config = Config::load_default()?;
1022
1023 // Update slideshow interval from new config
1024 self.state.slideshow_interval = config.default.slideshow
1025 .as_ref()
1026 .filter(|s| s.enabled)
1027 .map(|s| s.interval);
1028
1029 self.state.config = config;
1030 tracing::info!("Configuration reloaded");
1031
1032 // Re-apply default wallpaper
1033 self.apply_default_wallpaper()?;
1034
1035 Ok(())
1036 }
1037
1038 /// Get current status as JSON
1039 fn get_status(&self) -> serde_json::Value {
1040 let playlist_info = self.state.playlist.as_ref().map(|p| {
1041 serde_json::json!({
1042 "source": p.source,
1043 "current_index": p.current_index,
1044 "total": p.len(),
1045 "current_image": p.current(),
1046 "shuffled": p.shuffled,
1047 "mode": format!("{}", p.mode),
1048 })
1049 });
1050
1051 let interval_secs = self.state.slideshow_interval.map(|d| d.as_secs());
1052
1053 serde_json::json!({
1054 "workspace": self.state.current_workspace,
1055 "paused": self.state.paused,
1056 "interval_secs": interval_secs,
1057 "playlist": playlist_info,
1058 })
1059 }
1060
1061 /// List images from a source
1062 fn list_source(&self, source: &str) -> Result<Vec<String>> {
1063 let expanded = shellexpand::tilde(source);
1064 self.list_local_directory(&expanded)
1065 }
1066
1067 /// List images in a local directory
1068 fn list_local_directory(&self, path: &str) -> Result<Vec<String>> {
1069 let dir_path = std::path::Path::new(path);
1070
1071 if dir_path.is_file() {
1072 return Ok(vec![path.to_string()]);
1073 }
1074
1075 if !dir_path.is_dir() {
1076 anyhow::bail!("Path is not a file or directory: {}", path);
1077 }
1078
1079 let mut images = Vec::new();
1080 for entry in std::fs::read_dir(dir_path)? {
1081 let entry = entry?;
1082 let entry_path = entry.path();
1083 if entry_path.is_file() && ImageLoader::is_supported_format(&entry_path) {
1084 images.push(entry_path.to_string_lossy().to_string());
1085 }
1086 }
1087
1088 images.sort();
1089 Ok(images)
1090 }
1091 }
1092
1093 /// RAII guard for PID file cleanup
1094 ///
1095 /// Removes the PID file when dropped, ensuring cleanup even on panic.
1096 struct PidFileGuard;
1097
1098 impl Drop for PidFileGuard {
1099 fn drop(&mut self) {
1100 if let Err(e) = pid::remove_pid_file() {
1101 tracing::warn!("Failed to remove PID file on shutdown: {}", e);
1102 } else {
1103 tracing::debug!("PID file removed on shutdown");
1104 }
1105 }
1106 }
1107