Skip to main content

wallr_core/daemon/
mod.rs

1use crate::config::WallrConfig;
2use crate::ipc::{IpcCommand, IpcResponse, start_ipc_server};
3use crate::renderer::Renderer;
4use crate::wallpaper::{SetOptions, WallpaperEngine};
5use std::sync::Arc;
6use std::sync::atomic::{AtomicBool, Ordering};
7use tokio::sync::Mutex;
8
9use raw_window_handle::{
10    DisplayHandle, HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle,
11    WaylandDisplayHandle, WaylandWindowHandle, WindowHandle,
12};
13use smithay_client_toolkit::{
14    compositor::{CompositorHandler, CompositorState},
15    delegate_compositor, delegate_layer, delegate_output, delegate_registry, delegate_shm,
16    output::{OutputHandler, OutputState},
17    registry::{ProvidesRegistryState, RegistryState},
18    registry_handlers,
19    shell::WaylandSurface,
20    shell::wlr_layer::{
21        Anchor, KeyboardInteractivity, Layer, LayerShell, LayerShellHandler, LayerSurface,
22        LayerSurfaceConfigure,
23    },
24    shm::slot::{Buffer as ShmBuffer, SlotPool},
25    shm::{Shm, ShmHandler},
26};
27use wayland_client::{
28    Connection, Dispatch, Proxy, QueueHandle,
29    globals::registry_queue_init,
30    protocol::{wl_compositor, wl_output, wl_surface},
31};
32use wayland_protocols::wp::viewporter::client::{
33    wp_viewport::{self, WpViewport},
34    wp_viewporter::{self, WpViewporter},
35};
36#[derive(Debug, thiserror::Error)]
37pub enum DaemonError {
38    #[error("daemon already running: {0}")]
39    AlreadyRunning(String),
40    #[error("failed to start daemon: {0}")]
41    StartError(String),
42    #[error("I/O error: {0}")]
43    Io(#[from] std::io::Error),
44    #[error("IPC error: {0}")]
45    Ipc(#[from] crate::ipc::IpcError),
46    #[error("Config error: {0}")]
47    Config(#[from] crate::config::ConfigError),
48    #[error("Wallpaper error: {0}")]
49    Wallpaper(#[from] crate::wallpaper::WallpaperError),
50}
51
52pub struct WaylandWindow {
53    pub display: *mut std::ffi::c_void,
54    pub surface: *mut std::ffi::c_void,
55}
56
57unsafe impl Send for WaylandWindow {}
58unsafe impl Sync for WaylandWindow {}
59
60impl HasWindowHandle for WaylandWindow {
61    fn window_handle(&self) -> Result<WindowHandle<'_>, raw_window_handle::HandleError> {
62        let surface = std::ptr::NonNull::new(self.surface)
63            .ok_or(raw_window_handle::HandleError::Unavailable)?;
64        let handle = WaylandWindowHandle::new(surface);
65        unsafe { Ok(WindowHandle::borrow_raw(RawWindowHandle::Wayland(handle))) }
66    }
67}
68
69impl HasDisplayHandle for WaylandWindow {
70    fn display_handle(&self) -> Result<DisplayHandle<'_>, raw_window_handle::HandleError> {
71        let display = std::ptr::NonNull::new(self.display)
72            .ok_or(raw_window_handle::HandleError::Unavailable)?;
73        let handle = WaylandDisplayHandle::new(display);
74        unsafe { Ok(DisplayHandle::borrow_raw(RawDisplayHandle::Wayland(handle))) }
75    }
76}
77
78#[derive(Clone)]
79struct OutputInfo {
80    name: String,
81    width: u32,
82    height: u32,
83    scale_factor: i32,
84    wl_output: wl_output::WlOutput,
85}
86
87#[derive(Clone)]
88struct OutputLifecycle {
89    name: String,
90    render_state: std::sync::Arc<tokio::sync::Mutex<RenderState>>,
91    active: std::sync::Arc<std::sync::atomic::AtomicBool>,
92}
93
94struct WaylandState {
95    registry_state: RegistryState,
96    output_state: OutputState,
97    compositor_state: CompositorState,
98    shm: Shm,
99    outputs: std::collections::HashMap<u32, OutputInfo>,
100    surfaces: Vec<(u32, LayerSurface)>,
101    viewporter: Option<WpViewporter>,
102    viewports: std::collections::HashMap<u32, WpViewport>,
103    output_lifecycles: std::collections::HashMap<u32, OutputLifecycle>,
104    pending_restores: std::collections::HashSet<u32>,
105    /// Layer shell protocol object for creating background surfaces.
106    layer_shell: LayerShell,
107    /// Compositor protocol object for creating input regions.
108    compositor: wl_compositor::WlCompositor,
109    /// Shared daemon context for hotplug: creates and destroys render states
110    /// when outputs appear or disappear.
111    hotplug: Option<DaemonHotplug>,
112}
113
114/// Wrapper around `*mut c_void` that implements `Send`. The pointer is a
115/// Wayland display pointer that lives for the entire process lifetime.
116struct SendDisplayPtr(*mut std::ffi::c_void);
117unsafe impl Send for SendDisplayPtr {}
118
119/// Shared context for hotplug operations. Stored in `WaylandState` so the
120/// output callbacks can create/destroy render states without needing access
121/// to the full `Daemon` state.
122struct DaemonHotplug {
123    renderer: std::sync::Arc<std::sync::Mutex<Option<std::sync::Arc<Renderer>>>>,
124    config: crate::config::WallrConfig,
125    display_ptr: SendDisplayPtr,
126    /// Shared render-state map. Protected by `tokio::sync::Mutex` so the IPC
127    /// handler (async) and the Wayland callbacks (sync, via `blocking_dispatch`)
128    /// can both access it. The Wayland thread never holds this across an await,
129    /// so there is no risk of deadlocking the event loop.
130    render_states: std::sync::Arc<
131        tokio::sync::Mutex<
132            std::collections::HashMap<String, std::sync::Arc<tokio::sync::Mutex<RenderState>>>,
133        >,
134    >,
135}
136
137impl ProvidesRegistryState for WaylandState {
138    fn registry(&mut self) -> &mut RegistryState {
139        &mut self.registry_state
140    }
141
142    registry_handlers![OutputState,];
143}
144
145impl CompositorHandler for WaylandState {
146    fn scale_factor_changed(
147        &mut self,
148        _conn: &Connection,
149        _qh: &QueueHandle<Self>,
150        _surface: &wl_surface::WlSurface,
151        _new_factor: i32,
152    ) {
153    }
154    fn transform_changed(
155        &mut self,
156        _conn: &Connection,
157        _qh: &QueueHandle<Self>,
158        _surface: &wl_surface::WlSurface,
159        _new_transform: wl_output::Transform,
160    ) {
161    }
162    fn frame(
163        &mut self,
164        _conn: &Connection,
165        _qh: &QueueHandle<Self>,
166        _surface: &wl_surface::WlSurface,
167        _time: u32,
168    ) {
169    }
170    fn surface_enter(
171        &mut self,
172        _conn: &Connection,
173        _qh: &QueueHandle<Self>,
174        _surface: &wl_surface::WlSurface,
175        _output: &wl_output::WlOutput,
176    ) {
177    }
178    fn surface_leave(
179        &mut self,
180        _conn: &Connection,
181        _qh: &QueueHandle<Self>,
182        _surface: &wl_surface::WlSurface,
183        _output: &wl_output::WlOutput,
184    ) {
185    }
186}
187
188impl wayland_client::Dispatch<wayland_client::protocol::wl_region::WlRegion, ()> for WaylandState {
189    fn event(
190        _state: &mut WaylandState,
191        _region: &wayland_client::protocol::wl_region::WlRegion,
192        _event: wayland_client::protocol::wl_region::Event,
193        _data: &(),
194        _conn: &Connection,
195        _qh: &QueueHandle<WaylandState>,
196    ) {
197    }
198}
199
200impl Dispatch<WpViewporter, ()> for WaylandState {
201    fn event(
202        _state: &mut WaylandState,
203        _proxy: &WpViewporter,
204        _event: wp_viewporter::Event,
205        _data: &(),
206        _conn: &Connection,
207        _qh: &QueueHandle<WaylandState>,
208    ) {
209    }
210}
211
212impl Dispatch<WpViewport, ()> for WaylandState {
213    fn event(
214        _state: &mut WaylandState,
215        _proxy: &WpViewport,
216        _event: wp_viewport::Event,
217        _data: &(),
218        _conn: &Connection,
219        _qh: &QueueHandle<WaylandState>,
220    ) {
221    }
222}
223
224fn viewport_destination(configured: (u32, u32), physical: (u32, u32)) -> Option<(i32, i32)> {
225    let (width, height) = configured;
226    let (physical_width, physical_height) = physical;
227    match (width, height) {
228        (0, 0) => None,
229        (0, height) if physical_height > 0 => {
230            let width = (u64::from(height) * u64::from(physical_width)
231                + u64::from(physical_height) / 2)
232                / u64::from(physical_height);
233            Some((i32::try_from(width).ok()?, i32::try_from(height).ok()?))
234        }
235        (width, 0) if physical_width > 0 => {
236            let height = (u64::from(width) * u64::from(physical_height)
237                + u64::from(physical_width) / 2)
238                / u64::from(physical_width);
239            Some((i32::try_from(width).ok()?, i32::try_from(height).ok()?))
240        }
241        (width, height) => Some((i32::try_from(width).ok()?, i32::try_from(height).ok()?)),
242    }
243}
244
245#[cfg(test)]
246mod viewport_tests {
247    use super::{
248        VideoPresentAction, advance_gif_slots, clamp_max_fps, clamp_preload_frames,
249        is_transient_wallpaper_error, is_usable_wallpaper_file, persist_wallpaper_at,
250        read_wallpaper_state, validate_live_config, video_present_action, viewport_destination,
251        write_wallpaper_state,
252    };
253    use crate::renderer::FrameStatus;
254
255    #[test]
256    fn gif_slots_never_flip_to_an_unuploaded_frame() {
257        // Warm path: idle texture already holds the target frame.
258        let advanced = advance_gif_slots(0, 0, 1, 1, true);
259        assert_eq!((advanced.0, advanced.1, advanced.3), (1, 1, true));
260
261        // Upload failure: keep presenting the old frame, retry later.
262        let retry = advance_gif_slots(0, 0, 2, 1, false);
263        assert_eq!((retry.0, retry.1, retry.3), (0, 0, false));
264
265        // Retry succeeds once the decoder catches up.
266        let retried = advance_gif_slots(0, 0, 2, 1, true);
267        assert_eq!((retried.0, retried.1, retried.3), (1, 1, true));
268
269        // Same-frame repeats keep the current texture.
270        let same = advance_gif_slots(1, 1, 2, 1, true);
271        assert_eq!((same.0, same.1, same.3), (1, 1, true));
272
273        // Skip-ahead jump after a busy present still lands on the newest
274        // frame, never on a stale texture.
275        let jump = advance_gif_slots(1, 1, 2, 4, true);
276        assert_eq!((jump.0, jump.1, jump.3), (0, 4, true));
277    }
278
279    #[test]
280    fn preserves_complete_configure_size() {
281        assert_eq!(
282            viewport_destination((3072, 1728), (3840, 2160)),
283            Some((3072, 1728))
284        );
285    }
286
287    #[test]
288    fn derives_missing_dimension_from_physical_aspect_ratio() {
289        assert_eq!(
290            viewport_destination((0, 1728), (3840, 2160)),
291            Some((3072, 1728))
292        );
293        assert_eq!(
294            viewport_destination((3072, 0), (3840, 2160)),
295            Some((3072, 1728))
296        );
297        assert_eq!(viewport_destination((0, 0), (3840, 2160)), None);
298    }
299
300    #[test]
301    fn retries_recoverable_video_surface_failures() {
302        assert_eq!(
303            video_present_action(FrameStatus::TimedOut),
304            VideoPresentAction::Retry
305        );
306        assert_eq!(
307            video_present_action(FrameStatus::Outdated),
308            VideoPresentAction::Reconfigure
309        );
310        assert_eq!(
311            video_present_action(FrameStatus::Lost),
312            VideoPresentAction::Reconfigure
313        );
314    }
315
316    #[test]
317    fn rotates_wallpaper_state_only_after_successful_persistence() {
318        let temporary = tempfile::tempdir().expect("temporary directory");
319        let first = temporary.path().join("first.jpg");
320        let second = temporary.path().join("second.jpg");
321        std::fs::write(&first, b"first").expect("first wallpaper");
322        std::fs::write(&second, b"second").expect("second wallpaper");
323
324        persist_wallpaper_at(temporary.path(), "DP-1", &first).expect("persist first");
325        assert_eq!(
326            read_wallpaper_state(temporary.path(), "last_wallpaper", "DP-1"),
327            Some(first.clone())
328        );
329        assert_eq!(
330            read_wallpaper_state(temporary.path(), "previous_wallpaper", "DP-1"),
331            None
332        );
333
334        persist_wallpaper_at(temporary.path(), "DP-1", &second).expect("persist second");
335        assert_eq!(
336            read_wallpaper_state(temporary.path(), "last_wallpaper", "DP-1"),
337            Some(second)
338        );
339        assert_eq!(
340            read_wallpaper_state(temporary.path(), "previous_wallpaper", "DP-1"),
341            Some(first)
342        );
343
344        let missing = temporary.path().join("missing.jpg");
345        write_wallpaper_state(temporary.path(), "last_wallpaper", "DP-1", &missing)
346            .expect("persist missing path for restore test");
347        assert_eq!(
348            read_wallpaper_state(temporary.path(), "last_wallpaper", "DP-1"),
349            Some(missing)
350        );
351    }
352
353    #[test]
354    fn wallpaper_state_preserves_non_utf8_and_whitespace() {
355        use std::os::unix::ffi::OsStringExt;
356
357        let temporary = tempfile::tempdir().expect("temporary directory");
358        let wallpaper = std::path::PathBuf::from(std::ffi::OsString::from_vec(
359            b" /tmp/wallpaper-\xff.jpg ".to_vec(),
360        ));
361
362        write_wallpaper_state(temporary.path(), "last_wallpaper", "DP-1", &wallpaper)
363            .expect("persist wallpaper path");
364
365        assert_eq!(
366            read_wallpaper_state(temporary.path(), "last_wallpaper", "DP-1"),
367            Some(wallpaper)
368        );
369    }
370
371    #[test]
372    fn retries_only_explicitly_transient_errors() {
373        let timed_out = anyhow::Error::new(std::io::Error::new(
374            std::io::ErrorKind::TimedOut,
375            "temporary timeout",
376        ));
377        let missing = anyhow::Error::new(std::io::Error::new(
378            std::io::ErrorKind::NotFound,
379            "missing wallpaper",
380        ));
381        let recoverable_video = anyhow::Error::new(crate::video::VideoError::QueueFull);
382
383        assert!(is_transient_wallpaper_error(&timed_out));
384        assert!(is_transient_wallpaper_error(&recoverable_video));
385        assert!(!is_transient_wallpaper_error(&missing));
386        assert!(!is_transient_wallpaper_error(&anyhow::anyhow!(
387            "invalid dimensions"
388        )));
389    }
390
391    #[test]
392    fn live_video_params_stay_bounded() {
393        assert_eq!(clamp_preload_frames(0), 1);
394        assert_eq!(clamp_preload_frames(2), 2);
395        assert_eq!(clamp_preload_frames(100), 3);
396        assert_eq!(clamp_max_fps(None), None);
397        assert_eq!(clamp_max_fps(Some(0)), None);
398        assert_eq!(clamp_max_fps(Some(60)), Some(60));
399        assert_eq!(clamp_max_fps(Some(10_000)), Some(240));
400    }
401
402    #[test]
403    fn watcher_requires_non_empty_files() {
404        let temporary = tempfile::tempdir().expect("temporary directory");
405        let empty = temporary.path().join("empty.jpg");
406        let filled = temporary.path().join("filled.jpg");
407        std::fs::write(&empty, b"").expect("empty file");
408        std::fs::write(&filled, b"data").expect("filled file");
409        assert!(!is_usable_wallpaper_file(&empty));
410        assert!(is_usable_wallpaper_file(&filled));
411        assert!(!is_usable_wallpaper_file(
412            &temporary.path().join("missing.jpg")
413        ));
414    }
415
416    #[test]
417    fn reload_rejects_absurd_live_values() {
418        let mut cfg = crate::config::WallrConfig::default();
419        assert!(validate_live_config(&cfg).is_ok());
420        cfg.video.preload_frames = 1000;
421        assert!(validate_live_config(&cfg).is_err());
422        cfg.video.preload_frames = 2;
423        cfg.daemon.max_fps = Some(0);
424        assert!(validate_live_config(&cfg).is_err());
425    }
426}
427
428impl LayerShellHandler for WaylandState {
429    fn configure(
430        &mut self,
431        _conn: &Connection,
432        _qh: &QueueHandle<Self>,
433        layer: &LayerSurface,
434        configure: LayerSurfaceConfigure,
435        _serial: u32,
436    ) {
437        // SCTK acknowledges the configure. wgpu owns subsequent buffer commits,
438        // which must not race with a bufferless commit when explicit sync is active.
439        let output_id = self.surfaces.iter().find_map(|(output_id, surface)| {
440            (surface.wl_surface().id() == layer.wl_surface().id()).then_some(*output_id)
441        });
442        let Some(output_id) = output_id else {
443            return;
444        };
445        let destination = self.outputs.get(&output_id).and_then(|output| {
446            viewport_destination(configure.new_size, (output.width, output.height))
447        });
448        if let Some(viewport) = self.viewports.get(&output_id) {
449            let Some((logical_width, logical_height)) = destination else {
450                tracing::warn!(
451                    "Output {output_id} configure omitted both dimensions; waiting for a usable size"
452                );
453                return;
454            };
455            viewport.set_destination(logical_width, logical_height);
456            tracing::debug!(
457                "Configured viewport destination for output {output_id}: {logical_width}x{logical_height}"
458            );
459        }
460        if !self.pending_restores.remove(&output_id) {
461            return;
462        }
463        let Some(lifecycle) = self.output_lifecycles.get(&output_id).cloned() else {
464            return;
465        };
466        let Some(render_states) = self
467            .hotplug
468            .as_ref()
469            .map(|hotplug| hotplug.render_states.clone())
470        else {
471            return;
472        };
473
474        tokio::spawn(async move {
475            if !lifecycle.active.load(Ordering::SeqCst) {
476                return;
477            }
478            restore_cached_wallpaper(&lifecycle.name, &lifecycle.render_state).await;
479
480            let mut states = render_states.lock().await;
481            if lifecycle.active.load(Ordering::SeqCst) {
482                states.insert(lifecycle.name.clone(), lifecycle.render_state);
483                tracing::info!("Output configured: {}", lifecycle.name);
484            }
485        });
486    }
487
488    fn closed(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _layer: &LayerSurface) {}
489}
490
491impl ShmHandler for WaylandState {
492    fn shm_state(&mut self) -> &mut Shm {
493        &mut self.shm
494    }
495}
496
497impl OutputHandler for WaylandState {
498    fn output_state(&mut self) -> &mut OutputState {
499        &mut self.output_state
500    }
501    fn new_output(
502        &mut self,
503        _conn: &Connection,
504        _qh: &QueueHandle<Self>,
505        output: wl_output::WlOutput,
506    ) {
507        let id = output.id().protocol_id();
508        tracing::info!("Output detected: protocol_id={id}");
509
510        let mut info = OutputInfo {
511            name: format!("output-{id}"),
512            width: 1920,
513            height: 1080,
514            scale_factor: 1,
515            wl_output: output,
516        };
517
518        // Resolve the compositor-provided name (e.g. "DP-1", "HDMI-A-1").
519        if let Some(info_data) = self.output_state.info(&info.wl_output) {
520            if let Some(mode) = info_data.modes.iter().find(|m| m.current) {
521                info.width = mode.dimensions.0 as u32;
522                info.height = mode.dimensions.1 as u32;
523            }
524            info.scale_factor = info_data.scale_factor;
525            let resolved = info_data
526                .name
527                .as_deref()
528                .filter(|n| !n.is_empty())
529                .or(info_data.description.as_deref().filter(|n| !n.is_empty()));
530            if let Some(real_name) = resolved {
531                info.name = real_name.to_string();
532            } else if !info_data.make.is_empty() || !info_data.model.is_empty() {
533                let fallback = format!("{} {}", info_data.make, info_data.model)
534                    .trim()
535                    .to_string();
536                if !fallback.is_empty() {
537                    info.name = fallback;
538                }
539            }
540        }
541
542        self.outputs.insert(id, info.clone());
543
544        // Create a render state for the new output.
545        // Extract values from hotplug before passing &mut self to avoid
546        // borrow checker conflicts (hotplug is inside self).
547        if self.hotplug.is_some() {
548            let name = info.name.clone();
549            let renderer = self.hotplug.as_ref().unwrap().renderer.clone();
550            let display_ptr = self.hotplug.as_ref().unwrap().display_ptr.0;
551            let config = self.hotplug.as_ref().unwrap().config.clone();
552            match create_render_state_for_output_sync(
553                &renderer,
554                display_ptr,
555                self,
556                _qh,
557                &info,
558                &config,
559            ) {
560                Ok(rs) => {
561                    let rs = std::sync::Arc::new(tokio::sync::Mutex::new(rs));
562                    self.output_lifecycles.insert(
563                        id,
564                        OutputLifecycle {
565                            name,
566                            render_state: rs,
567                            active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)),
568                        },
569                    );
570                    self.pending_restores.insert(id);
571                }
572                Err(e) => {
573                    tracing::error!("Hotplug: failed to create render state for {name}: {e}");
574                }
575            }
576        }
577    }
578    fn update_output(
579        &mut self,
580        _conn: &Connection,
581        _qh: &QueueHandle<Self>,
582        output: wl_output::WlOutput,
583    ) {
584        let id = output.id().protocol_id();
585        if let Some(info) = self.outputs.get_mut(&id) {
586            let mut new_width = info.width;
587            let mut new_height = info.height;
588            let mut new_scale = info.scale_factor;
589
590            if let Some(mode) = self
591                .output_state
592                .info(&output)
593                .and_then(|i| i.modes.iter().find(|m| m.current).cloned())
594            {
595                new_width = mode.dimensions.0 as u32;
596                new_height = mode.dimensions.1 as u32;
597            }
598            if let Some(info_data) = self.output_state.info(&output) {
599                new_scale = info_data.scale_factor;
600                let resolved = info_data
601                    .name
602                    .as_deref()
603                    .filter(|n| !n.is_empty())
604                    .or(info_data.description.as_deref().filter(|n| !n.is_empty()));
605                if let Some(real_name) = resolved {
606                    if real_name != info.name {
607                        tracing::info!(
608                            "Output {id}: resolved name '{}' -> '{}'",
609                            info.name,
610                            real_name
611                        );
612                        info.name = real_name.to_string();
613                    }
614                } else if info.name.starts_with("output-")
615                    && (!info_data.make.is_empty() || !info_data.model.is_empty())
616                {
617                    let fallback = format!("{} {}", info_data.make, info_data.model)
618                        .trim()
619                        .to_string();
620                    if !fallback.is_empty() {
621                        tracing::info!(
622                            "Output {id}: fallback name '{}' -> '{}'",
623                            info.name,
624                            fallback
625                        );
626                        info.name = fallback;
627                    }
628                }
629            }
630
631            let changed = new_width != info.width
632                || new_height != info.height
633                || new_scale != info.scale_factor;
634
635            info.width = new_width;
636            info.height = new_height;
637            info.scale_factor = new_scale;
638
639            // Reconfigure the wgpu surface when dimensions or scale change.
640            if changed {
641                let name = info.name.clone();
642                if let Some(ref hotplug) = self.hotplug {
643                    let render_states = hotplug.render_states.clone();
644                    tokio::spawn(async move {
645                        let states = render_states.lock().await;
646                        if let Some(rs) = states.get(&name) {
647                            let mut lock = rs.lock().await;
648                            lock.width = new_width;
649                            lock.height = new_height;
650                            if let Some(gpu) = lock.gpu.as_ref() {
651                                if let Ok(shared) = lock.renderer.lock() {
652                                    if let Some(renderer) = shared.as_ref() {
653                                        gpu.surface.configure(
654                                            &renderer.device,
655                                            &wgpu::SurfaceConfiguration {
656                                                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
657                                                format: gpu.format,
658                                                width: new_width,
659                                                height: new_height,
660                                                present_mode: wgpu::PresentMode::Fifo,
661                                                alpha_mode: wgpu::CompositeAlphaMode::Opaque,
662                                                view_formats: vec![],
663                                                desired_maximum_frame_latency: 1,
664                                            },
665                                        );
666                                    }
667                                }
668                                tracing::info!(
669                                    "Hotplug: reconfigured {name} to {new_width}x{new_height}"
670                                );
671                            }
672                        }
673                    });
674                }
675            }
676        }
677    }
678    fn output_destroyed(
679        &mut self,
680        _conn: &Connection,
681        _qh: &QueueHandle<Self>,
682        output: wl_output::WlOutput,
683    ) {
684        let id = output.id().protocol_id();
685        if let Some(info) = self.outputs.remove(&id) {
686            tracing::info!("Output disconnected: {} (protocol_id={})", info.name, id);
687            self.pending_restores.remove(&id);
688            let viewport = self.viewports.remove(&id);
689            let layer_surface = self
690                .surfaces
691                .iter()
692                .position(|(pid, _)| *pid == id)
693                .map(|position| self.surfaces.swap_remove(position).1);
694            let lifecycle = self.output_lifecycles.remove(&id);
695            if let Some(lifecycle) = &lifecycle {
696                lifecycle.active.store(false, Ordering::SeqCst);
697            }
698            // Remove the render state from the shared map and stop playback.
699            if let (Some(hotplug), Some(lifecycle)) = (&self.hotplug, lifecycle) {
700                let render_states = hotplug.render_states.clone();
701                tokio::spawn(async move {
702                    let mut states = render_states.lock().await;
703                    if states
704                        .get(&lifecycle.name)
705                        .is_some_and(|state| Arc::ptr_eq(state, &lifecycle.render_state))
706                    {
707                        states.remove(&lifecycle.name);
708                    }
709                    drop(states);
710
711                    let state = lifecycle.render_state.lock().await;
712                    state.playback_gen.fetch_add(1, Ordering::SeqCst);
713                    state.pacer.notify();
714                    state.video_playback.stop();
715                    let render_lock = state.render_lock.clone();
716                    drop(state);
717
718                    let _ = tokio::task::spawn_blocking(move || {
719                        drop(
720                            render_lock
721                                .lock()
722                                .unwrap_or_else(|poisoned| poisoned.into_inner()),
723                        );
724                    })
725                    .await;
726                    if let Some(viewport) = viewport {
727                        viewport.destroy();
728                    }
729                    drop(layer_surface);
730                    tracing::info!("Hotplug: cleaned up render state for {}", lifecycle.name);
731                });
732            } else {
733                if let Some(viewport) = viewport {
734                    viewport.destroy();
735                }
736                drop(layer_surface);
737            }
738        }
739    }
740}
741
742delegate_compositor!(WaylandState);
743delegate_layer!(WaylandState);
744delegate_output!(WaylandState);
745delegate_registry!(WaylandState);
746delegate_shm!(WaylandState);
747
748/// Wakes paced live-playback loops when a new commit bumps the generation.
749struct LivePacer {
750    lock: std::sync::Mutex<()>,
751    cond: std::sync::Condvar,
752}
753
754impl LivePacer {
755    fn new() -> Self {
756        Self {
757            lock: std::sync::Mutex::new(()),
758            cond: std::sync::Condvar::new(),
759        }
760    }
761
762    fn notify(&self) {
763        let _guard = self.lock.lock().unwrap();
764        self.cond.notify_all();
765    }
766
767    /// Blocks until `deadline` or until `notify` is called, whichever comes
768    /// first.
769    fn wait_until(&self, deadline: std::time::Instant) {
770        let guard = self.lock.lock().unwrap();
771        let now = std::time::Instant::now();
772        if deadline <= now {
773            return;
774        }
775        // A notification is a real scheduling event: it means a newer
776        // wallpaper may have superseded the current player.  Using
777        // `wait_timeout_while` with an always-true predicate turns every
778        // notify into a spurious wakeup and makes the waiter sleep until its
779        // full deadline anyway (up to 24h for paused GIFs).  A plain timed
780        // wait returns on notify; the caller then checks the generation and
781        // exits without another decode/upload/present.
782        let _ = self.cond.wait_timeout(guard, deadline - now);
783    }
784}
785
786struct RenderState {
787    /// GPU resources are grouped behind one ownership boundary so the next
788    /// lazy-GPU step can replace this with an optional, on-demand state
789    /// without changing static Wayland/shm fields.
790    gpu: Option<GpuState>,
791    renderer: std::sync::Arc<std::sync::Mutex<Option<std::sync::Arc<Renderer>>>>,
792    display_ptr: SendDisplayPtr,
793    raw_surface: SendDisplayPtr,
794    /// Serializes transition rendering. The lock is only ever held by the
795    /// detached render task, never by the IPC loop, so a stalled present
796    /// cannot freeze the daemon.
797    render_lock: std::sync::Arc<std::sync::Mutex<()>>,
798    /// Bumped on every commit. Live playback checks it each frame and stops
799    /// as soon as a new wallpaper supersedes the one it is playing.
800    playback_gen: std::sync::Arc<std::sync::atomic::AtomicU64>,
801    /// Wakes paced live-playback loops when a new commit bumps the generation,
802    /// so an old player exits immediately instead of after its sleep quantum.
803    pacer: std::sync::Arc<LivePacer>,
804    current_bind: Option<wgpu::BindGroup>,
805    current_tex: Option<wgpu::Texture>,
806    width: u32,
807    height: u32,
808    current_width: u32,
809    current_height: u32,
810    /// Video playback manager
811    video_playback: std::sync::Arc<crate::video::VideoPlayback>,
812    /// Hardware backend to request for new decoders (from `video.hw_decode`).
813    hw_accel: crate::video::HwAccel,
814    /// GPU adapter policy shared by all outputs in this daemon.
815    preferred_gpu: crate::video::GpuSelection,
816    /// Maximum decoded frames buffered ahead of presentation.
817    preload_frames: usize,
818    /// Optional cap for live video presentation.
819    max_fps: Option<u32>,
820    /// Current scaling mode for live playback.
821    scaling_mode: u32,
822    /// Effect used by the active request, for idempotent switch detection.
823    current_effect: Option<crate::animation::Effect>,
824    /// Per-output uniform buffer + bind group (Issue #9 race fix).
825    /// Path of the last wallpaper set on this output (for restore).
826    last_wallpaper: Option<std::path::PathBuf>,
827    /// Previous wallpaper state before blank (for restore).
828    pre_blank: Option<(std::path::PathBuf, u32)>,
829    /// Whether this output is currently blanked.
830    blanked: bool,
831    /// GIF playback paused state (shared with play_live task).
832    gif_paused: std::sync::Arc<std::sync::atomic::AtomicBool>,
833    /// Shared-memory fast path for completed static wallpapers. Dynamic
834    /// content and transitions continue to use wgpu; static content can drop
835    /// its GPU image and become compositor-owned with no render loop.
836    shm_surface: wl_surface::WlSurface,
837    /// Server handle for (re)creating the pool. `Shm` itself is not Clone,
838    /// but the underlying proxy is; a fresh handle is minted on demand.
839    shm_server: wayland_client::protocol::wl_shm::WlShm,
840    shm_pool: SlotPool,
841    /// Ping-pong shm buffers. The parked buffer is usually the currently
842    /// displayed one (legitimately compositor-held), so single-buffer reuse
843    /// can never hit in steady use; alternating guarantees the idle buffer
844    /// was released when its sibling was presented, bounding the pool.
845    shm_buffers: [Option<ShmBuffer>; 2],
846    shm_index: usize,
847    shm_width: u32,
848    shm_height: u32,
849    /// A layer surface must not switch from wgpu explicit-sync commits to
850    /// bufferless `wl_shm` commits. Some compositors reject that sequence
851    /// with a missing acquire timeline. Keep static requests on wgpu after
852    /// the first GPU presentation for this surface.
853    gpu_surface_used: bool,
854    /// Cached video texture for reuse across same-dimension video playback.
855    /// Reusing Y/UV planes, conversion resources, and output texture avoids
856    /// reallocating GPU memory on every video switch when resolution is unchanged.
857    /// Wrapped in Arc so it can be shared with active playback tasks.
858    cached_video_texture: Option<std::sync::Arc<crate::renderer::VideoTexture>>,
859    /// Whether videos restart from the beginning at end of stream.
860    loop_video: bool,
861}
862
863struct GpuState {
864    renderer: std::sync::Arc<Renderer>,
865    surface: &'static wgpu::Surface<'static>,
866    format: wgpu::TextureFormat,
867    per_output_uniforms: std::sync::Arc<crate::renderer::PerOutputUniforms>,
868}
869
870/// Everything the transition render task needs; the daemon state has already
871/// been promoted to the new wallpaper before a transition is spawned.
872struct CommitData {
873    bg_bind: wgpu::BindGroup,
874    new_bind: wgpu::BindGroup,
875    img_width: u32,
876    img_height: u32,
877    old_img_width: u32,
878    old_img_height: u32,
879    format: wgpu::TextureFormat,
880    width: u32,
881    height: u32,
882    /// Animated frames to play live after the transition, when the committed
883    /// file is a GIF.
884    animated: Option<crate::animated::AnimatedImage>,
885    /// Video metadata when committed file is a video.
886    is_video: bool,
887    /// Plane and conversion resources retained across transition and playback.
888    /// Shared via Arc so the cache can retain it for reuse after playback ends.
889    video_texture: Option<std::sync::Arc<crate::renderer::VideoTexture>>,
890    /// Playback generation captured at commit time; live playback stops when
891    /// it no longer matches `RenderState::playback_gen`.
892    generation: u64,
893    /// Scaling mode: 0=Fill, 1=Fit, 2=Stretch, 3=Center, 4=Tile.
894    scaling_mode: u32,
895    /// Optional cap for live video presentation.
896    max_fps: Option<u32>,
897}
898
899impl RenderState {
900    fn ensure_gpu(&mut self) -> anyhow::Result<()> {
901        if self.gpu.is_some() {
902            return Ok(());
903        }
904
905        let renderer = {
906            let mut shared = self
907                .renderer
908                .lock()
909                .map_err(|_| anyhow::anyhow!("renderer initialization lock poisoned"))?;
910            if let Some(renderer) = shared.as_ref() {
911                renderer.clone()
912            } else {
913                let renderer = tokio::runtime::Handle::current()
914                    .block_on(Renderer::new(&self.preferred_gpu))
915                    .map_err(|e| anyhow::anyhow!("GPU init failed: {e}"))?;
916                let renderer = std::sync::Arc::new(renderer);
917                *shared = Some(renderer.clone());
918                renderer
919            }
920        };
921
922        let window_handle = WaylandWindow {
923            display: self.display_ptr.0,
924            surface: self.raw_surface.0,
925        };
926        let wgpu_surface = renderer
927            .instance
928            .create_surface(&window_handle)
929            .map_err(|e| anyhow::anyhow!("wgpu surface creation failed: {e:?}"))?;
930        let formats = wgpu_surface.get_capabilities(&renderer.adapter).formats;
931        let format = formats
932            .iter()
933            .copied()
934            .find(|format| *format == wgpu::TextureFormat::Bgra8UnormSrgb)
935            .or_else(|| formats.into_iter().next())
936            .unwrap_or(wgpu::TextureFormat::Bgra8UnormSrgb);
937        wgpu_surface.configure(
938            &renderer.device,
939            &wgpu::SurfaceConfiguration {
940                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
941                format,
942                width: self.width,
943                height: self.height,
944                present_mode: wgpu::PresentMode::Fifo,
945                alpha_mode: wgpu::CompositeAlphaMode::Opaque,
946                view_formats: vec![],
947                desired_maximum_frame_latency: 1,
948            },
949        );
950        // SAFETY: the Wayland display and layer surface are retained by the
951        // daemon for the lifetime of this render state.
952        let wgpu_surface: wgpu::Surface<'static> = unsafe { std::mem::transmute(wgpu_surface) };
953        let surface: &'static wgpu::Surface<'static> = Box::leak(Box::new(wgpu_surface));
954        self.gpu = Some(GpuState {
955            renderer: renderer.clone(),
956            surface,
957            format,
958            per_output_uniforms: std::sync::Arc::new(renderer.create_per_output_uniforms()),
959        });
960        Ok(())
961    }
962
963    fn set_wallpaper(
964        &mut self,
965        path: &std::path::Path,
966        effect: &crate::animation::Effect,
967        duration_ms: u32,
968        scaling_mode: u32,
969    ) -> anyhow::Result<()> {
970        // Setting the already-active source is an idempotent operation. Do
971        // not decode it, allocate another GPU texture, or enqueue another
972        // transition merely because a watcher/IPC client repeated the same
973        // request. This is especially important for bursty wallpaper
974        // scripts, where the no-op path should be effectively free.
975        if !self.blanked
976            && (self.current_bind.is_some() || self.shm_buffers.iter().any(|b| b.is_some()))
977            && self.last_wallpaper.as_deref() == Some(path)
978            && self.scaling_mode == scaling_mode
979            && self.current_effect.as_ref() == Some(effect)
980        {
981            return Ok(());
982        }
983        // A zero-duration static commit does not need a shader or a swapchain
984        // frame. Hand the pixels directly to the compositor and release the
985        // persistent GPU image, matching the low-idle-cost architecture used
986        // by dedicated static wallpaper daemons.
987        let is_animated_image = matches!(
988            path.extension().and_then(|extension| extension.to_str()),
989            Some("gif" | "GIF" | "apng" | "APNG")
990        );
991        if duration_ms == 0
992            && !is_animated_image
993            && !crate::video::VideoDecoder::is_video_file(path)
994            && !self.gpu_surface_used
995            && self.commit_static_shm(path, scaling_mode)?
996        {
997            self.playback_gen.fetch_add(1, Ordering::SeqCst);
998            self.pacer.notify();
999            self.video_playback.stop();
1000            self.current_bind = None;
1001            self.current_tex = None;
1002            self.current_width = 0;
1003            self.current_height = 0;
1004            self.scaling_mode = scaling_mode;
1005            self.current_effect = Some(effect.clone());
1006            self.last_wallpaper = Some(path.to_path_buf());
1007            self.blanked = false;
1008            // Dropping the old bind group/texture is enough to release the
1009            // Rust-side ownership. A blocking device drain here makes every
1010            // static switch wait for unrelated GPU work even though the new
1011            // frame is already compositor-owned through wl_shm. The render
1012            // path polls when it actually needs synchronization.
1013            return Ok(());
1014        }
1015        self.ensure_gpu()?;
1016        let commit = self.commit_wallpaper(path, scaling_mode)?;
1017        self.gpu_surface_used = true;
1018        self.scaling_mode = scaling_mode;
1019        self.current_effect = Some(effect.clone());
1020        self.spawn_transition(commit, effect, duration_ms);
1021        // Update last_wallpaper after successful commit
1022        self.last_wallpaper = Some(path.to_path_buf());
1023        Ok(())
1024    }
1025
1026    fn commit_static_shm(
1027        &mut self,
1028        path: &std::path::Path,
1029        scaling_mode: u32,
1030    ) -> anyhow::Result<bool> {
1031        use image::{ImageDecoder, ImageReader};
1032
1033        // The wgpu transition task and the wl_shm fast path target the same
1034        // layer surface. Serialize the protocol commit so a static switch
1035        // cannot race an in-flight GPU present.
1036        // A transition can be parked indefinitely in FIFO present while a
1037        // compositor is suspended or a monitor is disabled. Never make an
1038        // IPC/static switch wait behind that task: fall back to the normal
1039        // GPU commit, which promotes the new generation and lets the stale
1040        // transition exit before it renders again.
1041        let Some(_render_guard) = self.render_lock.try_lock().ok() else {
1042            return Ok(false);
1043        };
1044
1045        let decoder = ImageReader::open(path)?.into_decoder()?;
1046        let (source_width, source_height) = decoder.dimensions();
1047        Renderer::validate_static_decode(source_width, source_height, decoder.total_bytes())?;
1048        let image = image::DynamicImage::from_decoder(decoder)?;
1049        let (rgba, width, height) = crate::renderer::prepare_image_rgba(
1050            &image,
1051            self.width,
1052            self.height,
1053            scaling_mode,
1054            wgpu::Limits::default().max_texture_dimension_2d,
1055        )?;
1056        if width != self.width || height != self.height {
1057            // wl_shm buffers are displayed at their native size. The GPU path
1058            // remains responsible for center/tile and other non-fullscreen
1059            // modes that cannot be represented by one compositor buffer.
1060            return Ok(false);
1061        }
1062
1063        let stride = width
1064            .checked_mul(4)
1065            .ok_or_else(|| anyhow::anyhow!("static image stride overflow"))?;
1066        // Ping-pong between two buffers. The parked buffer is usually the
1067        // currently displayed one (legitimately compositor-held, so waiting
1068        // on it is futile); alternating guarantees the idle sibling was
1069        // released when its partner was presented, so steady-state switching
1070        // never allocates and the pool never grows.
1071        let mut used_slot = 0usize;
1072        let mut chosen: Option<ShmBuffer> = None;
1073        if self.shm_width == width && self.shm_height == height {
1074            for _ in 0..2 {
1075                let slot = self.shm_index % 2;
1076                self.shm_index = self.shm_index.wrapping_add(1);
1077                if let Some(buffer) = self.shm_buffers[slot].take() {
1078                    if buffer.canvas(&mut self.shm_pool).is_some() {
1079                        chosen = Some(buffer);
1080                        used_slot = slot;
1081                        break;
1082                    }
1083                    self.shm_buffers[slot] = Some(buffer);
1084                }
1085            }
1086            if chosen.is_none() {
1087                // Both slots busy under sustained flood. Wait briefly for a
1088                // release on a detached task (never the Wayland event loop).
1089                'wait: for _ in 0..10 {
1090                    std::thread::sleep(std::time::Duration::from_millis(5));
1091                    for slot in 0..2 {
1092                        if let Some(buffer) = self.shm_buffers[slot].take() {
1093                            if buffer.canvas(&mut self.shm_pool).is_some() {
1094                                chosen = Some(buffer);
1095                                used_slot = slot;
1096                                break 'wait;
1097                            }
1098                            self.shm_buffers[slot] = Some(buffer);
1099                        }
1100                    }
1101                }
1102            }
1103        } else {
1104            // Output size changed: drop both parked buffers (rare path).
1105            self.shm_buffers = [None, None];
1106        }
1107        let buffer = match chosen {
1108            Some(buffer) => buffer,
1109            None => {
1110                // No free buffer. Never grow the pool beyond the pair:
1111                // pool growth is process-lifetime high-water billed to
1112                // post-workload RSS (measured ~390 MiB retained after 90
1113                // rapid switches with unbounded growth).
1114                if self.shm_width != width || self.shm_height != height {
1115                    // Output size changed: reset the pair (rare path).
1116                    self.shm_buffers = [None, None];
1117                    let (new_buffer, _) = self.shm_pool.create_buffer(
1118                        width as i32,
1119                        height as i32,
1120                        stride as i32,
1121                        wayland_client::protocol::wl_shm::Format::Xrgb8888,
1122                    )?;
1123                    used_slot = 0;
1124                    new_buffer
1125                } else if let Some(slot) = self.shm_buffers.iter().position(|slot| slot.is_none()) {
1126                    // Priming the pair: still bounded at two buffers.
1127                    let (new_buffer, _) = self.shm_pool.create_buffer(
1128                        width as i32,
1129                        height as i32,
1130                        stride as i32,
1131                        wayland_client::protocol::wl_shm::Format::Xrgb8888,
1132                    )?;
1133                    used_slot = slot;
1134                    new_buffer
1135                } else {
1136                    // Both slots busy: the compositor is more than a frame
1137                    // behind under flood. Fall back to the GPU path instead
1138                    // of growing the pool; it promotes its own texture and
1139                    // a later commit reuses a released slot.
1140                    return Ok(false);
1141                }
1142            }
1143        };
1144        let canvas = buffer
1145            .canvas(&mut self.shm_pool)
1146            .ok_or_else(|| anyhow::anyhow!("shared-memory wallpaper buffer is still active"))?;
1147        for (src, dst) in rgba
1148            .as_raw()
1149            .chunks_exact(4)
1150            .zip(canvas.chunks_exact_mut(4))
1151        {
1152            // XRGB8888 is stored as B,G,R,X on little-endian Wayland hosts.
1153            // Write channels directly so the hot conversion loop does not
1154            // construct a temporary slice for every pixel.
1155            dst[0] = src[2];
1156            dst[1] = src[1];
1157            dst[2] = src[0];
1158            dst[3] = 0xff;
1159        }
1160        buffer
1161            .attach_to(&self.shm_surface)
1162            .map_err(|e| anyhow::anyhow!(e))?;
1163        self.shm_surface
1164            .damage_buffer(0, 0, width as i32, height as i32);
1165        self.shm_surface.commit();
1166        self.shm_buffers[used_slot] = Some(buffer);
1167        self.shm_width = width;
1168        self.shm_height = height;
1169        Ok(true)
1170    }
1171
1172    /// Loads the new wallpaper and atomically promotes it to the current
1173    /// frame. The outgoing bind group stays alive for the transition, so the
1174    /// render task can keep drawing from it after this commit returns.
1175    fn commit_wallpaper(
1176        &mut self,
1177        path: &std::path::Path,
1178        scaling_mode: u32,
1179    ) -> anyhow::Result<CommitData> {
1180        use image::{ImageDecoder, ImageReader};
1181        let gpu = self
1182            .gpu
1183            .as_ref()
1184            .expect("GPU state required for dynamic content");
1185
1186        // Check if this is a video file FIRST
1187        if crate::video::VideoDecoder::is_video_file(path) {
1188            tracing::info!("Video file detected: {:?}", path);
1189
1190            let generation = self.playback_gen.load(Ordering::SeqCst).wrapping_add(1);
1191            let renderer = gpu.renderer.clone();
1192            // Prepare and validate the new decoder before replacing active
1193            // playback. A failed video therefore leaves the old wallpaper and
1194            // decoder untouched.
1195            let mut prepared = crate::video::VideoPlayback::prepare(
1196                path,
1197                self.hw_accel,
1198                self.preload_frames,
1199                self.loop_video,
1200                // Allow slow software decoders a fair window to produce the
1201                // first frame before falling back to a black start.
1202                std::time::Duration::from_secs(5),
1203                move |metadata| {
1204                    renderer
1205                        .validate_video_texture(metadata.width, metadata.height)
1206                        .map_err(crate::video::VideoError::GpuResourceCreation)
1207                },
1208            )?;
1209            let metadata = prepared.metadata().clone();
1210            let first_frame = prepared.take_first_frame();
1211
1212            let (tex_width, tex_height) = first_frame
1213                .as_ref()
1214                .map(|frame| (frame.width, frame.height))
1215                .unwrap_or((metadata.width, metadata.height));
1216
1217            // Reuse the cached video texture if dimensions match, avoiding
1218            // Y/UV/output texture reallocation when switching between same-resolution videos.
1219            let video_texture = if let Some(cached) = self.cached_video_texture.as_ref()
1220                && cached.matches_dimensions(tex_width, tex_height)
1221            {
1222                tracing::debug!("Reusing video texture for {}x{}", tex_width, tex_height);
1223                // Keep the Arc in the cache; clone for CommitData so both hold a reference
1224                cached.clone()
1225            } else {
1226                if let Some(previous) = self.cached_video_texture.as_ref() {
1227                    tracing::debug!(
1228                        "Video resolution changed, recreating texture: {}x{} -> {}x{}",
1229                        previous.width(),
1230                        previous.height(),
1231                        tex_width,
1232                        tex_height
1233                    );
1234                }
1235                let new_texture =
1236                    std::sync::Arc::new(gpu.renderer.create_video_texture(tex_width, tex_height)?);
1237                // Store in cache for future reuse
1238                self.cached_video_texture = Some(new_texture.clone());
1239                new_texture
1240            };
1241            let (img_width, img_height) = if let Some(frame) = first_frame {
1242                gpu.renderer
1243                    .update_video_texture(&video_texture, &frame.data)?;
1244                (frame.width, frame.height)
1245            } else {
1246                // WebGPU initializes the output to black when no frame arrives.
1247                tracing::warn!("No first frame available, using black texture");
1248                (metadata.width, metadata.height)
1249            };
1250            self.video_playback.commit(prepared, generation);
1251            self.playback_gen.store(generation, Ordering::SeqCst);
1252            self.pacer.notify();
1253            let new_tex = video_texture.texture().clone();
1254            let new_bind = video_texture.bind_group().clone();
1255
1256            let old_bind = self.current_bind.take();
1257            let (old_img_width, old_img_height) = if old_bind.is_some() {
1258                (self.current_width.max(1), self.current_height.max(1))
1259            } else {
1260                (img_width, img_height)
1261            };
1262            let bg_bind = old_bind.unwrap_or_else(|| new_bind.clone());
1263
1264            drop(self.current_tex.take());
1265            self.current_tex = Some(new_tex);
1266            self.current_bind = Some(new_bind.clone());
1267            self.current_width = img_width;
1268            self.current_height = img_height;
1269
1270            return Ok(CommitData {
1271                bg_bind,
1272                new_bind,
1273                img_width,
1274                img_height,
1275                old_img_width,
1276                old_img_height,
1277                format: gpu.format,
1278                width: self.width,
1279                height: self.height,
1280                animated: None,
1281                is_video: true,
1282                video_texture: Some(video_texture),
1283                generation,
1284                scaling_mode,
1285                max_fps: self.max_fps,
1286            });
1287        }
1288
1289        // Stream animated frames (GIF) on demand during playback; the
1290        // transition's incoming texture is the GIF's first frame.
1291        let mut animated = crate::animated::AnimatedImage::decode(path)?;
1292        let (new_tex, new_bind, img_width, img_height) = if let Some(anim) = animated.as_mut() {
1293            let (w, h) = (anim.width, anim.height);
1294            let (tex, bind) = gpu.renderer.create_texture(w, h)?;
1295            let first = anim.first_frame();
1296            if !first.is_empty() {
1297                gpu.renderer.update_texture(&tex, first, w, h);
1298            }
1299            (tex, bind, w, h)
1300        } else {
1301            let decoder = ImageReader::open(path)?.into_decoder()?;
1302            let (source_width, source_height) = decoder.dimensions();
1303            Renderer::validate_static_decode(source_width, source_height, decoder.total_bytes())?;
1304            let new_img = image::DynamicImage::from_decoder(decoder)?;
1305            let (tex, bind, width, height) =
1306                gpu.renderer
1307                    .load_texture(&new_img, self.width, self.height, scaling_mode)?;
1308            (tex, bind, width, height)
1309        };
1310
1311        // Only supersede active video playback after the replacement has
1312        // decoded and allocated successfully.
1313        self.video_playback.stop();
1314        // Release cached video texture when switching away from video.
1315        // Static and GIF wallpapers don't need NV12 conversion resources.
1316        self.cached_video_texture = None;
1317
1318        let old_bind = self.current_bind.take();
1319        let (old_img_width, old_img_height) = if old_bind.is_some() {
1320            (self.current_width.max(1), self.current_height.max(1))
1321        } else {
1322            (img_width, img_height)
1323        };
1324        // Keep the last image as the outgoing frame. On the first ever run,
1325        // using the incoming image for both sides is a clean no-op transition;
1326        // it avoids a black flash while still allowing the cached wallpaper
1327        // restored at daemon startup to become the real outgoing frame.
1328        let bg_bind = old_bind.unwrap_or_else(|| new_bind.clone());
1329
1330        drop(self.current_tex.take());
1331        self.current_tex = Some(new_tex);
1332        self.current_bind = Some(new_bind.clone());
1333        self.current_width = img_width;
1334        self.current_height = img_height;
1335
1336        let generation = self.playback_gen.fetch_add(1, Ordering::SeqCst) + 1;
1337        self.pacer.notify();
1338
1339        Ok(CommitData {
1340            bg_bind,
1341            new_bind,
1342            img_width,
1343            img_height,
1344            old_img_width,
1345            old_img_height,
1346            format: gpu.format,
1347            width: self.width,
1348            height: self.height,
1349            animated,
1350            is_video: false,
1351            video_texture: None,
1352            generation,
1353            scaling_mode,
1354            max_fps: self.max_fps,
1355        })
1356    }
1357
1358    /// Renders the committed transition on a detached blocking task. The IPC
1359    /// path never waits on GPU presents, so a stalled compositor (monitor
1360    /// off, suspend) cannot hang the daemon. Transitions are serialized by
1361    /// the render lock: a later one simply waits until the earlier drains.
1362    fn spawn_transition(
1363        &self,
1364        commit: CommitData,
1365        effect: &crate::animation::Effect,
1366        duration_ms: u32,
1367    ) {
1368        let gpu = self
1369            .gpu
1370            .as_ref()
1371            .expect("GPU state required for transitions");
1372        let renderer = gpu.renderer.clone();
1373        let surface: &'static wgpu::Surface<'static> = gpu.surface;
1374        let render_lock = self.render_lock.clone();
1375        let playback_gen = self.playback_gen.clone();
1376        let pacer = self.pacer.clone();
1377        let video_playback = self.video_playback.clone();
1378        let per_output_uniforms = std::sync::Arc::clone(&gpu.per_output_uniforms);
1379        let gif_paused = self.gif_paused.clone();
1380        let effect = effect.clone();
1381        drop(tokio::task::spawn_blocking(move || {
1382            render_transition(
1383                renderer,
1384                surface,
1385                render_lock,
1386                playback_gen,
1387                pacer,
1388                video_playback,
1389                gif_paused,
1390                commit,
1391                effect,
1392                duration_ms,
1393                &per_output_uniforms,
1394            );
1395        }));
1396    }
1397}
1398
1399async fn restore_cached_wallpaper(name: &str, render_state: &Arc<Mutex<RenderState>>) {
1400    let state_root = wallpaper_state_root();
1401    let Some(path) = read_wallpaper_state(&state_root, "last_wallpaper", name) else {
1402        return;
1403    };
1404
1405    let effect = crate::animation::Effect::Fade(crate::animation::FadeParams::default());
1406    // Restoring a cached static wallpaper does not need an entrance
1407    // transition. Avoid decoding/uploading a temporary GPU frame at daemon
1408    // startup; static images can settle directly through the zero-duration
1409    // path, while GIF/video inputs still select their dynamic pipeline.
1410    if let Err(err) = set_wallpaper_with_retry(render_state, &path, &effect, 0, 0).await {
1411        tracing::warn!("Failed to restore wallpaper for {name} from {path:?}: {err}");
1412        let Some(previous) = read_wallpaper_state(&state_root, "previous_wallpaper", name) else {
1413            return;
1414        };
1415        match set_wallpaper_with_retry(render_state, &previous, &effect, 0, 0).await {
1416            Ok(()) => {
1417                if let Err(persist_err) =
1418                    write_wallpaper_state(&state_root, "last_wallpaper", name, &previous)
1419                {
1420                    tracing::warn!(
1421                        "Restored previous wallpaper for {name}, but failed to update state: {persist_err}"
1422                    );
1423                } else {
1424                    tracing::warn!("Restored previous wallpaper for {name} after {path:?} failed");
1425                }
1426            }
1427            Err(previous_err) => tracing::warn!(
1428                "Failed to restore previous wallpaper for {name} from {previous:?}: {previous_err}"
1429            ),
1430        }
1431    }
1432}
1433
1434const WALLPAPER_RETRY_ATTEMPTS: usize = 3;
1435const WALLPAPER_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(150);
1436/// `SlotPool` grows automatically when a buffer does not fit. Keep daemon
1437/// startup cheap instead of reserving a full output-sized shm mapping before
1438/// the first static wallpaper is requested.
1439const INITIAL_SHM_POOL_BYTES: usize = 4096;
1440
1441async fn set_wallpaper_with_retry(
1442    render_state: &Arc<Mutex<RenderState>>,
1443    path: &std::path::Path,
1444    effect: &crate::animation::Effect,
1445    duration_ms: u32,
1446    scaling_mode: u32,
1447) -> anyhow::Result<()> {
1448    let render_state = Arc::clone(render_state);
1449    let path = path.to_path_buf();
1450    let effect = effect.clone();
1451
1452    tokio::task::spawn_blocking(move || {
1453        let mut attempt = 1;
1454        loop {
1455            let result = {
1456                let mut state = render_state.blocking_lock();
1457                state.set_wallpaper(&path, &effect, duration_ms, scaling_mode)
1458            };
1459            match result {
1460                Ok(()) => return Ok(()),
1461                Err(err)
1462                    if attempt < WALLPAPER_RETRY_ATTEMPTS
1463                        && is_transient_wallpaper_error(&err) =>
1464                {
1465                    tracing::warn!(
1466                        "Transient wallpaper error for {path:?} (attempt {attempt}/{WALLPAPER_RETRY_ATTEMPTS}): {err}"
1467                    );
1468                    attempt += 1;
1469                    std::thread::sleep(WALLPAPER_RETRY_DELAY);
1470                }
1471                Err(err) => return Err(err),
1472            }
1473        }
1474    })
1475    .await?
1476}
1477
1478fn is_transient_wallpaper_error(err: &anyhow::Error) -> bool {
1479    err.chain().any(|cause| {
1480        cause
1481            .downcast_ref::<std::io::Error>()
1482            .is_some_and(|io_err| {
1483                matches!(
1484                    io_err.kind(),
1485                    std::io::ErrorKind::Interrupted
1486                        | std::io::ErrorKind::WouldBlock
1487                        | std::io::ErrorKind::TimedOut
1488                )
1489            })
1490            || cause
1491                .downcast_ref::<crate::video::VideoError>()
1492                .is_some_and(crate::video::VideoError::is_recoverable)
1493    })
1494}
1495
1496fn wallpaper_state_root() -> std::path::PathBuf {
1497    dirs::cache_dir()
1498        .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
1499        .join("wallr")
1500}
1501
1502fn wallpaper_state_path(root: &std::path::Path, state_dir: &str, name: &str) -> std::path::PathBuf {
1503    root.join(state_dir).join(name)
1504}
1505
1506fn read_wallpaper_state(
1507    root: &std::path::Path,
1508    state_dir: &str,
1509    name: &str,
1510) -> Option<std::path::PathBuf> {
1511    use std::os::unix::ffi::OsStringExt;
1512
1513    let path = std::fs::read(wallpaper_state_path(root, state_dir, name)).ok()?;
1514    let wallpaper = std::path::PathBuf::from(std::ffi::OsString::from_vec(path));
1515    (!wallpaper.as_os_str().is_empty()).then_some(wallpaper)
1516}
1517
1518fn write_wallpaper_state(
1519    root: &std::path::Path,
1520    state_dir: &str,
1521    name: &str,
1522    wallpaper: &std::path::Path,
1523) -> std::io::Result<()> {
1524    use std::io::Write;
1525    use std::os::unix::ffi::OsStrExt;
1526    use std::sync::atomic::{AtomicU64, Ordering};
1527
1528    static TEMP_ID: AtomicU64 = AtomicU64::new(0);
1529
1530    let state_path = wallpaper_state_path(root, state_dir, name);
1531    let parent = state_path
1532        .parent()
1533        .ok_or_else(|| std::io::Error::other("wallpaper state path has no parent"))?;
1534    std::fs::create_dir_all(parent)?;
1535    let file_name = state_path
1536        .file_name()
1537        .and_then(|value| value.to_str())
1538        .unwrap_or("wallpaper");
1539
1540    loop {
1541        let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
1542        let temporary_path = parent.join(format!(".{file_name}.{}.{}.tmp", std::process::id(), id));
1543        match std::fs::OpenOptions::new()
1544            .write(true)
1545            .create_new(true)
1546            .open(&temporary_path)
1547        {
1548            Ok(mut temporary) => {
1549                if let Err(err) = temporary
1550                    .write_all(wallpaper.as_os_str().as_bytes())
1551                    .and_then(|()| temporary.sync_all())
1552                    .and_then(|()| std::fs::rename(&temporary_path, &state_path))
1553                {
1554                    let _ = std::fs::remove_file(&temporary_path);
1555                    return Err(err);
1556                }
1557                return Ok(());
1558            }
1559            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
1560            Err(err) => return Err(err),
1561        }
1562    }
1563}
1564
1565fn persist_wallpaper(name: &str, wallpaper: &std::path::Path) -> std::io::Result<()> {
1566    let root = wallpaper_state_root();
1567    persist_wallpaper_at(&root, name, wallpaper)
1568}
1569
1570fn persist_wallpaper_at(
1571    root: &std::path::Path,
1572    name: &str,
1573    wallpaper: &std::path::Path,
1574) -> std::io::Result<()> {
1575    let current = read_wallpaper_state(root, "last_wallpaper", name);
1576    // Hot-path no-op: a repeated set of the already-active wallpaper (bursty
1577    // scripts, benchmark loops) must not pay two temp-file writes, two
1578    // fsyncs, and two renames per call. The on-disk state is already correct,
1579    // and fsync latency spikes are the dominant jitter source on this path.
1580    if current.as_deref() == Some(wallpaper) {
1581        return Ok(());
1582    }
1583    let previous = current.filter(|current| current.exists() && current != wallpaper);
1584    write_wallpaper_state(root, "last_wallpaper", name, wallpaper)?;
1585    if let Some(previous) = previous {
1586        write_wallpaper_state(root, "previous_wallpaper", name, &previous)?;
1587    }
1588    Ok(())
1589}
1590
1591/// Presents one frame per vsync until the wall-clock duration elapses. With
1592/// PresentMode::Fifo, `get_current_texture` blocks until the previous frame
1593/// is presented, so this loop is paced to the monitor refresh rate, and the
1594/// transition lasts exactly `duration_ms` on any refresh rate - frame-count
1595/// pacing would run too fast on high-refresh panels and too slow when the
1596/// present rate is low. If the compositor stops presenting, the loop can park
1597/// inside a present; that is fine here because the task is detached.
1598#[allow(clippy::too_many_arguments)]
1599fn render_transition(
1600    renderer: std::sync::Arc<Renderer>,
1601    surface: &'static wgpu::Surface<'static>,
1602    render_lock: std::sync::Arc<std::sync::Mutex<()>>,
1603    playback_gen: std::sync::Arc<std::sync::atomic::AtomicU64>,
1604    pacer: std::sync::Arc<LivePacer>,
1605    video_playback: std::sync::Arc<crate::video::VideoPlayback>,
1606    gif_paused: std::sync::Arc<std::sync::atomic::AtomicBool>,
1607    mut commit: CommitData,
1608    effect: crate::animation::Effect,
1609    duration_ms: u32,
1610    per_output_uniforms: &crate::renderer::PerOutputUniforms,
1611) {
1612    // Do this check before taking the render lock. Rapid IPC updates can
1613    // otherwise queue several detached tasks, each retaining a complete
1614    // incoming/outgoing GPU resource pair while waiting for its turn.
1615    if playback_gen.load(Ordering::Acquire) != commit.generation {
1616        reclaim_commit_resources(&renderer, commit);
1617        return;
1618    }
1619    let _guard = render_lock
1620        .lock()
1621        .unwrap_or_else(|poisoned| poisoned.into_inner());
1622
1623    let duration = std::time::Duration::from_millis(u64::from(duration_ms.max(1)));
1624    let start = std::time::Instant::now();
1625    loop {
1626        // A newer commit owns the output now. Abort before doing any more
1627        // CPU/GPU work so superseded transitions release their bind groups,
1628        // textures, and task stack immediately instead of queueing behind the
1629        // render lock for the full transition duration.
1630        if playback_gen.load(Ordering::Acquire) != commit.generation {
1631            reclaim_commit_resources(&renderer, commit);
1632            return;
1633        }
1634        let progress = start.elapsed().as_secs_f32() / duration.as_secs_f32();
1635        let uniforms = crate::animation::compute_effect_uniforms(&effect, progress.clamp(0.0, 1.0));
1636        let status = renderer.render_frame(
1637            crate::renderer::FrameRequest {
1638                surface,
1639                format: commit.format,
1640                bg_bind: &commit.bg_bind,
1641                new_bind: &commit.new_bind,
1642                effect: &uniforms,
1643                width: commit.width,
1644                height: commit.height,
1645                img_width: commit.img_width,
1646                img_height: commit.img_height,
1647                old_img_width: commit.old_img_width,
1648                old_img_height: commit.old_img_height,
1649                scaling_mode: commit.scaling_mode,
1650            },
1651            per_output_uniforms,
1652        );
1653        let status = match status {
1654            Ok(status) => status,
1655            Err(err) => {
1656                eprintln!("wallr: transition render failed: {err}");
1657                break;
1658            }
1659        };
1660        if progress >= 1.0 {
1661            break;
1662        }
1663        match status {
1664            crate::renderer::FrameStatus::Presented => {}
1665            // A stalled compositor parks inside `get_current_texture`; a
1666            // timeout just means "try the next vsync" without breaking the
1667            // wall-clock duration. Yield briefly so a struggling compositor
1668            // is not hammered with back-to-back acquires.
1669            crate::renderer::FrameStatus::TimedOut => {
1670                std::thread::sleep(std::time::Duration::from_millis(4));
1671            }
1672            // The swapchain is stale (resize, scale, recreation). Reconfigure
1673            // once and continue the transition instead of blanking.
1674            crate::renderer::FrameStatus::Outdated | crate::renderer::FrameStatus::Lost => {
1675                surface.configure(
1676                    &renderer.device,
1677                    &wgpu::SurfaceConfiguration {
1678                        usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1679                        format: commit.format,
1680                        width: commit.width.max(1),
1681                        height: commit.height.max(1),
1682                        present_mode: wgpu::PresentMode::Fifo,
1683                        alpha_mode: wgpu::CompositeAlphaMode::Opaque,
1684                        view_formats: vec![],
1685                        desired_maximum_frame_latency: 1,
1686                    },
1687                );
1688            }
1689        }
1690    }
1691
1692    // The transition ended; if the committed wallpaper is an animated GIF and
1693    // nothing superseded it while we rendered, keep the render lock and play
1694    // the frames live until the next commit bumps the generation.
1695    let mut animated = commit.animated.take();
1696    if let Some(animated) = animated.as_mut()
1697        && playback_gen.load(Ordering::SeqCst) == commit.generation
1698    {
1699        play_live(
1700            &renderer,
1701            surface,
1702            &commit,
1703            animated,
1704            &playback_gen,
1705            &pacer,
1706            &gif_paused,
1707            per_output_uniforms,
1708        );
1709    } else if commit.is_video && playback_gen.load(Ordering::SeqCst) == commit.generation {
1710        play_video(
1711            &renderer,
1712            surface,
1713            &commit,
1714            &video_playback,
1715            &playback_gen,
1716            &pacer,
1717            per_output_uniforms,
1718        );
1719    }
1720    // Ensure resources from a completed static transition, or a live player
1721    // that just stopped, are handed back to the backend before the detached
1722    // task disappears. This is intentionally outside the frame hot path.
1723    reclaim_commit_resources(&renderer, commit);
1724}
1725
1726fn reclaim_commit_resources(renderer: &Renderer, commit: CommitData) {
1727    drop(commit);
1728    renderer.device.poll(wgpu::Maintain::Wait);
1729}
1730
1731/// GIF double-buffer slot advance.
1732///
1733/// Returns the new (displayed texture index, displayed frame, idle-texture
1734/// frame) plus whether the requested frame can be shown. A failed upload
1735/// leaves the previous frame presented so the caller retries; flipping to
1736/// an unwritten texture makes the wallpaper visibly alternate between the
1737/// old and new image.
1738fn advance_gif_slots(
1739    cur: usize,
1740    cur_frame: usize,
1741    next_frame: usize,
1742    index: usize,
1743    upload_ok: bool,
1744) -> (usize, usize, usize, bool) {
1745    if index == cur_frame {
1746        return (cur, cur_frame, next_frame, true);
1747    }
1748    let target = cur ^ 1;
1749    if next_frame == index {
1750        // The idle texture was already warmed with this frame.
1751        return (target, index, next_frame, true);
1752    }
1753    if upload_ok {
1754        return (target, index, index, true);
1755    }
1756    (cur, cur_frame, next_frame, false)
1757}
1758
1759/// Presents live wallpaper frames until the next commit. One frame is
1760/// presented per GIF frame boundary instead of at the monitor refresh rate.
1761/// Two textures are double-buffered and frames are decompressed directly
1762/// into a mapped staging ring (no intermediate copy), so the wake path only
1763/// presents and the pacing sleep hides the decode/upload entirely.
1764#[allow(clippy::too_many_arguments)]
1765fn play_live(
1766    renderer: &Renderer,
1767    surface: &'static wgpu::Surface<'static>,
1768    commit: &CommitData,
1769    animated: &mut crate::animated::AnimatedImage,
1770    playback_gen: &std::sync::atomic::AtomicU64,
1771    pacer: &LivePacer,
1772    gif_paused: &std::sync::Arc<std::sync::atomic::AtomicBool>,
1773    per_output_uniforms: &crate::renderer::PerOutputUniforms,
1774) {
1775    let Ok((tex_a, bind_a)) = renderer.create_texture(animated.width, animated.height) else {
1776        tracing::warn!(
1777            "GIF dimensions {}x{} exceed the GPU texture limit",
1778            animated.width,
1779            animated.height
1780        );
1781        return;
1782    };
1783    let Ok((tex_b, bind_b)) = renderer.create_texture(animated.width, animated.height) else {
1784        tracing::warn!(
1785            "GIF dimensions {}x{} exceed the GPU texture limit",
1786            animated.width,
1787            animated.height
1788        );
1789        return;
1790    };
1791    let (frame_w, frame_h) = (animated.width, animated.height);
1792    let (bytes_per_row, rows) = (frame_w * 4, frame_h);
1793    let frame_bytes = bytes_per_row as u64 * rows as u64;
1794
1795    // Map+decompress+copy path needs a byte-per-row multiple of the copy
1796    // alignment; fall back to write_texture for odd widths.
1797    let direct_upload = bytes_per_row % 256 == 0;
1798    let staging: Vec<wgpu::Buffer> = if direct_upload {
1799        (0..2)
1800            .map(|_| {
1801                renderer.device.create_buffer(&wgpu::BufferDescriptor {
1802                    label: Some("wallr-gif-staging"),
1803                    size: frame_bytes,
1804                    usage: wgpu::BufferUsages::MAP_WRITE | wgpu::BufferUsages::COPY_SRC,
1805                    mapped_at_creation: false,
1806                })
1807            })
1808            .collect()
1809    } else {
1810        Vec::new()
1811    };
1812
1813    let first = animated.first_frame();
1814    if !first.is_empty() {
1815        renderer.update_texture(&tex_a, first, frame_w, frame_h);
1816        renderer.update_texture(&tex_b, first, frame_w, frame_h);
1817    }
1818    let binds = [bind_a, bind_b];
1819    let textures = [tex_a, tex_b];
1820
1821    // Uploads frame `index` into `textures[tgt]`. Returns true when the GPU
1822    // copy was recorded.
1823    let upload = |renderer: &Renderer,
1824                  tgt: usize,
1825                  index: usize,
1826                  slot: usize,
1827                  animated: &mut crate::animated::AnimatedImage|
1828     -> bool {
1829        if direct_upload {
1830            let buffer = &staging[slot];
1831            let slice = buffer.slice(..);
1832            slice.map_async(wgpu::MapMode::Write, |_| {});
1833            renderer.device.poll(wgpu::Maintain::Wait);
1834            let ok = {
1835                let mut mapped = slice.get_mapped_range_mut();
1836                animated.decompress_into(index, &mut mapped)
1837            };
1838            buffer.unmap();
1839            if ok {
1840                let mut encoder = renderer
1841                    .device
1842                    .create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
1843                encoder.copy_buffer_to_texture(
1844                    wgpu::TexelCopyBufferInfo {
1845                        buffer,
1846                        layout: wgpu::TexelCopyBufferLayout {
1847                            offset: 0,
1848                            bytes_per_row: Some(bytes_per_row),
1849                            rows_per_image: Some(rows),
1850                        },
1851                    },
1852                    wgpu::TexelCopyTextureInfo {
1853                        texture: &textures[tgt],
1854                        mip_level: 0,
1855                        origin: wgpu::Origin3d::ZERO,
1856                        aspect: wgpu::TextureAspect::All,
1857                    },
1858                    wgpu::Extent3d {
1859                        width: frame_w,
1860                        height: frame_h,
1861                        depth_or_array_layers: 1,
1862                    },
1863                );
1864                renderer.queue.submit([encoder.finish()]);
1865                return true;
1866            }
1867        } else {
1868            let frame = animated.frame_at(index);
1869            if !frame.is_empty() {
1870                renderer.update_texture(&textures[tgt], frame, frame_w, frame_h);
1871                return true;
1872            }
1873        }
1874        false
1875    };
1876
1877    let mut cur = 0usize; // texture index currently holding the presented frame
1878    let mut cur_frame = 0usize; // frame index currently in texture `cur`
1879    let mut next_frame = 0usize; // frame index currently in the idle texture
1880    let mut slot = 0usize; // staging ring slot for the next upload
1881    let start = std::time::Instant::now();
1882    let mut paused_elapsed = std::time::Duration::ZERO; // Accumulated pause time
1883    let static_effect = crate::animation::Effect::Fade(crate::animation::FadeParams::default());
1884    loop {
1885        if playback_gen.load(Ordering::SeqCst) != commit.generation {
1886            return;
1887        }
1888
1889        // Check if paused - if so, keep presenting the current frame but don't advance
1890        if gif_paused.load(Ordering::SeqCst) {
1891            // The compositor retains the last committed buffer. Re-presenting
1892            // an identical frame while paused only burns CPU/GPU time and
1893            // wakes laptops needlessly. Park until resume or a replacement
1894            // wallpaper notifies the shared pacer.
1895            let pause_start = std::time::Instant::now();
1896            pacer.wait_until(std::time::Instant::now() + std::time::Duration::from_secs(86_400));
1897            paused_elapsed += pause_start.elapsed();
1898            continue;
1899        }
1900
1901        let index = animated.frame_index_at(start.elapsed() - paused_elapsed);
1902        if index != cur_frame {
1903            // Advance the double buffer only when the next frame actually
1904            // reaches the GPU. Uploads fail transiently while the streaming
1905            // decoder catches up (loop wrap, uncached tail). Flipping the
1906            // presented texture without data would show a stale frame for a
1907            // whole frame period: the on-screen result looks exactly like
1908            // the wallpaper alternating old/new/old as uploads alternate
1909            // success and failure. Keep displaying the last good frame and
1910            // retry on the next pass instead.
1911            let target = cur ^ 1;
1912            let upload_ok = if next_frame == index {
1913                true
1914            } else {
1915                let ok = upload(renderer, target, index, slot, animated);
1916                if ok {
1917                    slot ^= 1;
1918                }
1919                ok
1920            };
1921            let (new_cur, new_cur_frame, new_next, displayable) =
1922                advance_gif_slots(cur, cur_frame, next_frame, index, upload_ok);
1923            if !displayable {
1924                // The decoder has not caught up yet. Yield briefly so it can
1925                // produce the frame instead of busy-spinning the program
1926                // whole (a spin keeps every present late and reads as
1927                // heavy frame-by-frame staggering); then retry.
1928                pacer.wait_until(std::time::Instant::now() + std::time::Duration::from_millis(2));
1929                continue;
1930            }
1931            cur = new_cur;
1932            cur_frame = new_cur_frame;
1933            next_frame = new_next;
1934        }
1935        let uniforms = crate::animation::compute_effect_uniforms(&static_effect, 1.0);
1936        let status = renderer.render_frame(
1937            crate::renderer::FrameRequest {
1938                surface,
1939                format: commit.format,
1940                bg_bind: &binds[cur],
1941                new_bind: &binds[cur],
1942                effect: &uniforms,
1943                width: commit.width,
1944                height: commit.height,
1945                img_width: animated.width,
1946                img_height: animated.height,
1947                old_img_width: animated.width,
1948                old_img_height: animated.height,
1949                scaling_mode: commit.scaling_mode,
1950            },
1951            per_output_uniforms,
1952        );
1953        match status {
1954            Ok(crate::renderer::FrameStatus::Presented) => {}
1955            // A timeout is transient compositor backpressure, not a reason
1956            // to stop the animation: yield briefly and keep presenting.
1957            // (Previously any non-present status killed GIF playback.)
1958            Ok(crate::renderer::FrameStatus::TimedOut) => {
1959                std::thread::sleep(std::time::Duration::from_millis(4));
1960            }
1961            Ok(crate::renderer::FrameStatus::Outdated | crate::renderer::FrameStatus::Lost) => {
1962                surface.configure(
1963                    &renderer.device,
1964                    &wgpu::SurfaceConfiguration {
1965                        usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1966                        format: commit.format,
1967                        width: commit.width.max(1),
1968                        height: commit.height.max(1),
1969                        present_mode: wgpu::PresentMode::Fifo,
1970                        alpha_mode: wgpu::CompositeAlphaMode::Opaque,
1971                        view_formats: vec![],
1972                        desired_maximum_frame_latency: 1,
1973                    },
1974                );
1975            }
1976            Err(err) => {
1977                tracing::warn!("GIF present failed ({err}), stopping playback");
1978                return;
1979            }
1980        }
1981
1982        // Pace to the next GIF frame boundary instead of presenting at the
1983        // monitor refresh rate: an animated wallpaper only needs a present
1984        // when its frame changes. A commit wakes us via the pacer. The
1985        // boundary is computed in absolute time (frame_start is loop-relative,
1986        // so add the completed loops) to stay correct after the animation
1987        // wraps. While waiting, warm the idle texture with the next frame so
1988        // the wake path stays on the hot critical section.
1989        let elapsed = start.elapsed() - paused_elapsed;
1990        let total: std::time::Duration = animated.total_duration();
1991        let loops = (elapsed.as_millis() / total.as_millis().max(1)) as u64;
1992        let next_change = animated.frame_start(index + 1) + total * (loops as u32);
1993        let wait = next_change.saturating_sub(elapsed);
1994        if wait > std::time::Duration::ZERO {
1995            let next = index + 1;
1996            if next_frame != next && upload(renderer, cur ^ 1, next, slot, animated) {
1997                slot ^= 1;
1998                next_frame = next;
1999            }
2000            // A failed warm leaves next_frame stale on purpose: the boundary
2001            // path above re-attempts the upload when the frame is needed.
2002            pacer.wait_until(std::time::Instant::now() + wait);
2003        }
2004    }
2005}
2006
2007#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2008enum VideoPresentAction {
2009    Presented,
2010    Retry,
2011    Reconfigure,
2012}
2013
2014fn video_present_action(status: crate::renderer::FrameStatus) -> VideoPresentAction {
2015    match status {
2016        crate::renderer::FrameStatus::Presented => VideoPresentAction::Presented,
2017        crate::renderer::FrameStatus::TimedOut => VideoPresentAction::Retry,
2018        crate::renderer::FrameStatus::Outdated | crate::renderer::FrameStatus::Lost => {
2019            VideoPresentAction::Reconfigure
2020        }
2021    }
2022}
2023
2024/// Live video playback loop: continuously updates texture with decoded frames.
2025fn play_video(
2026    renderer: &Renderer,
2027    surface: &'static wgpu::Surface<'static>,
2028    commit: &CommitData,
2029    video_playback: &std::sync::Arc<crate::video::VideoPlayback>,
2030    playback_gen: &std::sync::atomic::AtomicU64,
2031    pacer: &LivePacer,
2032    per_output_uniforms: &crate::renderer::PerOutputUniforms,
2033) {
2034    let mut width = commit.img_width;
2035    let mut height = commit.img_height;
2036
2037    let Some(initial) = commit.video_texture.as_ref() else {
2038        tracing::warn!("Video conversion resources unavailable");
2039        return;
2040    };
2041    // Task-local texture handle. Unchanged resolution reuses the commit's
2042    // GPU resources every frame (upload + NV12 conversion only); only a
2043    // genuine mid-stream resolution change recreates them.
2044    let mut owned_replacement: Option<crate::renderer::VideoTexture> = None;
2045    let mut texture: &crate::renderer::VideoTexture = initial.as_ref();
2046    let static_effect = crate::animation::Effect::Fade(crate::animation::FadeParams::default());
2047
2048    let min_frame_interval = commit
2049        .max_fps
2050        .filter(|fps| *fps > 0)
2051        .map(|fps| std::time::Duration::from_secs_f64(1.0 / f64::from(fps)));
2052    let mut last_present: Option<std::time::Instant> = None;
2053    // Watchdog: if the decoder dies or stalls, stop instead of presenting
2054    // the same frozen frame forever at a millisecond cadence.
2055    let mut last_frame_at = std::time::Instant::now();
2056
2057    loop {
2058        // A newer commit superseded us. Do NOT touch the shared
2059        // `video_playback` here: the successor commit already replaced the
2060        // decoder (video) or stopped it (static image), and stopping it now
2061        // would kill the successor's playback too.
2062        if playback_gen.load(Ordering::SeqCst) != commit.generation {
2063            return;
2064        }
2065
2066        if let (Some(interval), Some(previous)) = (min_frame_interval, last_present) {
2067            pacer.wait_until(previous + interval);
2068            if playback_gen.load(Ordering::SeqCst) != commit.generation {
2069                return;
2070            }
2071        }
2072
2073        // Pull the next displayable frame. The decoder queue is bounded, so
2074        // this never blocks; unchanged frames need no upload or presentation.
2075        let mut frame_uploaded = false;
2076        if let Some(frame) = video_playback.next_frame_in_generation(commit.generation) {
2077            // Mid-stream resolution change (rare): recreate only the video
2078            // conversion resources instead of dropping every subsequent frame.
2079            if frame.width != width || frame.height != height {
2080                match renderer.create_video_texture(frame.width, frame.height) {
2081                    Ok(replacement) => {
2082                        tracing::info!(
2083                            "Video resolution changed {}x{} -> {}x{}, recreated conversion resources",
2084                            width,
2085                            height,
2086                            frame.width,
2087                            frame.height
2088                        );
2089                        width = frame.width;
2090                        height = frame.height;
2091                        // Drop any previous mid-stream replacement before
2092                        // installing the new one so only one spare texture
2093                        // is ever alive in this task.
2094                        owned_replacement.take();
2095                        owned_replacement = Some(replacement);
2096                        texture = owned_replacement.as_ref().expect("just stored");
2097                    }
2098                    Err(err) => {
2099                        tracing::warn!("Video resolution change rejected: {err}");
2100                        pacer.wait_until(
2101                            std::time::Instant::now() + std::time::Duration::from_millis(2),
2102                        );
2103                        continue;
2104                    }
2105                }
2106            }
2107            if let Err(err) = renderer.update_video_texture(texture, &frame.data) {
2108                // A transient GPU hiccup must not end playback; retry on the
2109                // next frame instead.
2110                tracing::warn!("Video frame upload failed: {err}");
2111            } else {
2112                last_frame_at = std::time::Instant::now();
2113                frame_uploaded = true;
2114            }
2115            // `frame` owns the only copy of the decoded planes; it drops here
2116            // after GPU upload instead of lingering in any queue or cache.
2117        }
2118
2119        if !frame_uploaded {
2120            if playback_gen.load(Ordering::SeqCst) != commit.generation {
2121                return;
2122            }
2123            if video_playback.is_paused() {
2124                // Deliberately paused: rest cheaply and wait for resume.
2125                pacer.wait_until(std::time::Instant::now() + std::time::Duration::from_millis(100));
2126                continue;
2127            }
2128            if last_frame_at.elapsed() > std::time::Duration::from_secs(5) {
2129                tracing::warn!("Video playback stalled, stopping");
2130                return;
2131            }
2132            let wait = video_playback
2133                .time_until_next_frame_in_generation(commit.generation)
2134                .unwrap_or(std::time::Duration::from_millis(2));
2135            if playback_gen.load(Ordering::SeqCst) != commit.generation {
2136                return;
2137            }
2138            pacer.wait_until(std::time::Instant::now() + wait);
2139            continue;
2140        }
2141
2142        let uniforms = crate::animation::compute_effect_uniforms(&static_effect, 1.0);
2143        let status = renderer.render_frame(
2144            crate::renderer::FrameRequest {
2145                surface,
2146                format: commit.format,
2147                bg_bind: texture.bind_group(),
2148                new_bind: texture.bind_group(),
2149                effect: &uniforms,
2150                width: commit.width,
2151                height: commit.height,
2152                img_width: width,
2153                img_height: height,
2154                old_img_width: width,
2155                old_img_height: height,
2156                scaling_mode: commit.scaling_mode,
2157            },
2158            per_output_uniforms,
2159        );
2160
2161        match status.map(video_present_action) {
2162            Ok(VideoPresentAction::Presented) => {
2163                last_present = Some(std::time::Instant::now());
2164            }
2165            Ok(action) => {
2166                if action == VideoPresentAction::Reconfigure {
2167                    surface.configure(
2168                        &renderer.device,
2169                        &wgpu::SurfaceConfiguration {
2170                            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
2171                            format: commit.format,
2172                            width: commit.width,
2173                            height: commit.height,
2174                            present_mode: wgpu::PresentMode::Fifo,
2175                            alpha_mode: wgpu::CompositeAlphaMode::Opaque,
2176                            view_formats: vec![],
2177                            desired_maximum_frame_latency: 1,
2178                        },
2179                    );
2180                }
2181                pacer.wait_until(std::time::Instant::now() + std::time::Duration::from_millis(100));
2182            }
2183            Err(err) => {
2184                tracing::warn!("Video present failed ({err}), stopping playback");
2185                video_playback.stop();
2186                return;
2187            }
2188        }
2189    }
2190}
2191
2192/// Resolve target render states from an optional monitor name.
2193/// Returns all states when `monitor` is None, or the specific named state.
2194/// Returns an empty vec for unknown monitor names (letting callers return
2195/// an error).
2196async fn resolve_targets(
2197    render_states: &std::collections::HashMap<
2198        String,
2199        std::sync::Arc<tokio::sync::Mutex<RenderState>>,
2200    >,
2201    monitor: Option<&str>,
2202) -> Vec<std::sync::Arc<tokio::sync::Mutex<RenderState>>> {
2203    match monitor {
2204        Some(name) => {
2205            if let Some(rs) = render_states.get(name) {
2206                vec![rs.clone()]
2207            } else {
2208                Vec::new()
2209            }
2210        }
2211        None => render_states.values().cloned().collect(),
2212    }
2213}
2214
2215fn resolve_named_targets(
2216    render_states: &std::collections::HashMap<
2217        String,
2218        std::sync::Arc<tokio::sync::Mutex<RenderState>>,
2219    >,
2220    monitor: Option<&str>,
2221) -> Vec<(String, std::sync::Arc<tokio::sync::Mutex<RenderState>>)> {
2222    match monitor {
2223        Some(name) => render_states
2224            .get(name)
2225            .map(|state| vec![(name.to_string(), state.clone())])
2226            .unwrap_or_default(),
2227        None => render_states
2228            .iter()
2229            .map(|(name, state)| (name.clone(), state.clone()))
2230            .collect(),
2231    }
2232}
2233
2234type RenderStateMap = std::collections::HashMap<String, Arc<Mutex<RenderState>>>;
2235type SharedRenderStates = std::sync::Arc<tokio::sync::Mutex<RenderStateMap>>;
2236
2237/// Snapshot target states while holding the map lock briefly, then drop the
2238/// guard before any decode, GPU, or theme work. Holding the map lock across
2239/// long operations would block hotplug insert/remove and other IPC commands.
2240async fn snapshot_targets(
2241    map: &SharedRenderStates,
2242    monitor: Option<&str>,
2243) -> Vec<Arc<Mutex<RenderState>>> {
2244    let states = map.lock().await;
2245    resolve_targets(&states, monitor).await
2246}
2247
2248async fn snapshot_named_targets(
2249    map: &SharedRenderStates,
2250    monitor: Option<&str>,
2251) -> Vec<(String, Arc<Mutex<RenderState>>)> {
2252    let states = map.lock().await;
2253    resolve_named_targets(&states, monitor)
2254}
2255
2256async fn snapshot_all_named(map: &SharedRenderStates) -> Vec<(String, Arc<Mutex<RenderState>>)> {
2257    let states = map.lock().await;
2258    states
2259        .iter()
2260        .map(|(name, state)| (name.clone(), state.clone()))
2261        .collect()
2262}
2263
2264/// Returns settled outputs' shared-memory pools to the OS.
2265///
2266/// A committed shm buffer stays mapped as long as its pool lives, which
2267/// otherwise pins a full output-sized mapping per output forever (plus any
2268/// flood growth). Once nothing is drawing, the compositor already owns the
2269/// displayed pixels, so the pool and both ping-pong buffers can go: the
2270/// next static set recreates a fresh minimal pool. Settled means no set in
2271/// flight (state guard free) and no live transition, video, or GIF (those
2272/// hold the render lock for their whole run). One async task, no OS thread.
2273async fn reap_idle_shm_pools(states: SharedRenderStates) {
2274    loop {
2275        // One-second cadence: cheap (a few non-blocking try_locks when
2276        // idle) and guarantees a quiescent pool is returned well before
2277        // any multi-second idle sampling window observes it.
2278        tokio::time::sleep(std::time::Duration::from_secs(1)).await;
2279        for (_, state) in snapshot_all_named(&states).await {
2280            let Ok(mut locked) = state.try_lock() else {
2281                continue;
2282            };
2283            if locked.render_lock.try_lock().is_err() {
2284                continue;
2285            }
2286            if locked.shm_pool.len() > INITIAL_SHM_POOL_BYTES {
2287                locked.shm_buffers = [None, None];
2288                locked.shm_width = 0;
2289                locked.shm_height = 0;
2290                let fresh = SlotPool::new(
2291                    INITIAL_SHM_POOL_BYTES,
2292                    &smithay_client_toolkit::shm::Shm::from(locked.shm_server.clone()),
2293                );
2294                match fresh {
2295                    Ok(pool) => locked.shm_pool = pool,
2296                    Err(_) => continue,
2297                }
2298            }
2299            // Quiescent heap: hand freed top pages back as well. Only free
2300            // pages move, so live allocations are unaffected.
2301            // SAFETY: malloc_trim only releases unallocated pages back to
2302            // the OS; it cannot invalidate live pointers. The return value
2303            // (whether anything was released) is intentionally ignored.
2304            unsafe {
2305                libc::malloc_trim(0);
2306            }
2307        }
2308    }
2309}
2310
2311/// Caps for live-applied configuration. Keeps queues bounded and prevents
2312/// absurd scheduling values from IPC or config reload. Wallpaper playback
2313/// needs at most ~3 decoded frames in flight (bounded queue + one pending);
2314/// a larger backlog only retains obsolete frames the display can never show.
2315pub(crate) fn clamp_preload_frames(requested: usize) -> usize {
2316    requested.clamp(1, 3)
2317}
2318
2319pub(crate) fn clamp_max_fps(requested: Option<u32>) -> Option<u32> {
2320    match requested {
2321        Some(0) | None => None,
2322        Some(fps) => Some(fps.clamp(1, 240)),
2323    }
2324}
2325
2326/// Returns true when `path` looks like a usable wallpaper file: exists, is a
2327/// file, and has non-zero size. Used by IPC to avoid decoding
2328/// partially-written files.
2329fn is_usable_wallpaper_file(path: &std::path::Path) -> bool {
2330    std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() > 0)
2331}
2332
2333/// Validates a freshly loaded config before it replaces the live one.
2334/// Only checks values applied at runtime; unknown future keys are ignored by
2335/// serde defaults and never fail reload.
2336fn validate_live_config(cfg: &WallrConfig) -> Result<(), String> {
2337    if cfg.video.preload_frames > 64 {
2338        return Err(format!(
2339            "video.preload_frames {} exceeds maximum 64",
2340            cfg.video.preload_frames
2341        ));
2342    }
2343    if let Some(fps) = cfg.daemon.max_fps
2344        && (fps == 0 || fps > 1000)
2345    {
2346        return Err(format!("daemon.max_fps {fps} out of range 1..=1000"));
2347    }
2348    Ok(())
2349}
2350
2351/// Tune glibc malloc for a wallpaper daemon's bursty allocation pattern.
2352/// Animated/video decode issues thousands of transient multi-MB buffers
2353/// (GIF canvases, zstd scratch, NV12 planes). Under that churn glibc's
2354/// default *dynamic* mmap threshold adapts upward, after which freed blocks
2355/// are retained in per-thread arenas instead of being munmap'd: measured
2356/// 78 MiB stuck after GIF→static with defaults vs 25 MiB with a fixed
2357/// 128 KiB threshold (identical workload, `MALLOC_MMAP_THRESHOLD_` experiment).
2358/// A fixed threshold keeps transient decode buffers on mmap (returned to the
2359/// OS on free); a small trim threshold returns sbrk heap promptly.
2360#[cfg(target_os = "linux")]
2361fn tune_allocator() {
2362    // SAFETY: mallopt with valid M_* parameters only adjusts allocator
2363    // thresholds; it cannot invalidate live pointers. A 0 return merely
2364    // keeps glibc defaults.
2365    unsafe {
2366        libc::mallopt(libc::M_MMAP_THRESHOLD, 128 * 1024);
2367        libc::mallopt(libc::M_TRIM_THRESHOLD, 64 * 1024);
2368    }
2369}
2370
2371pub struct Daemon {
2372    config: WallrConfig,
2373    paused: Arc<AtomicBool>,
2374    engine: Arc<Mutex<WallpaperEngine>>,
2375}
2376
2377impl Daemon {
2378    pub fn new(config: WallrConfig) -> Result<Self, DaemonError> {
2379        let engine = WallpaperEngine::new(config.clone())?;
2380        Ok(Self {
2381            config,
2382            paused: Arc::new(AtomicBool::new(false)),
2383            engine: Arc::new(Mutex::new(engine)),
2384        })
2385    }
2386
2387    pub async fn start(self) -> Result<(), DaemonError> {
2388        tune_allocator();
2389        let socket_path = crate::config::expand_path(&self.config.daemon.socket);
2390        if socket_path.exists() {
2391            if tokio::net::UnixStream::connect(&socket_path).await.is_ok() {
2392                return Err(DaemonError::AlreadyRunning(
2393                    socket_path.to_string_lossy().to_string(),
2394                ));
2395            }
2396            let _ = std::fs::remove_file(&socket_path);
2397        }
2398
2399        let conn = Connection::connect_to_env()
2400            .map_err(|e| DaemonError::StartError(format!("Failed to connect to Wayland: {e:?}")))?;
2401        let backend = conn.backend();
2402        let display_ptr = backend.display_ptr() as *mut std::ffi::c_void;
2403
2404        let (globals, mut event_queue) = registry_queue_init(&conn)
2405            .map_err(|e| DaemonError::StartError(format!("registry_queue_init failed: {e:?}")))?;
2406        let qh = event_queue.handle();
2407
2408        let compositor_state = CompositorState::bind(&globals, &qh)
2409            .map_err(|e| DaemonError::StartError(format!("compositor bind failed: {e:?}")))?;
2410        let layer_shell = LayerShell::bind(&globals, &qh)
2411            .map_err(|e| DaemonError::StartError(format!("layer_shell bind failed: {e:?}")))?;
2412        let shm = Shm::bind(&globals, &qh)
2413            .map_err(|e| DaemonError::StartError(format!("shm bind failed: {e:?}")))?;
2414
2415        // Bind compositor once for creating empty input regions (passthrough).
2416        let compositor = globals
2417            .bind::<wl_compositor::WlCompositor, WaylandState, smithay_client_toolkit::globals::GlobalData>(
2418                &qh,
2419                1..=4,
2420                smithay_client_toolkit::globals::GlobalData,
2421            )
2422            .map_err(|e| DaemonError::StartError(format!("compositor bind failed: {e:?}")))?;
2423        let viewporter = globals
2424            .bind::<WpViewporter, WaylandState, ()>(&qh, 1..=1, ())
2425            .ok();
2426        if viewporter.is_none() {
2427            tracing::warn!(
2428                "wp_viewporter is unavailable; fractional outputs will use integer buffer scaling"
2429            );
2430        }
2431
2432        let mut wayland_state = WaylandState {
2433            registry_state: RegistryState::new(&globals),
2434            output_state: OutputState::new(&globals, &qh),
2435            compositor_state,
2436            shm,
2437            outputs: std::collections::HashMap::new(),
2438            surfaces: Vec::new(),
2439            viewporter,
2440            viewports: std::collections::HashMap::new(),
2441            output_lifecycles: std::collections::HashMap::new(),
2442            pending_restores: std::collections::HashSet::new(),
2443            layer_shell,
2444            compositor,
2445            hotplug: None,
2446        };
2447
2448        // Multiple roundtrips: some compositors deliver output events lazily
2449        // across several dispatch cycles. Five roundtrips ensures all outputs
2450        // are discovered and their modes/scale are populated.
2451        for i in 0..5 {
2452            event_queue
2453                .roundtrip(&mut wayland_state)
2454                .map_err(|e| DaemonError::StartError(format!("roundtrip {i} failed: {e:?}")))?;
2455        }
2456
2457        if wayland_state.outputs.is_empty() {
2458            return Err(DaemonError::StartError(
2459                "no outputs detected after roundtrip".into(),
2460            ));
2461        }
2462
2463        tracing::info!(
2464            "Detected {} output(s): {:?}",
2465            wayland_state.outputs.len(),
2466            wayland_state
2467                .outputs
2468                .values()
2469                .map(|o| format!("{} ({}x{})", o.name, o.width, o.height))
2470                .collect::<Vec<_>>()
2471        );
2472
2473        let renderer: std::sync::Arc<std::sync::Mutex<Option<std::sync::Arc<Renderer>>>> =
2474            std::sync::Arc::new(std::sync::Mutex::new(None));
2475
2476        // Create a LayerSurface, wgpu Surface, and RenderState for every
2477        // known output. The key is the output's human-readable name (e.g.
2478        // "DP-1", "eDP-1") so IPC can target a specific monitor.
2479        let mut render_states_map: std::collections::HashMap<String, Arc<Mutex<RenderState>>> =
2480            std::collections::HashMap::new();
2481
2482        // Collect output info first so we can pass &mut wayland_state to the
2483        // helper (we need &mut to push LayerSurfaces into the surfaces vec).
2484        let output_info: Vec<(u32, OutputInfo)> = wayland_state
2485            .outputs
2486            .iter()
2487            .map(|(k, v)| {
2488                (
2489                    *k,
2490                    OutputInfo {
2491                        name: v.name.clone(),
2492                        width: v.width,
2493                        height: v.height,
2494                        scale_factor: v.scale_factor,
2495                        wl_output: v.wl_output.clone(),
2496                    },
2497                )
2498            })
2499            .collect();
2500
2501        for (proto_id, info) in &output_info {
2502            let name = info.name.clone();
2503            let rs = Self::create_render_state_for_output(
2504                &renderer,
2505                display_ptr,
2506                &mut wayland_state,
2507                &qh,
2508                info,
2509                &self.config,
2510            )
2511            .await?;
2512            let rs = Arc::new(Mutex::new(rs));
2513            render_states_map.insert(name.clone(), rs.clone());
2514            wayland_state.output_lifecycles.insert(
2515                *proto_id,
2516                OutputLifecycle {
2517                    name: name.clone(),
2518                    render_state: rs,
2519                    active: Arc::new(std::sync::atomic::AtomicBool::new(true)),
2520                },
2521            );
2522            wayland_state.pending_restores.insert(*proto_id);
2523
2524            tracing::info!("Output ready: {name} ({proto_id})");
2525        }
2526
2527        // Wrap the render-state map in Arc<Mutex<...>> so it can be shared
2528        // between the Wayland event loop (hotplug) and the IPC handler.
2529        let render_states: std::sync::Arc<
2530            tokio::sync::Mutex<std::collections::HashMap<String, Arc<Mutex<RenderState>>>>,
2531        > = std::sync::Arc::new(tokio::sync::Mutex::new(render_states_map));
2532
2533        // Store the hotplug context in WaylandState so output callbacks can
2534        // create/destroy render states when outputs appear or disappear.
2535        wayland_state.hotplug = Some(DaemonHotplug {
2536            renderer: renderer.clone(),
2537            config: self.config.clone(),
2538            display_ptr: SendDisplayPtr(display_ptr),
2539            render_states: render_states.clone(),
2540        });
2541
2542        // One more roundtrip to catch outputs that appeared between the
2543        // initial roundtrips and the hotplug context being stored.
2544        event_queue
2545            .roundtrip(&mut wayland_state)
2546            .map_err(|e| DaemonError::StartError(format!("hotplug roundtrip failed: {e:?}")))?;
2547
2548        let paused_clone = self.paused.clone();
2549        let engine_clone = self.engine.clone();
2550        let render_states_clone = render_states.clone();
2551        // Return settled outputs' shm pools to the OS (see reap_idle_shm_pools).
2552        tokio::spawn(reap_idle_shm_pools(render_states.clone()));
2553        // Generation counter for theme/hook work. Rapid A→B→C switches bump
2554        // the counter; detached theme tasks with a stale generation exit
2555        // without invoking external processes for obsolete wallpapers.
2556        let theme_gen = Arc::new(std::sync::atomic::AtomicU64::new(0));
2557        let theme_gen_clone = theme_gen.clone();
2558
2559        // Graceful shutdown on POSIX signals: stop video decoding, remove the
2560        // IPC socket, and exit. The compositor releases the layer-shell
2561        // surface automatically when the process exits.
2562        {
2563            let rs_map = render_states.clone();
2564            let socket_path = socket_path.clone();
2565            tokio::spawn(async move {
2566                use tokio::signal::unix::{SignalKind, signal};
2567                let mut term = signal(SignalKind::terminate()).expect("SIGTERM handler");
2568                let mut int = signal(SignalKind::interrupt()).expect("SIGINT handler");
2569                let mut hup = signal(SignalKind::hangup()).expect("SIGHUP handler");
2570                tokio::select! {
2571                    _ = term.recv() => {}
2572                    _ = int.recv() => {}
2573                    _ = hup.recv() => {}
2574                }
2575                tracing::info!("Signal received, shutting down gracefully");
2576                let states = rs_map.lock().await;
2577                for rs in states.values() {
2578                    if let Ok(state) = rs.try_lock() {
2579                        state.video_playback.stop();
2580                    }
2581                }
2582                drop(states);
2583                let _ = std::fs::remove_file(&socket_path);
2584                std::process::exit(0);
2585            });
2586        }
2587
2588        let ipc_socket_path = socket_path.clone();
2589        start_ipc_server(&socket_path, move |cmd| {
2590            let paused = paused_clone.clone();
2591            let engine = engine_clone.clone();
2592            let render_states_map = render_states_clone.clone();
2593            let theme_gen = theme_gen_clone.clone();
2594            let stop_socket = ipc_socket_path.clone();
2595            async move {
2596                match cmd {
2597                    IpcCommand::Pause { monitor } => {
2598                        let targets =
2599                            snapshot_targets(&render_states_map, monitor.as_deref()).await;
2600                        if targets.is_empty() {
2601                            return IpcResponse {
2602                                success: false,
2603                                message: Some("No matching outputs".into()),
2604                            };
2605                        }
2606                        if monitor.is_none() {
2607                            paused.store(true, Ordering::SeqCst);
2608                        }
2609                        for rs in targets {
2610                            let rs_lock = rs.lock().await;
2611                            rs_lock.video_playback.pause();
2612                            rs_lock.gif_paused.store(true, Ordering::SeqCst);
2613                        }
2614                        IpcResponse {
2615                            success: true,
2616                            message: Some("Paused".into()),
2617                        }
2618                    }
2619                    IpcCommand::Resume { monitor } => {
2620                        let targets =
2621                            snapshot_targets(&render_states_map, monitor.as_deref()).await;
2622                        if targets.is_empty() {
2623                            return IpcResponse {
2624                                success: false,
2625                                message: Some("No matching outputs".into()),
2626                            };
2627                        }
2628                        if monitor.is_none() {
2629                            paused.store(false, Ordering::SeqCst);
2630                        }
2631                        for rs in targets {
2632                            let rs_lock = rs.lock().await;
2633                            rs_lock.video_playback.resume();
2634                            rs_lock.gif_paused.store(false, Ordering::SeqCst);
2635                            rs_lock.pacer.notify();
2636                        }
2637                        IpcResponse {
2638                            success: true,
2639                            message: Some("Resumed".into()),
2640                        }
2641                    }
2642                    IpcCommand::Reload => {
2643                        // Re-read config from disk, validate, and apply live
2644                        // values without rebuilding GPU state. Invalid configs
2645                        // keep the previous valid configuration.
2646                        let fresh = crate::config::load_config(None)
2647                            .map_err(|e| e.to_string())
2648                            .and_then(|cfg| {
2649                                validate_live_config(&cfg)?;
2650                                Ok(cfg)
2651                            });
2652                        let fresh = match fresh {
2653                            Ok(cfg) => cfg,
2654                            Err(reason) => {
2655                                return IpcResponse {
2656                                    success: false,
2657                                    message: Some(format!("Reload rejected: {reason}")),
2658                                };
2659                            }
2660                        };
2661                        let preload = clamp_preload_frames(fresh.video.preload_frames);
2662                        let max_fps = clamp_max_fps(fresh.daemon.max_fps);
2663                        let hw_accel =
2664                            crate::video::HwAccel::from_config(&fresh.video.hw_decode);
2665                        {
2666                            let mut eng = engine.lock().await;
2667                            eng.config = fresh.clone();
2668                            if let Err(e) = eng.reload() {
2669                                return IpcResponse {
2670                                    success: false,
2671                                    message: Some(e.to_string()),
2672                                };
2673                            }
2674                        }
2675                        // Apply video params per output without holding the
2676                        // map lock across awaits.
2677                        let targets = snapshot_all_named(&render_states_map).await;
2678                        for (_, rs) in targets {
2679                            let mut rs_lock = rs.lock().await;
2680                            rs_lock.hw_accel = hw_accel;
2681                            rs_lock.preload_frames = preload;
2682                            rs_lock.max_fps = max_fps;
2683                        }
2684                        let _ = theme_gen;
2685                        IpcResponse {
2686                            success: true,
2687                            message: Some("Reloaded".into()),
2688                        }
2689                    }
2690                    IpcCommand::Preview {
2691                        path,
2692                        effect,
2693                        duration_ms,
2694                        no_theme,
2695                        theme_override,
2696                        monitor,
2697                        scaling_mode,
2698                    } => {
2699                        if paused.load(Ordering::SeqCst) {
2700                            return IpcResponse {
2701                                success: false,
2702                                message: Some("Daemon is paused".into()),
2703                            };
2704                        }
2705                        let p = std::path::PathBuf::from(&path);
2706                        if !is_usable_wallpaper_file(&p) {
2707                            return IpcResponse {
2708                                success: false,
2709                                message: Some(format!("File not found or unreadable: {}", path)),
2710                            };
2711                        }
2712
2713                        // Snapshot targets first, then drop the map lock
2714                        // before any decode/upload work so hotplug stays
2715                        // responsive while a switch is in flight.
2716                        let targets =
2717                            snapshot_named_targets(&render_states_map, monitor.as_deref()).await;
2718                        if targets.is_empty() {
2719                            return IpcResponse {
2720                                success: false,
2721                                message: match &monitor {
2722                                    Some(name) => Some(format!("Unknown monitor: {name}")),
2723                                    None => Some("No outputs available".into()),
2724                                },
2725                            };
2726                        }
2727
2728                        let effect = effect.unwrap_or_else(|| {
2729                            crate::animation::Effect::Fade(crate::animation::FadeParams::default())
2730                        });
2731                        // Live playback only starts after the transition, so for
2732                        // videos an unrequested 2s fade reads as a long "load".
2733                        // Default to a short fade unless the user asked for one.
2734                        let is_video = crate::video::VideoDecoder::is_video_file(&p);
2735                        let duration = duration_ms
2736                            .unwrap_or(if is_video { 150 } else { 2000 })
2737                            .min(crate::ipc::MAX_DURATION_MS);
2738                        let sm = scaling_mode.unwrap_or(crate::config::ScalingMode::Fill);
2739                        let scaling_mode_u32 = match sm {
2740                            crate::config::ScalingMode::Fill => 0u32,
2741                            crate::config::ScalingMode::Fit => 1,
2742                            crate::config::ScalingMode::Stretch => 2,
2743                            crate::config::ScalingMode::Center => 3,
2744                            crate::config::ScalingMode::Tile => 4,
2745                        };
2746
2747                        let mut last_err = None;
2748                        for (name, rs) in &targets {
2749                            let result = set_wallpaper_with_retry(
2750                                rs,
2751                                &p,
2752                                &effect,
2753                                duration,
2754                                scaling_mode_u32,
2755                            )
2756                            .await;
2757
2758                            match result {
2759                                Err(e) => last_err = Some(format!("Render failed: {e}")),
2760                                Ok(()) => {
2761                                    if let Err(err) = persist_wallpaper(name, &p) {
2762                                        tracing::warn!(
2763                                            "Wallpaper changed on {name}, but state persistence failed: {err}"
2764                                        );
2765                                    }
2766                                }
2767                            }
2768                        }
2769
2770                        match last_err {
2771                            Some(e) => IpcResponse {
2772                                success: false,
2773                                message: Some(e),
2774                            },
2775                            None => {
2776                                // Wallpaper is already visible at this point.
2777                                // Theme/hooks/reload run detached so they never
2778                                // block the next switch. Rapid A→B→C switches
2779                                // supersede queued theme work via the
2780                                // generation counter.
2781                                let generation =
2782                                    theme_gen.fetch_add(1, Ordering::SeqCst) + 1;
2783                                let opts = SetOptions {
2784                                    no_theme,
2785                                    theme_provider: theme_override,
2786                                    monitor,
2787                                };
2788                                let eng = engine.clone();
2789                                let theme_gen_check = theme_gen.clone();
2790                                let p_clone = p.clone();
2791                                tokio::spawn(async move {
2792                                    if theme_gen_check.load(Ordering::SeqCst) != generation {
2793                                        return;
2794                                    }
2795                                    let mut eng = eng.lock().await;
2796                                    if theme_gen_check.load(Ordering::SeqCst) != generation {
2797                                        return;
2798                                    }
2799                                    if let Err(e) =
2800                                        eng.set_wallpaper(&p_clone, &opts).await
2801                                    {
2802                                        tracing::warn!(
2803                                            "Post-render hooks/theme failed for {p_clone:?}: {e}"
2804                                        );
2805                                    }
2806                                });
2807                                IpcResponse {
2808                                    success: true,
2809                                    message: None,
2810                                }
2811                            }
2812                        }
2813                    }
2814                    IpcCommand::Stop => {
2815                        let targets = snapshot_all_named(&render_states_map).await;
2816                        for (_, rs) in targets {
2817                            let state = rs.lock().await;
2818                            state.playback_gen.fetch_add(1, Ordering::SeqCst);
2819                            state.pacer.notify();
2820                            state.video_playback.stop();
2821                        }
2822                        let sp = stop_socket.clone();
2823                        tokio::spawn(async move {
2824                            tokio::time::sleep(std::time::Duration::from_millis(300)).await;
2825                            let _ = std::fs::remove_file(&sp);
2826                            std::process::exit(0);
2827                        });
2828                        IpcResponse {
2829                            success: true,
2830                            message: Some("Stopping".into()),
2831                        }
2832                    }
2833                    IpcCommand::Status => {
2834                        let state = if paused.load(Ordering::SeqCst) {
2835                            "paused"
2836                        } else {
2837                            "running"
2838                        };
2839                        IpcResponse {
2840                            success: true,
2841                            message: Some(format!("wallr daemon {}", state)),
2842                        }
2843                    }
2844                    IpcCommand::Seek {
2845                        timestamp_ms,
2846                        monitor,
2847                    } => {
2848                        let targets =
2849                            snapshot_named_targets(&render_states_map, monitor.as_deref()).await;
2850                        if targets.is_empty() {
2851                            return IpcResponse {
2852                                success: false,
2853                                message: match &monitor {
2854                                    Some(name) => Some(format!("Unknown monitor: {name}")),
2855                                    None => Some("No outputs available".into()),
2856                                },
2857                            };
2858                        }
2859                        // When monitor is unspecified, seek all outputs.
2860                        // The map lock was already dropped; work only on the
2861                        // snapshot so hotplug stays responsive.
2862                        let mut seek_count = 0u32;
2863                        let mut errors = Vec::new();
2864                        for (name, rs) in &targets {
2865                            let rs_lock = rs.lock().await;
2866                            match rs_lock
2867                                .video_playback
2868                                .seek(std::time::Duration::from_millis(timestamp_ms))
2869                            {
2870                                Ok(()) => {
2871                                    seek_count += 1;
2872                                }
2873                                Err(e) => {
2874                                    errors.push(format!("{}: {}", name, e));
2875                                }
2876                            }
2877                        }
2878                        if seek_count == 0 {
2879                            IpcResponse {
2880                                success: false,
2881                                message: Some(format!(
2882                                    "Seek failed on all outputs: {}",
2883                                    errors.join("; ")
2884                                )),
2885                            }
2886                        } else if !errors.is_empty() {
2887                            IpcResponse {
2888                                success: true,
2889                                message: Some(format!(
2890                                    "Seeked {} output(s) to {}ms, {} failed: {}",
2891                                    seek_count,
2892                                    timestamp_ms,
2893                                    errors.len(),
2894                                    errors.join("; ")
2895                                )),
2896                            }
2897                        } else {
2898                            IpcResponse {
2899                                success: true,
2900                                message: Some(format!(
2901                                    "Seeked {} output(s) to {}ms",
2902                                    seek_count, timestamp_ms
2903                                )),
2904                            }
2905                        }
2906                    }
2907                    IpcCommand::Info { monitor } => {
2908                        let targets =
2909                            snapshot_named_targets(&render_states_map, monitor.as_deref()).await;
2910                        if targets.is_empty() {
2911                            return IpcResponse {
2912                                success: false,
2913                                message: match &monitor {
2914                                    Some(name) => Some(format!("Unknown monitor: {name}")),
2915                                    None => Some("No outputs available".into()),
2916                                },
2917                            };
2918                        }
2919
2920                        let mut lines = vec![
2921                            format!("wallr v{}", env!("CARGO_PKG_VERSION")),
2922                            String::new(),
2923                            format!("Outputs: {}", targets.len()),
2924                        ];
2925                        for (name, _) in &targets {
2926                            lines.push(format!("  - {name}"));
2927                        }
2928
2929                        // Collect target output info
2930                        for (name, rs) in &targets {
2931                            let rs_lock = rs.lock().await;
2932                            // GPU state is lazy: a static-only output may never
2933                            // have initialized it. Report that instead of
2934                            // panicking the IPC handler task.
2935                            let gpu_info = rs_lock.gpu.as_ref().map_or_else(
2936                                || "GPU: not initialized (static content)".to_string(),
2937                                |gpu| {
2938                                    crate::video::gpu::adapter_diagnostics(&gpu.renderer.adapter)
2939                                },
2940                            );
2941
2942                            lines.push(String::new());
2943                            lines.push(format!("[{name}] {}x{}", rs_lock.width, rs_lock.height));
2944                            lines.push(gpu_info);
2945
2946                            match rs_lock.video_playback.metadata() {
2947                                Some(meta) => {
2948                                    let decoder_info = rs_lock.video_playback.decoder_info();
2949                                    let hw = rs_lock.video_playback.hw_accel_in_use();
2950                                    let state = if rs_lock.video_playback.is_paused() {
2951                                        "paused"
2952                                    } else {
2953                                        "playing"
2954                                    };
2955                                    let position = rs_lock
2956                                        .video_playback
2957                                        .position()
2958                                        .map(|p| format!("{:.2}s", p.as_secs_f64()))
2959                                        .unwrap_or_else(|| "?".to_string());
2960                                    lines.push(String::new());
2961                                    lines.push("Video:".into());
2962                                    lines.push(format!(
2963                                        "  Resolution: {}x{}",
2964                                        meta.width, meta.height
2965                                    ));
2966                                    lines.push(format!("  FPS: {:.2}", meta.fps));
2967                                    lines.push(format!(
2968                                        "  Duration: {:.2}s",
2969                                        meta.duration.as_secs_f64()
2970                                    ));
2971                                    lines.push(format!(
2972                                        "  Codec: {}",
2973                                        decoder_info
2974                                            .as_ref()
2975                                            .map(|d| d.codec_name.as_str())
2976                                            .unwrap_or("unknown")
2977                                    ));
2978                                    lines.push(format!("  Container: {}", meta.format));
2979                                    lines.push(format!("  Decoder: {}", hw.name()));
2980                                    if let Some(info) = decoder_info.as_ref() {
2981                                        lines.push(format!("  Decoder State: {}", info.state.name()));
2982                                    }
2983                                    lines.push(format!(
2984                                        "  GPU Decode: {}",
2985                                        if hw == crate::video::HwAccel::Software {
2986                                            "disabled"
2987                                        } else {
2988                                            "enabled"
2989                                        }
2990                                    ));
2991                                    if let Some(info) = decoder_info.as_ref()
2992                                        && info.dropped_frames > 0
2993                                    {
2994                                        lines.push(format!(
2995                                            "  Dropped Frames: {}",
2996                                            info.dropped_frames
2997                                        ));
2998                                    }
2999                                    lines.push(format!("  State: {} @ {}", state, position));
3000                                }
3001                                None => {
3002                                    lines.push(String::new());
3003                                    lines.push("Video: none active".into());
3004                                    lines.push("Decoder: idle".into());
3005                                }
3006                            }
3007                        }
3008
3009                        IpcResponse {
3010                            success: true,
3011                            message: Some(lines.join("\n")),
3012                        }
3013                    }
3014                    IpcCommand::MonitorList => {
3015                        let targets = snapshot_all_named(&render_states_map).await;
3016                        let mut lines = Vec::new();
3017                        for (name, rs) in &targets {
3018                            let lock = rs.lock().await;
3019                            lines.push(format!("{}: {}x{}", name, lock.width, lock.height));
3020                        }
3021                        if lines.is_empty() {
3022                            IpcResponse {
3023                                success: true,
3024                                message: Some("No monitors connected".into()),
3025                            }
3026                        } else {
3027                            IpcResponse {
3028                                success: true,
3029                                message: Some(lines.join("\n")),
3030                            }
3031                        }
3032                    }
3033                    IpcCommand::MonitorCurrent => {
3034                        // Return info for the first output as "current".
3035                        let targets = snapshot_all_named(&render_states_map).await;
3036                        if let Some((name, rs)) = targets.first() {
3037                            let lock = rs.lock().await;
3038                            IpcResponse {
3039                                success: true,
3040                                message: Some(format!("{}: {}x{}", name, lock.width, lock.height)),
3041                            }
3042                        } else {
3043                            IpcResponse {
3044                                success: false,
3045                                message: Some("No monitors connected".into()),
3046                            }
3047                        }
3048                    }
3049                    IpcCommand::Blank {
3050                        monitor,
3051                        effect,
3052                        duration_ms,
3053                    } => {
3054                        let targets =
3055                            snapshot_named_targets(&render_states_map, monitor.as_deref()).await;
3056                        if targets.is_empty() {
3057                            return IpcResponse {
3058                                success: false,
3059                                message: match &monitor {
3060                                    Some(name) => Some(format!("Unknown monitor: {name}")),
3061                                    None => Some("No outputs available".into()),
3062                                },
3063                            };
3064                        }
3065                        let mut blanked_count = 0u32;
3066                        let mut errors = Vec::new();
3067                        let black_effect = effect.unwrap_or_else(|| {
3068                            crate::animation::Effect::Fade(crate::animation::FadeParams::default())
3069                        });
3070                        let duration = duration_ms.unwrap_or(800);
3071                        let mut blank_file = match tempfile::Builder::new()
3072                            .prefix("wallr_blank-")
3073                            .suffix(".png")
3074                            .tempfile()
3075                        {
3076                            Ok(file) => file,
3077                            Err(e) => {
3078                                return IpcResponse {
3079                                    success: false,
3080                                    message: Some(format!("Failed to create blank image: {e}")),
3081                                };
3082                            }
3083                        };
3084                        let blank_path = blank_file.path().to_path_buf();
3085                        let blank = image::DynamicImage::ImageRgba8(
3086                            image::RgbaImage::from_pixel(1, 1, image::Rgba([0, 0, 0, 255])),
3087                        );
3088                        if let Err(e) = blank
3089                            .write_to(blank_file.as_file_mut(), image::ImageFormat::Png)
3090                        {
3091                            return IpcResponse {
3092                                success: false,
3093                                message: Some(format!("Failed to write blank image: {e}")),
3094                            };
3095                        }
3096
3097                        for (name, rs) in &targets {
3098                            let rs = Arc::clone(rs);
3099                            let blank_path = blank_path.clone();
3100                            let black_effect = black_effect.clone();
3101                            let blanked = tokio::task::spawn_blocking(move || {
3102                                let mut lock = rs.blocking_lock();
3103                                if lock.blanked {
3104                                    return Ok(false);
3105                                }
3106                                let previous = (
3107                                    lock.last_wallpaper.clone().unwrap_or_default(),
3108                                    lock.scaling_mode,
3109                                );
3110                                lock.set_wallpaper(&blank_path, &black_effect, duration, 0)?;
3111                                lock.pre_blank = Some(previous);
3112                                lock.blanked = true;
3113                                Ok::<bool, anyhow::Error>(true)
3114                            })
3115                            .await;
3116                            match blanked {
3117                                Ok(Ok(true)) => blanked_count += 1,
3118                                Ok(Ok(false)) => {}
3119                                Ok(Err(e)) => errors.push(format!("{name}: blank failed: {e}")),
3120                                Err(e) => errors.push(format!("{name}: blank task failed: {e}")),
3121                            }
3122                        }
3123                        if errors.is_empty() {
3124                            IpcResponse {
3125                                success: true,
3126                                message: Some(format!("Blanked {blanked_count} output(s)")),
3127                            }
3128                        } else {
3129                            IpcResponse {
3130                                success: false,
3131                                message: Some(format!(
3132                                    "Blanked {blanked_count} output(s), {} error(s): {}",
3133                                    errors.len(),
3134                                    errors.join("; ")
3135                                )),
3136                            }
3137                        }
3138                    }
3139                    IpcCommand::Restore {
3140                        monitor,
3141                        effect,
3142                        duration_ms,
3143                    } => {
3144                        let targets =
3145                            snapshot_named_targets(&render_states_map, monitor.as_deref()).await;
3146                        if targets.is_empty() {
3147                            return IpcResponse {
3148                                success: false,
3149                                message: match &monitor {
3150                                    Some(name) => Some(format!("Unknown monitor: {name}")),
3151                                    None => Some("No outputs available".into()),
3152                                },
3153                            };
3154                        }
3155                        let mut restored_count = 0u32;
3156                        let mut errors = Vec::new();
3157                        let restore_effect = effect.unwrap_or_else(|| {
3158                            crate::animation::Effect::Fade(crate::animation::FadeParams::default())
3159                        });
3160                        let duration = duration_ms.unwrap_or(800).min(crate::ipc::MAX_DURATION_MS);
3161
3162                        for (name, rs) in &targets {
3163                            let rs = Arc::clone(rs);
3164                            let restore_effect = restore_effect.clone();
3165                            let result = tokio::task::spawn_blocking(move || {
3166                                let mut lock = rs.blocking_lock();
3167                                if !lock.blanked {
3168                                    return None;
3169                                }
3170                                let result = match lock.pre_blank.clone() {
3171                                    Some((path, scaling_mode)) if path.exists() => lock
3172                                        .set_wallpaper(
3173                                            &path,
3174                                            &restore_effect,
3175                                            duration,
3176                                            scaling_mode,
3177                                        )
3178                                        .map_err(|e| format!("restore failed: {e}")),
3179                                    Some(_) => Err("wallpaper path no longer exists".to_string()),
3180                                    None => Err("no previous wallpaper to restore".to_string()),
3181                                };
3182                                if result.is_ok() {
3183                                    lock.blanked = false;
3184                                    lock.pre_blank = None;
3185                                }
3186                                Some(result)
3187                            })
3188                            .await;
3189                            match result {
3190                                Ok(Some(Ok(()))) => restored_count += 1,
3191                                Ok(Some(Err(e))) => errors.push(format!("{name}: {e}")),
3192                                Ok(None) => {}
3193                                Err(e) => errors.push(format!("{name}: restore task failed: {e}")),
3194                            }
3195                        }
3196                        if !errors.is_empty() {
3197                            IpcResponse {
3198                                success: restored_count > 0,
3199                                message: Some(format!(
3200                                    "Restored {} output(s), {} error(s): {}",
3201                                    restored_count,
3202                                    errors.len(),
3203                                    errors.join("; ")
3204                                )),
3205                            }
3206                        } else {
3207                            IpcResponse {
3208                                success: true,
3209                                message: Some(format!("Restored {restored_count} output(s)")),
3210                            }
3211                        }
3212                    }
3213                }
3214            }
3215        })
3216        .await?;
3217
3218        tokio::task::spawn_blocking(move || {
3219            loop {
3220                if let Err(e) = event_queue.blocking_dispatch(&mut wayland_state) {
3221                    eprintln!("Wayland dispatch error: {e:?}");
3222                    break;
3223                }
3224            }
3225            // The compositor connection is dead (e.g. the compositor exited
3226            // or killed our layer surface with a protocol error). Rendering
3227            // can never recover, so exit and let the supervisor restart us.
3228            eprintln!("wallr: Wayland connection lost, exiting");
3229            std::process::exit(1);
3230        });
3231
3232        loop {
3233            tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
3234        }
3235    }
3236
3237    /// Creates a LayerSurface, wgpu Surface, and RenderState for a single
3238    /// Wayland output.
3239    #[allow(clippy::too_many_arguments)]
3240    async fn create_render_state_for_output(
3241        renderer: &std::sync::Arc<std::sync::Mutex<Option<std::sync::Arc<Renderer>>>>,
3242        display_ptr: *mut std::ffi::c_void,
3243        wayland_state: &mut WaylandState,
3244        qh: &QueueHandle<WaylandState>,
3245        output: &OutputInfo,
3246        config: &WallrConfig,
3247    ) -> Result<RenderState, DaemonError> {
3248        let wl_surface = wayland_state.compositor_state.create_surface(qh);
3249        let layer_surface = wayland_state.layer_shell.create_layer_surface(
3250            qh,
3251            wl_surface,
3252            Layer::Background,
3253            Some("wallr"),
3254            Some(&output.wl_output),
3255        );
3256        layer_surface.set_anchor(Anchor::TOP | Anchor::BOTTOM | Anchor::LEFT | Anchor::RIGHT);
3257        layer_surface.set_exclusive_zone(-1);
3258        layer_surface.set_keyboard_interactivity(KeyboardInteractivity::None);
3259
3260        // Empty input region so clicks pass through to the desktop.
3261        let empty_region = wayland_state.compositor.create_region(qh, ());
3262        layer_surface
3263            .wl_surface()
3264            .set_input_region(Some(&empty_region));
3265        let output_id = output.wl_output.id().protocol_id();
3266        let viewport = wayland_state
3267            .viewporter
3268            .as_ref()
3269            .map(|viewporter| viewporter.get_viewport(layer_surface.wl_surface(), qh, ()));
3270        let scale_factor = if viewport.is_some() {
3271            1
3272        } else if output.scale_factor > 0 {
3273            output.scale_factor
3274        } else {
3275            1
3276        };
3277        layer_surface.wl_surface().set_buffer_scale(scale_factor);
3278        layer_surface.commit();
3279        empty_region.destroy();
3280        if let Some(viewport) = viewport {
3281            wayland_state.viewports.insert(output_id, viewport);
3282        }
3283
3284        // mode.dimensions already returns physical pixels; do not multiply by scale.
3285        let width = output.width;
3286        let height = output.height;
3287
3288        let raw_surface = layer_surface.wl_surface().id().as_ptr() as *mut std::ffi::c_void;
3289        let shm_surface = layer_surface.wl_surface().clone();
3290        wayland_state.surfaces.push((output_id, layer_surface));
3291
3292        Ok(RenderState {
3293            gpu: None,
3294            renderer: renderer.clone(),
3295            display_ptr: SendDisplayPtr(display_ptr),
3296            raw_surface: SendDisplayPtr(raw_surface),
3297            render_lock: std::sync::Arc::new(std::sync::Mutex::new(())),
3298            playback_gen: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
3299            pacer: std::sync::Arc::new(LivePacer::new()),
3300            current_bind: None,
3301            current_tex: None,
3302            width,
3303            height,
3304            current_width: 0,
3305            current_height: 0,
3306            video_playback: std::sync::Arc::new(crate::video::VideoPlayback::new()),
3307            hw_accel: crate::video::HwAccel::from_config(&config.video.hw_decode),
3308            preferred_gpu: config.video.preferred_gpu.clone(),
3309            preload_frames: clamp_preload_frames(config.video.preload_frames),
3310            max_fps: clamp_max_fps(config.daemon.max_fps),
3311            scaling_mode: 0,
3312            current_effect: None,
3313            last_wallpaper: None,
3314            pre_blank: None,
3315            blanked: false,
3316            gif_paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
3317            shm_surface,
3318            shm_server: wayland_state.shm.wl_shm().clone(),
3319            shm_pool: SlotPool::new(INITIAL_SHM_POOL_BYTES, &wayland_state.shm).map_err(|e| {
3320                DaemonError::StartError(format!("shared-memory pool creation failed: {e}"))
3321            })?,
3322            shm_buffers: [None, None],
3323            shm_index: 0,
3324            shm_width: 0,
3325            shm_height: 0,
3326            gpu_surface_used: false,
3327            cached_video_texture: None,
3328            loop_video: config.wallpaper.loop_video,
3329        })
3330    }
3331}
3332
3333/// Synchronous version of `Daemon::create_render_state_for_output` for hotplug.
3334/// Reuses the existing adapter from the renderer instead of requesting a
3335/// new one, avoiding the async requirement.
3336#[allow(clippy::too_many_arguments)]
3337fn create_render_state_for_output_sync(
3338    renderer: &std::sync::Arc<std::sync::Mutex<Option<std::sync::Arc<Renderer>>>>,
3339    display_ptr: *mut std::ffi::c_void,
3340    wayland_state: &mut WaylandState,
3341    qh: &QueueHandle<WaylandState>,
3342    output: &OutputInfo,
3343    config: &WallrConfig,
3344) -> Result<RenderState, DaemonError> {
3345    let wl_surface = wayland_state.compositor_state.create_surface(qh);
3346    let layer_surface = wayland_state.layer_shell.create_layer_surface(
3347        qh,
3348        wl_surface,
3349        Layer::Background,
3350        Some("wallr"),
3351        Some(&output.wl_output),
3352    );
3353    layer_surface.set_anchor(Anchor::TOP | Anchor::BOTTOM | Anchor::LEFT | Anchor::RIGHT);
3354    layer_surface.set_exclusive_zone(-1);
3355    layer_surface.set_keyboard_interactivity(KeyboardInteractivity::None);
3356
3357    let empty_region = wayland_state.compositor.create_region(qh, ());
3358    layer_surface
3359        .wl_surface()
3360        .set_input_region(Some(&empty_region));
3361    let output_id = output.wl_output.id().protocol_id();
3362    let viewport = wayland_state
3363        .viewporter
3364        .as_ref()
3365        .map(|viewporter| viewporter.get_viewport(layer_surface.wl_surface(), qh, ()));
3366    let scale_factor = if viewport.is_some() {
3367        1
3368    } else if output.scale_factor > 0 {
3369        output.scale_factor
3370    } else {
3371        1
3372    };
3373    layer_surface.wl_surface().set_buffer_scale(scale_factor);
3374    layer_surface.commit();
3375    empty_region.destroy();
3376    if let Some(viewport) = viewport {
3377        wayland_state.viewports.insert(output_id, viewport);
3378    }
3379
3380    // mode.dimensions already returns physical pixels; do not multiply by scale.
3381    let width = output.width;
3382    let height = output.height;
3383
3384    let raw_surface = layer_surface.wl_surface().id().as_ptr() as *mut std::ffi::c_void;
3385    let shm_surface = layer_surface.wl_surface().clone();
3386    wayland_state.surfaces.push((output_id, layer_surface));
3387
3388    Ok(RenderState {
3389        gpu: None,
3390        renderer: renderer.clone(),
3391        display_ptr: SendDisplayPtr(display_ptr),
3392        raw_surface: SendDisplayPtr(raw_surface),
3393        render_lock: std::sync::Arc::new(std::sync::Mutex::new(())),
3394        playback_gen: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
3395        pacer: std::sync::Arc::new(LivePacer::new()),
3396        current_bind: None,
3397        current_tex: None,
3398        width,
3399        height,
3400        current_width: 0,
3401        current_height: 0,
3402        video_playback: std::sync::Arc::new(crate::video::VideoPlayback::new()),
3403        hw_accel: crate::video::HwAccel::from_config(&config.video.hw_decode),
3404        preferred_gpu: config.video.preferred_gpu.clone(),
3405        preload_frames: clamp_preload_frames(config.video.preload_frames),
3406        max_fps: clamp_max_fps(config.daemon.max_fps),
3407        scaling_mode: 0,
3408        current_effect: None,
3409        last_wallpaper: None,
3410        pre_blank: None,
3411        blanked: false,
3412        gif_paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
3413        shm_surface,
3414        shm_server: wayland_state.shm.wl_shm().clone(),
3415        shm_pool: SlotPool::new(INITIAL_SHM_POOL_BYTES, &wayland_state.shm).map_err(|e| {
3416            DaemonError::StartError(format!("shared-memory pool creation failed: {e}"))
3417        })?,
3418        shm_buffers: [None, None],
3419        shm_index: 0,
3420        shm_width: 0,
3421        shm_height: 0,
3422        gpu_surface_used: false,
3423        cached_video_texture: None,
3424        loop_video: config.wallpaper.loop_video,
3425    })
3426}