Skip to main content

wallr_core/daemon/
mod.rs

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