Skip to main content

wallr_core/daemon/
mod.rs

1use crate::config::WallrConfig;
2use crate::ipc::{IpcCommand, IpcResponse, start_ipc_server};
3use crate::renderer::Renderer;
4use crate::wallpaper::{SetOptions, WallpaperEngine};
5use notify::{Event, EventKind, RecursiveMode, Watcher};
6use std::path::PathBuf;
7use std::sync::Arc;
8use std::sync::atomic::{AtomicBool, Ordering};
9use tokio::sync::Mutex;
10
11use raw_window_handle::{
12    DisplayHandle, HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle,
13    WaylandDisplayHandle, WaylandWindowHandle, WindowHandle,
14};
15use smithay_client_toolkit::{
16    compositor::{CompositorHandler, CompositorState},
17    delegate_compositor, delegate_layer, delegate_output, delegate_registry, delegate_shm,
18    output::{OutputHandler, OutputState},
19    registry::{ProvidesRegistryState, RegistryState},
20    registry_handlers,
21    shell::WaylandSurface,
22    shell::wlr_layer::{
23        Anchor, KeyboardInteractivity, Layer, LayerShell, LayerShellHandler, LayerSurface,
24        LayerSurfaceConfigure,
25    },
26    shm::{Shm, ShmHandler},
27};
28use wayland_client::{
29    Connection, Dispatch, Proxy, QueueHandle,
30    globals::registry_queue_init,
31    protocol::{wl_compositor, wl_output, wl_surface},
32};
33use wayland_protocols::wp::viewporter::client::{
34    wp_viewport::{self, WpViewport},
35    wp_viewporter::{self, WpViewporter},
36};
37#[derive(Debug, thiserror::Error)]
38pub enum DaemonError {
39    #[error("daemon already running: {0}")]
40    AlreadyRunning(String),
41    #[error("failed to start daemon: {0}")]
42    StartError(String),
43    #[error("I/O error: {0}")]
44    Io(#[from] std::io::Error),
45    #[error("IPC error: {0}")]
46    Ipc(#[from] crate::ipc::IpcError),
47    #[error("Config error: {0}")]
48    Config(#[from] crate::config::ConfigError),
49    #[error("Wallpaper error: {0}")]
50    Wallpaper(#[from] crate::wallpaper::WallpaperError),
51}
52
53pub struct WaylandWindow {
54    pub display: *mut std::ffi::c_void,
55    pub surface: *mut std::ffi::c_void,
56}
57
58unsafe impl Send for WaylandWindow {}
59unsafe impl Sync for WaylandWindow {}
60
61impl HasWindowHandle for WaylandWindow {
62    fn window_handle(&self) -> Result<WindowHandle<'_>, raw_window_handle::HandleError> {
63        let surface = std::ptr::NonNull::new(self.surface)
64            .ok_or(raw_window_handle::HandleError::Unavailable)?;
65        let handle = WaylandWindowHandle::new(surface);
66        unsafe { Ok(WindowHandle::borrow_raw(RawWindowHandle::Wayland(handle))) }
67    }
68}
69
70impl HasDisplayHandle for WaylandWindow {
71    fn display_handle(&self) -> Result<DisplayHandle<'_>, raw_window_handle::HandleError> {
72        let display = std::ptr::NonNull::new(self.display)
73            .ok_or(raw_window_handle::HandleError::Unavailable)?;
74        let handle = WaylandDisplayHandle::new(display);
75        unsafe { Ok(DisplayHandle::borrow_raw(RawDisplayHandle::Wayland(handle))) }
76    }
77}
78
79#[derive(Clone)]
80struct OutputInfo {
81    name: String,
82    width: u32,
83    height: u32,
84    scale_factor: i32,
85    wl_output: wl_output::WlOutput,
86}
87
88#[derive(Clone)]
89struct OutputLifecycle {
90    name: String,
91    render_state: std::sync::Arc<tokio::sync::Mutex<RenderState>>,
92    active: std::sync::Arc<std::sync::atomic::AtomicBool>,
93}
94
95struct WaylandState {
96    registry_state: RegistryState,
97    output_state: OutputState,
98    compositor_state: CompositorState,
99    shm: Shm,
100    outputs: std::collections::HashMap<u32, OutputInfo>,
101    surfaces: Vec<(u32, LayerSurface)>,
102    viewporter: Option<WpViewporter>,
103    viewports: std::collections::HashMap<u32, WpViewport>,
104    output_lifecycles: std::collections::HashMap<u32, OutputLifecycle>,
105    pending_restores: std::collections::HashSet<u32>,
106    /// Layer shell protocol object for creating background surfaces.
107    layer_shell: LayerShell,
108    /// Compositor protocol object for creating input regions.
109    compositor: wl_compositor::WlCompositor,
110    /// Shared daemon context for hotplug: creates and destroys render states
111    /// when outputs appear or disappear.
112    hotplug: Option<DaemonHotplug>,
113}
114
115/// Wrapper around `*mut c_void` that implements `Send`. The pointer is a
116/// Wayland display pointer that lives for the entire process lifetime.
117struct SendDisplayPtr(*mut std::ffi::c_void);
118unsafe impl Send for SendDisplayPtr {}
119
120/// Shared context for hotplug operations. Stored in `WaylandState` so the
121/// output callbacks can create/destroy render states without needing access
122/// to the full `Daemon` state.
123struct DaemonHotplug {
124    renderer: std::sync::Arc<Renderer>,
125    config: crate::config::WallrConfig,
126    display_ptr: SendDisplayPtr,
127    /// Shared render-state map. Protected by `tokio::sync::Mutex` so the IPC
128    /// handler (async) and the Wayland callbacks (sync, via `blocking_dispatch`)
129    /// can both access it. The Wayland thread never holds this across an await,
130    /// so there is no risk of deadlocking the event loop.
131    render_states: std::sync::Arc<
132        tokio::sync::Mutex<
133            std::collections::HashMap<String, std::sync::Arc<tokio::sync::Mutex<RenderState>>>,
134        >,
135    >,
136}
137
138impl ProvidesRegistryState for WaylandState {
139    fn registry(&mut self) -> &mut RegistryState {
140        &mut self.registry_state
141    }
142
143    registry_handlers![OutputState,];
144}
145
146impl CompositorHandler for WaylandState {
147    fn scale_factor_changed(
148        &mut self,
149        _conn: &Connection,
150        _qh: &QueueHandle<Self>,
151        _surface: &wl_surface::WlSurface,
152        _new_factor: i32,
153    ) {
154    }
155    fn transform_changed(
156        &mut self,
157        _conn: &Connection,
158        _qh: &QueueHandle<Self>,
159        _surface: &wl_surface::WlSurface,
160        _new_transform: wl_output::Transform,
161    ) {
162    }
163    fn frame(
164        &mut self,
165        _conn: &Connection,
166        _qh: &QueueHandle<Self>,
167        _surface: &wl_surface::WlSurface,
168        _time: u32,
169    ) {
170    }
171    fn surface_enter(
172        &mut self,
173        _conn: &Connection,
174        _qh: &QueueHandle<Self>,
175        _surface: &wl_surface::WlSurface,
176        _output: &wl_output::WlOutput,
177    ) {
178    }
179    fn surface_leave(
180        &mut self,
181        _conn: &Connection,
182        _qh: &QueueHandle<Self>,
183        _surface: &wl_surface::WlSurface,
184        _output: &wl_output::WlOutput,
185    ) {
186    }
187}
188
189impl wayland_client::Dispatch<wayland_client::protocol::wl_region::WlRegion, ()> for WaylandState {
190    fn event(
191        _state: &mut WaylandState,
192        _region: &wayland_client::protocol::wl_region::WlRegion,
193        _event: wayland_client::protocol::wl_region::Event,
194        _data: &(),
195        _conn: &Connection,
196        _qh: &QueueHandle<WaylandState>,
197    ) {
198    }
199}
200
201impl Dispatch<WpViewporter, ()> for WaylandState {
202    fn event(
203        _state: &mut WaylandState,
204        _proxy: &WpViewporter,
205        _event: wp_viewporter::Event,
206        _data: &(),
207        _conn: &Connection,
208        _qh: &QueueHandle<WaylandState>,
209    ) {
210    }
211}
212
213impl Dispatch<WpViewport, ()> for WaylandState {
214    fn event(
215        _state: &mut WaylandState,
216        _proxy: &WpViewport,
217        _event: wp_viewport::Event,
218        _data: &(),
219        _conn: &Connection,
220        _qh: &QueueHandle<WaylandState>,
221    ) {
222    }
223}
224
225fn viewport_destination(configured: (u32, u32), physical: (u32, u32)) -> Option<(i32, i32)> {
226    let (width, height) = configured;
227    let (physical_width, physical_height) = physical;
228    match (width, height) {
229        (0, 0) => None,
230        (0, height) if physical_height > 0 => {
231            let width = (u64::from(height) * u64::from(physical_width)
232                + u64::from(physical_height) / 2)
233                / u64::from(physical_height);
234            Some((i32::try_from(width).ok()?, i32::try_from(height).ok()?))
235        }
236        (width, 0) if physical_width > 0 => {
237            let height = (u64::from(width) * u64::from(physical_height)
238                + u64::from(physical_width) / 2)
239                / u64::from(physical_width);
240            Some((i32::try_from(width).ok()?, i32::try_from(height).ok()?))
241        }
242        (width, height) => Some((i32::try_from(width).ok()?, i32::try_from(height).ok()?)),
243    }
244}
245
246#[cfg(test)]
247mod viewport_tests {
248    use super::{
249        VideoPresentAction, is_transient_wallpaper_error, persist_wallpaper_at,
250        read_wallpaper_state, video_present_action, viewport_destination, write_wallpaper_state,
251    };
252    use crate::renderer::FrameStatus;
253
254    #[test]
255    fn preserves_complete_configure_size() {
256        assert_eq!(
257            viewport_destination((3072, 1728), (3840, 2160)),
258            Some((3072, 1728))
259        );
260    }
261
262    #[test]
263    fn derives_missing_dimension_from_physical_aspect_ratio() {
264        assert_eq!(
265            viewport_destination((0, 1728), (3840, 2160)),
266            Some((3072, 1728))
267        );
268        assert_eq!(
269            viewport_destination((3072, 0), (3840, 2160)),
270            Some((3072, 1728))
271        );
272        assert_eq!(viewport_destination((0, 0), (3840, 2160)), None);
273    }
274
275    #[test]
276    fn retries_recoverable_video_surface_failures() {
277        assert_eq!(
278            video_present_action(FrameStatus::TimedOut),
279            VideoPresentAction::Retry
280        );
281        assert_eq!(
282            video_present_action(FrameStatus::Outdated),
283            VideoPresentAction::Reconfigure
284        );
285        assert_eq!(
286            video_present_action(FrameStatus::Lost),
287            VideoPresentAction::Reconfigure
288        );
289    }
290
291    #[test]
292    fn rotates_wallpaper_state_only_after_successful_persistence() {
293        let temporary = tempfile::tempdir().expect("temporary directory");
294        let first = temporary.path().join("first.jpg");
295        let second = temporary.path().join("second.jpg");
296        std::fs::write(&first, b"first").expect("first wallpaper");
297        std::fs::write(&second, b"second").expect("second wallpaper");
298
299        persist_wallpaper_at(temporary.path(), "DP-1", &first).expect("persist first");
300        assert_eq!(
301            read_wallpaper_state(temporary.path(), "last_wallpaper", "DP-1"),
302            Some(first.clone())
303        );
304        assert_eq!(
305            read_wallpaper_state(temporary.path(), "previous_wallpaper", "DP-1"),
306            None
307        );
308
309        persist_wallpaper_at(temporary.path(), "DP-1", &second).expect("persist second");
310        assert_eq!(
311            read_wallpaper_state(temporary.path(), "last_wallpaper", "DP-1"),
312            Some(second)
313        );
314        assert_eq!(
315            read_wallpaper_state(temporary.path(), "previous_wallpaper", "DP-1"),
316            Some(first)
317        );
318
319        let missing = temporary.path().join("missing.jpg");
320        write_wallpaper_state(temporary.path(), "last_wallpaper", "DP-1", &missing)
321            .expect("persist missing path for restore test");
322        assert_eq!(
323            read_wallpaper_state(temporary.path(), "last_wallpaper", "DP-1"),
324            Some(missing)
325        );
326    }
327
328    #[test]
329    fn wallpaper_state_preserves_non_utf8_and_whitespace() {
330        use std::os::unix::ffi::OsStringExt;
331
332        let temporary = tempfile::tempdir().expect("temporary directory");
333        let wallpaper = std::path::PathBuf::from(std::ffi::OsString::from_vec(
334            b" /tmp/wallpaper-\xff.jpg ".to_vec(),
335        ));
336
337        write_wallpaper_state(temporary.path(), "last_wallpaper", "DP-1", &wallpaper)
338            .expect("persist wallpaper path");
339
340        assert_eq!(
341            read_wallpaper_state(temporary.path(), "last_wallpaper", "DP-1"),
342            Some(wallpaper)
343        );
344    }
345
346    #[test]
347    fn retries_only_explicitly_transient_errors() {
348        let timed_out = anyhow::Error::new(std::io::Error::new(
349            std::io::ErrorKind::TimedOut,
350            "temporary timeout",
351        ));
352        let missing = anyhow::Error::new(std::io::Error::new(
353            std::io::ErrorKind::NotFound,
354            "missing wallpaper",
355        ));
356        let recoverable_video = anyhow::Error::new(crate::video::VideoError::QueueFull);
357
358        assert!(is_transient_wallpaper_error(&timed_out));
359        assert!(is_transient_wallpaper_error(&recoverable_video));
360        assert!(!is_transient_wallpaper_error(&missing));
361        assert!(!is_transient_wallpaper_error(&anyhow::anyhow!(
362            "invalid dimensions"
363        )));
364    }
365}
366
367impl LayerShellHandler for WaylandState {
368    fn configure(
369        &mut self,
370        _conn: &Connection,
371        _qh: &QueueHandle<Self>,
372        layer: &LayerSurface,
373        configure: LayerSurfaceConfigure,
374        _serial: u32,
375    ) {
376        // SCTK acknowledges the configure. wgpu owns subsequent buffer commits,
377        // which must not race with a bufferless commit when explicit sync is active.
378        let output_id = self.surfaces.iter().find_map(|(output_id, surface)| {
379            (surface.wl_surface().id() == layer.wl_surface().id()).then_some(*output_id)
380        });
381        let Some(output_id) = output_id else {
382            return;
383        };
384        let destination = self.outputs.get(&output_id).and_then(|output| {
385            viewport_destination(configure.new_size, (output.width, output.height))
386        });
387        if let Some(viewport) = self.viewports.get(&output_id) {
388            let Some((logical_width, logical_height)) = destination else {
389                tracing::warn!(
390                    "Output {output_id} configure omitted both dimensions; waiting for a usable size"
391                );
392                return;
393            };
394            viewport.set_destination(logical_width, logical_height);
395            tracing::debug!(
396                "Configured viewport destination for output {output_id}: {logical_width}x{logical_height}"
397            );
398        }
399        if !self.pending_restores.remove(&output_id) {
400            return;
401        }
402        let Some(lifecycle) = self.output_lifecycles.get(&output_id).cloned() else {
403            return;
404        };
405        let Some(render_states) = self
406            .hotplug
407            .as_ref()
408            .map(|hotplug| hotplug.render_states.clone())
409        else {
410            return;
411        };
412
413        tokio::spawn(async move {
414            if !lifecycle.active.load(Ordering::SeqCst) {
415                return;
416            }
417            restore_cached_wallpaper(&lifecycle.name, &lifecycle.render_state).await;
418
419            let mut states = render_states.lock().await;
420            if lifecycle.active.load(Ordering::SeqCst) {
421                states.insert(lifecycle.name.clone(), lifecycle.render_state);
422                tracing::info!("Output configured: {}", lifecycle.name);
423            }
424        });
425    }
426
427    fn closed(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _layer: &LayerSurface) {}
428}
429
430impl ShmHandler for WaylandState {
431    fn shm_state(&mut self) -> &mut Shm {
432        &mut self.shm
433    }
434}
435
436impl OutputHandler for WaylandState {
437    fn output_state(&mut self) -> &mut OutputState {
438        &mut self.output_state
439    }
440    fn new_output(
441        &mut self,
442        _conn: &Connection,
443        _qh: &QueueHandle<Self>,
444        output: wl_output::WlOutput,
445    ) {
446        let id = output.id().protocol_id();
447        tracing::info!("Output detected: protocol_id={id}");
448
449        let mut info = OutputInfo {
450            name: format!("output-{id}"),
451            width: 1920,
452            height: 1080,
453            scale_factor: 1,
454            wl_output: output,
455        };
456
457        // Resolve the compositor-provided name (e.g. "DP-1", "HDMI-A-1").
458        if let Some(info_data) = self.output_state.info(&info.wl_output) {
459            if let Some(mode) = info_data.modes.iter().find(|m| m.current) {
460                info.width = mode.dimensions.0 as u32;
461                info.height = mode.dimensions.1 as u32;
462            }
463            info.scale_factor = info_data.scale_factor;
464            let resolved = info_data
465                .name
466                .as_deref()
467                .filter(|n| !n.is_empty())
468                .or(info_data.description.as_deref().filter(|n| !n.is_empty()));
469            if let Some(real_name) = resolved {
470                info.name = real_name.to_string();
471            } else if !info_data.make.is_empty() || !info_data.model.is_empty() {
472                let fallback = format!("{} {}", info_data.make, info_data.model)
473                    .trim()
474                    .to_string();
475                if !fallback.is_empty() {
476                    info.name = fallback;
477                }
478            }
479        }
480
481        self.outputs.insert(id, info.clone());
482
483        // Create a render state for the new output.
484        // Extract values from hotplug before passing &mut self to avoid
485        // borrow checker conflicts (hotplug is inside self).
486        if self.hotplug.is_some() {
487            let name = info.name.clone();
488            let renderer = self.hotplug.as_ref().unwrap().renderer.clone();
489            let display_ptr = self.hotplug.as_ref().unwrap().display_ptr.0;
490            let config = self.hotplug.as_ref().unwrap().config.clone();
491            match create_render_state_for_output_sync(
492                &renderer,
493                display_ptr,
494                self,
495                _qh,
496                &info,
497                &config,
498            ) {
499                Ok(rs) => {
500                    let rs = std::sync::Arc::new(tokio::sync::Mutex::new(rs));
501                    self.output_lifecycles.insert(
502                        id,
503                        OutputLifecycle {
504                            name,
505                            render_state: rs,
506                            active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)),
507                        },
508                    );
509                    self.pending_restores.insert(id);
510                }
511                Err(e) => {
512                    tracing::error!("Hotplug: failed to create render state for {name}: {e}");
513                }
514            }
515        }
516    }
517    fn update_output(
518        &mut self,
519        _conn: &Connection,
520        _qh: &QueueHandle<Self>,
521        output: wl_output::WlOutput,
522    ) {
523        let id = output.id().protocol_id();
524        if let Some(info) = self.outputs.get_mut(&id) {
525            let mut new_width = info.width;
526            let mut new_height = info.height;
527            let mut new_scale = info.scale_factor;
528
529            if let Some(mode) = self
530                .output_state
531                .info(&output)
532                .and_then(|i| i.modes.iter().find(|m| m.current).cloned())
533            {
534                new_width = mode.dimensions.0 as u32;
535                new_height = mode.dimensions.1 as u32;
536            }
537            if let Some(info_data) = self.output_state.info(&output) {
538                new_scale = info_data.scale_factor;
539                let resolved = info_data
540                    .name
541                    .as_deref()
542                    .filter(|n| !n.is_empty())
543                    .or(info_data.description.as_deref().filter(|n| !n.is_empty()));
544                if let Some(real_name) = resolved {
545                    if real_name != info.name {
546                        tracing::info!(
547                            "Output {id}: resolved name '{}' -> '{}'",
548                            info.name,
549                            real_name
550                        );
551                        info.name = real_name.to_string();
552                    }
553                } else if info.name.starts_with("output-")
554                    && (!info_data.make.is_empty() || !info_data.model.is_empty())
555                {
556                    let fallback = format!("{} {}", info_data.make, info_data.model)
557                        .trim()
558                        .to_string();
559                    if !fallback.is_empty() {
560                        tracing::info!(
561                            "Output {id}: fallback name '{}' -> '{}'",
562                            info.name,
563                            fallback
564                        );
565                        info.name = fallback;
566                    }
567                }
568            }
569
570            let changed = new_width != info.width
571                || new_height != info.height
572                || new_scale != info.scale_factor;
573
574            info.width = new_width;
575            info.height = new_height;
576            info.scale_factor = new_scale;
577
578            // Reconfigure the wgpu surface when dimensions or scale change.
579            if changed {
580                let name = info.name.clone();
581                if let Some(ref hotplug) = self.hotplug {
582                    let render_states = hotplug.render_states.clone();
583                    let renderer = hotplug.renderer.clone();
584                    tokio::spawn(async move {
585                        let states = render_states.lock().await;
586                        if let Some(rs) = states.get(&name) {
587                            let mut lock = rs.lock().await;
588                            lock.width = new_width;
589                            lock.height = new_height;
590                            let surf_config = wgpu::SurfaceConfiguration {
591                                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
592                                format: lock.format,
593                                width: new_width,
594                                height: new_height,
595                                present_mode: wgpu::PresentMode::Fifo,
596                                alpha_mode: wgpu::CompositeAlphaMode::Opaque,
597                                view_formats: vec![],
598                                desired_maximum_frame_latency: 2,
599                            };
600                            lock.surface.configure(&renderer.device, &surf_config);
601                            tracing::info!(
602                                "Hotplug: reconfigured {name} to {new_width}x{new_height}"
603                            );
604                        }
605                    });
606                }
607            }
608        }
609    }
610    fn output_destroyed(
611        &mut self,
612        _conn: &Connection,
613        _qh: &QueueHandle<Self>,
614        output: wl_output::WlOutput,
615    ) {
616        let id = output.id().protocol_id();
617        if let Some(info) = self.outputs.remove(&id) {
618            tracing::info!("Output disconnected: {} (protocol_id={})", info.name, id);
619            self.pending_restores.remove(&id);
620            let viewport = self.viewports.remove(&id);
621            let layer_surface = self
622                .surfaces
623                .iter()
624                .position(|(pid, _)| *pid == id)
625                .map(|position| self.surfaces.swap_remove(position).1);
626            let lifecycle = self.output_lifecycles.remove(&id);
627            if let Some(lifecycle) = &lifecycle {
628                lifecycle.active.store(false, Ordering::SeqCst);
629            }
630            // Remove the render state from the shared map and stop playback.
631            if let (Some(hotplug), Some(lifecycle)) = (&self.hotplug, lifecycle) {
632                let render_states = hotplug.render_states.clone();
633                tokio::spawn(async move {
634                    let mut states = render_states.lock().await;
635                    if states
636                        .get(&lifecycle.name)
637                        .is_some_and(|state| Arc::ptr_eq(state, &lifecycle.render_state))
638                    {
639                        states.remove(&lifecycle.name);
640                    }
641                    drop(states);
642
643                    let state = lifecycle.render_state.lock().await;
644                    state.playback_gen.fetch_add(1, Ordering::SeqCst);
645                    state.pacer.notify();
646                    state.video_playback.stop();
647                    let render_lock = state.render_lock.clone();
648                    drop(state);
649
650                    let _ = tokio::task::spawn_blocking(move || {
651                        drop(
652                            render_lock
653                                .lock()
654                                .unwrap_or_else(|poisoned| poisoned.into_inner()),
655                        );
656                    })
657                    .await;
658                    if let Some(viewport) = viewport {
659                        viewport.destroy();
660                    }
661                    drop(layer_surface);
662                    tracing::info!("Hotplug: cleaned up render state for {}", lifecycle.name);
663                });
664            } else {
665                if let Some(viewport) = viewport {
666                    viewport.destroy();
667                }
668                drop(layer_surface);
669            }
670        }
671    }
672}
673
674delegate_compositor!(WaylandState);
675delegate_layer!(WaylandState);
676delegate_output!(WaylandState);
677delegate_registry!(WaylandState);
678delegate_shm!(WaylandState);
679
680/// Wakes paced live-playback loops when a new commit bumps the generation.
681struct LivePacer {
682    lock: std::sync::Mutex<()>,
683    cond: std::sync::Condvar,
684}
685
686impl LivePacer {
687    fn new() -> Self {
688        Self {
689            lock: std::sync::Mutex::new(()),
690            cond: std::sync::Condvar::new(),
691        }
692    }
693
694    fn notify(&self) {
695        let _guard = self.lock.lock().unwrap();
696        self.cond.notify_all();
697    }
698
699    /// Blocks until `deadline` or until `notify` is called, whichever comes
700    /// first.
701    fn wait_until(&self, deadline: std::time::Instant) {
702        let guard = self.lock.lock().unwrap();
703        let now = std::time::Instant::now();
704        if deadline <= now {
705            return;
706        }
707        let _ = self
708            .cond
709            .wait_timeout_while(guard, deadline - now, |_| true);
710    }
711}
712
713struct RenderState {
714    renderer: std::sync::Arc<Renderer>,
715    surface: &'static wgpu::Surface<'static>,
716    /// Serializes transition rendering. The lock is only ever held by the
717    /// detached render task, never by the IPC loop, so a stalled present
718    /// cannot freeze the daemon.
719    render_lock: std::sync::Arc<std::sync::Mutex<()>>,
720    /// Bumped on every commit. Live playback checks it each frame and stops
721    /// as soon as a new wallpaper supersedes the one it is playing.
722    playback_gen: std::sync::Arc<std::sync::atomic::AtomicU64>,
723    /// Wakes paced live-playback loops when a new commit bumps the generation,
724    /// so an old player exits immediately instead of after its sleep quantum.
725    pacer: std::sync::Arc<LivePacer>,
726    current_bind: Option<wgpu::BindGroup>,
727    current_tex: Option<wgpu::Texture>,
728    width: u32,
729    height: u32,
730    current_width: u32,
731    current_height: u32,
732    format: wgpu::TextureFormat,
733    /// Video playback manager
734    video_playback: std::sync::Arc<crate::video::VideoPlayback>,
735    /// Hardware backend to request for new decoders (from `video.hw_decode`).
736    hw_accel: crate::video::HwAccel,
737    /// Maximum decoded frames buffered ahead of presentation.
738    preload_frames: usize,
739    /// Optional cap for live video presentation.
740    max_fps: Option<u32>,
741    /// Current scaling mode for live playback.
742    scaling_mode: u32,
743    /// Per-output uniform buffer + bind group (Issue #9 race fix).
744    per_output_uniforms: std::sync::Arc<crate::renderer::PerOutputUniforms>,
745    /// Path of the last wallpaper set on this output (for restore).
746    last_wallpaper: Option<std::path::PathBuf>,
747    /// Previous wallpaper state before blank (for restore).
748    pre_blank: Option<(std::path::PathBuf, u32)>,
749    /// Whether this output is currently blanked.
750    blanked: bool,
751    /// GIF playback paused state (shared with play_live task).
752    gif_paused: std::sync::Arc<std::sync::atomic::AtomicBool>,
753}
754
755/// Everything the transition render task needs; the daemon state has already
756/// been promoted to the new wallpaper before a transition is spawned.
757struct CommitData {
758    bg_bind: wgpu::BindGroup,
759    new_bind: wgpu::BindGroup,
760    img_width: u32,
761    img_height: u32,
762    old_img_width: u32,
763    old_img_height: u32,
764    format: wgpu::TextureFormat,
765    width: u32,
766    height: u32,
767    /// Animated frames to play live after the transition, when the committed
768    /// file is a GIF.
769    animated: Option<crate::animated::AnimatedImage>,
770    /// Video metadata when committed file is a video.
771    is_video: bool,
772    /// Plane and conversion resources retained across transition and playback.
773    video_texture: Option<crate::renderer::VideoTexture>,
774    /// Playback generation captured at commit time; live playback stops when
775    /// it no longer matches `RenderState::playback_gen`.
776    generation: u64,
777    /// Scaling mode: 0=Fill, 1=Fit, 2=Stretch, 3=Center, 4=Tile.
778    scaling_mode: u32,
779    /// Optional cap for live video presentation.
780    max_fps: Option<u32>,
781}
782
783impl RenderState {
784    fn set_wallpaper(
785        &mut self,
786        path: &std::path::Path,
787        effect: &crate::animation::Effect,
788        duration_ms: u32,
789        scaling_mode: u32,
790    ) -> anyhow::Result<()> {
791        let commit = self.commit_wallpaper(path, scaling_mode)?;
792        self.scaling_mode = scaling_mode;
793        self.spawn_transition(commit, effect, duration_ms);
794        // Update last_wallpaper after successful commit
795        self.last_wallpaper = Some(path.to_path_buf());
796        Ok(())
797    }
798
799    /// Loads the new wallpaper and atomically promotes it to the current
800    /// frame. The outgoing bind group stays alive for the transition, so the
801    /// render task can keep drawing from it after this commit returns.
802    fn commit_wallpaper(
803        &mut self,
804        path: &std::path::Path,
805        scaling_mode: u32,
806    ) -> anyhow::Result<CommitData> {
807        use image::{ImageDecoder, ImageReader};
808
809        // Check if this is a video file FIRST
810        if crate::video::VideoDecoder::is_video_file(path) {
811            tracing::info!("Video file detected: {:?}", path);
812
813            let generation = self.playback_gen.load(Ordering::SeqCst).wrapping_add(1);
814            let renderer = self.renderer.clone();
815            // Prepare and validate the new decoder before replacing active
816            // playback. A failed video therefore leaves the old wallpaper and
817            // decoder untouched.
818            let mut prepared = crate::video::VideoPlayback::prepare(
819                path,
820                self.hw_accel,
821                self.preload_frames,
822                std::time::Duration::from_millis(1000),
823                move |metadata| {
824                    renderer
825                        .validate_video_texture(metadata.width, metadata.height)
826                        .map_err(crate::video::VideoError::GpuResourceCreation)
827                },
828            )?;
829            let metadata = prepared.metadata().clone();
830            let first_frame = prepared.take_first_frame();
831
832            let (tex_width, tex_height) = first_frame
833                .as_ref()
834                .map(|frame| (frame.width, frame.height))
835                .unwrap_or((metadata.width, metadata.height));
836            let video_texture = self.renderer.create_video_texture(tex_width, tex_height)?;
837            let (img_width, img_height) = if let Some(frame) = first_frame {
838                self.renderer
839                    .update_video_texture(&video_texture, &frame.data)?;
840                (frame.width, frame.height)
841            } else {
842                // WebGPU initializes the output to black when no frame arrives.
843                tracing::warn!("No first frame available, using black texture");
844                (metadata.width, metadata.height)
845            };
846            self.video_playback.commit(prepared, generation);
847            self.playback_gen.store(generation, Ordering::SeqCst);
848            self.pacer.notify();
849            let new_tex = video_texture.texture().clone();
850            let new_bind = video_texture.bind_group().clone();
851
852            let old_bind = self.current_bind.take();
853            let (old_img_width, old_img_height) = if old_bind.is_some() {
854                (self.current_width.max(1), self.current_height.max(1))
855            } else {
856                (img_width, img_height)
857            };
858            let bg_bind = old_bind.unwrap_or_else(|| new_bind.clone());
859
860            drop(self.current_tex.take());
861            self.current_tex = Some(new_tex);
862            self.current_bind = Some(new_bind.clone());
863            self.current_width = img_width;
864            self.current_height = img_height;
865
866            return Ok(CommitData {
867                bg_bind,
868                new_bind,
869                img_width,
870                img_height,
871                old_img_width,
872                old_img_height,
873                format: self.format,
874                width: self.width,
875                height: self.height,
876                animated: None,
877                is_video: true,
878                video_texture: Some(video_texture),
879                generation,
880                scaling_mode,
881                max_fps: self.max_fps,
882            });
883        }
884
885        // Stream animated frames (GIF) on demand during playback; the
886        // transition's incoming texture is the GIF's first frame.
887        let mut animated = crate::animated::AnimatedImage::decode(path)?;
888        let (new_tex, new_bind, img_width, img_height) = if let Some(anim) = animated.as_mut() {
889            let (w, h) = (anim.width, anim.height);
890            let (tex, bind) = self.renderer.create_texture(w, h)?;
891            let first = anim.first_frame();
892            if !first.is_empty() {
893                self.renderer.update_texture(&tex, first, w, h);
894            }
895            (tex, bind, w, h)
896        } else {
897            let decoder = ImageReader::open(path)?.into_decoder()?;
898            let (source_width, source_height) = decoder.dimensions();
899            Renderer::validate_static_decode(source_width, source_height, decoder.total_bytes())?;
900            let new_img = image::DynamicImage::from_decoder(decoder)?;
901            let (tex, bind, width, height) =
902                self.renderer
903                    .load_texture(&new_img, self.width, self.height, scaling_mode)?;
904            (tex, bind, width, height)
905        };
906
907        // Only supersede active video playback after the replacement has
908        // decoded and allocated successfully.
909        self.video_playback.stop();
910
911        let old_bind = self.current_bind.take();
912        let (old_img_width, old_img_height) = if old_bind.is_some() {
913            (self.current_width.max(1), self.current_height.max(1))
914        } else {
915            (img_width, img_height)
916        };
917        // Keep the last image as the outgoing frame. On the first ever run,
918        // using the incoming image for both sides is a clean no-op transition;
919        // it avoids a black flash while still allowing the cached wallpaper
920        // restored at daemon startup to become the real outgoing frame.
921        let bg_bind = old_bind.unwrap_or_else(|| new_bind.clone());
922
923        drop(self.current_tex.take());
924        self.current_tex = Some(new_tex);
925        self.current_bind = Some(new_bind.clone());
926        self.current_width = img_width;
927        self.current_height = img_height;
928
929        let generation = self.playback_gen.fetch_add(1, Ordering::SeqCst) + 1;
930        self.pacer.notify();
931
932        Ok(CommitData {
933            bg_bind,
934            new_bind,
935            img_width,
936            img_height,
937            old_img_width,
938            old_img_height,
939            format: self.format,
940            width: self.width,
941            height: self.height,
942            animated,
943            is_video: false,
944            video_texture: None,
945            generation,
946            scaling_mode,
947            max_fps: self.max_fps,
948        })
949    }
950
951    /// Renders the committed transition on a detached blocking task. The IPC
952    /// path never waits on GPU presents, so a stalled compositor (monitor
953    /// off, suspend) cannot hang the daemon. Transitions are serialized by
954    /// the render lock: a later one simply waits until the earlier drains.
955    fn spawn_transition(
956        &self,
957        commit: CommitData,
958        effect: &crate::animation::Effect,
959        duration_ms: u32,
960    ) {
961        let renderer = self.renderer.clone();
962        let surface: &'static wgpu::Surface<'static> = self.surface;
963        let render_lock = self.render_lock.clone();
964        let playback_gen = self.playback_gen.clone();
965        let pacer = self.pacer.clone();
966        let video_playback = self.video_playback.clone();
967        let per_output_uniforms = std::sync::Arc::clone(&self.per_output_uniforms);
968        let gif_paused = self.gif_paused.clone();
969        let effect = effect.clone();
970        drop(tokio::task::spawn_blocking(move || {
971            render_transition(
972                renderer,
973                surface,
974                render_lock,
975                playback_gen,
976                pacer,
977                video_playback,
978                gif_paused,
979                commit,
980                effect,
981                duration_ms,
982                &per_output_uniforms,
983            );
984        }));
985    }
986}
987
988async fn restore_cached_wallpaper(name: &str, render_state: &Arc<Mutex<RenderState>>) {
989    let state_root = wallpaper_state_root();
990    let Some(path) = read_wallpaper_state(&state_root, "last_wallpaper", name) else {
991        return;
992    };
993
994    let effect = crate::animation::Effect::Fade(crate::animation::FadeParams::default());
995    if let Err(err) = set_wallpaper_with_retry(render_state, &path, &effect, 1000, 0).await {
996        tracing::warn!("Failed to restore wallpaper for {name} from {path:?}: {err}");
997        let Some(previous) = read_wallpaper_state(&state_root, "previous_wallpaper", name) else {
998            return;
999        };
1000        match set_wallpaper_with_retry(render_state, &previous, &effect, 1000, 0).await {
1001            Ok(()) => {
1002                if let Err(persist_err) =
1003                    write_wallpaper_state(&state_root, "last_wallpaper", name, &previous)
1004                {
1005                    tracing::warn!(
1006                        "Restored previous wallpaper for {name}, but failed to update state: {persist_err}"
1007                    );
1008                } else {
1009                    tracing::warn!("Restored previous wallpaper for {name} after {path:?} failed");
1010                }
1011            }
1012            Err(previous_err) => tracing::warn!(
1013                "Failed to restore previous wallpaper for {name} from {previous:?}: {previous_err}"
1014            ),
1015        }
1016    }
1017}
1018
1019const WALLPAPER_RETRY_ATTEMPTS: usize = 3;
1020const WALLPAPER_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(150);
1021
1022async fn set_wallpaper_with_retry(
1023    render_state: &Arc<Mutex<RenderState>>,
1024    path: &std::path::Path,
1025    effect: &crate::animation::Effect,
1026    duration_ms: u32,
1027    scaling_mode: u32,
1028) -> anyhow::Result<()> {
1029    let render_state = Arc::clone(render_state);
1030    let path = path.to_path_buf();
1031    let effect = effect.clone();
1032
1033    tokio::task::spawn_blocking(move || {
1034        let mut attempt = 1;
1035        loop {
1036            let result = {
1037                let mut state = render_state.blocking_lock();
1038                state.set_wallpaper(&path, &effect, duration_ms, scaling_mode)
1039            };
1040            match result {
1041                Ok(()) => return Ok(()),
1042                Err(err)
1043                    if attempt < WALLPAPER_RETRY_ATTEMPTS
1044                        && is_transient_wallpaper_error(&err) =>
1045                {
1046                    tracing::warn!(
1047                        "Transient wallpaper error for {path:?} (attempt {attempt}/{WALLPAPER_RETRY_ATTEMPTS}): {err}"
1048                    );
1049                    attempt += 1;
1050                    std::thread::sleep(WALLPAPER_RETRY_DELAY);
1051                }
1052                Err(err) => return Err(err),
1053            }
1054        }
1055    })
1056    .await?
1057}
1058
1059fn is_transient_wallpaper_error(err: &anyhow::Error) -> bool {
1060    err.chain().any(|cause| {
1061        cause
1062            .downcast_ref::<std::io::Error>()
1063            .is_some_and(|io_err| {
1064                matches!(
1065                    io_err.kind(),
1066                    std::io::ErrorKind::Interrupted
1067                        | std::io::ErrorKind::WouldBlock
1068                        | std::io::ErrorKind::TimedOut
1069                )
1070            })
1071            || cause
1072                .downcast_ref::<crate::video::VideoError>()
1073                .is_some_and(crate::video::VideoError::is_recoverable)
1074    })
1075}
1076
1077fn wallpaper_state_root() -> std::path::PathBuf {
1078    dirs::cache_dir()
1079        .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
1080        .join("wallr")
1081}
1082
1083fn wallpaper_state_path(root: &std::path::Path, state_dir: &str, name: &str) -> std::path::PathBuf {
1084    root.join(state_dir).join(name)
1085}
1086
1087fn read_wallpaper_state(
1088    root: &std::path::Path,
1089    state_dir: &str,
1090    name: &str,
1091) -> Option<std::path::PathBuf> {
1092    use std::os::unix::ffi::OsStringExt;
1093
1094    let path = std::fs::read(wallpaper_state_path(root, state_dir, name)).ok()?;
1095    let wallpaper = std::path::PathBuf::from(std::ffi::OsString::from_vec(path));
1096    (!wallpaper.as_os_str().is_empty()).then_some(wallpaper)
1097}
1098
1099fn write_wallpaper_state(
1100    root: &std::path::Path,
1101    state_dir: &str,
1102    name: &str,
1103    wallpaper: &std::path::Path,
1104) -> std::io::Result<()> {
1105    use std::io::Write;
1106    use std::os::unix::ffi::OsStrExt;
1107    use std::sync::atomic::{AtomicU64, Ordering};
1108
1109    static TEMP_ID: AtomicU64 = AtomicU64::new(0);
1110
1111    let state_path = wallpaper_state_path(root, state_dir, name);
1112    let parent = state_path
1113        .parent()
1114        .ok_or_else(|| std::io::Error::other("wallpaper state path has no parent"))?;
1115    std::fs::create_dir_all(parent)?;
1116    let file_name = state_path
1117        .file_name()
1118        .and_then(|value| value.to_str())
1119        .unwrap_or("wallpaper");
1120
1121    loop {
1122        let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
1123        let temporary_path = parent.join(format!(".{file_name}.{}.{}.tmp", std::process::id(), id));
1124        match std::fs::OpenOptions::new()
1125            .write(true)
1126            .create_new(true)
1127            .open(&temporary_path)
1128        {
1129            Ok(mut temporary) => {
1130                if let Err(err) = temporary
1131                    .write_all(wallpaper.as_os_str().as_bytes())
1132                    .and_then(|()| temporary.sync_all())
1133                    .and_then(|()| std::fs::rename(&temporary_path, &state_path))
1134                {
1135                    let _ = std::fs::remove_file(&temporary_path);
1136                    return Err(err);
1137                }
1138                return Ok(());
1139            }
1140            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
1141            Err(err) => return Err(err),
1142        }
1143    }
1144}
1145
1146fn persist_wallpaper(name: &str, wallpaper: &std::path::Path) -> std::io::Result<()> {
1147    let root = wallpaper_state_root();
1148    persist_wallpaper_at(&root, name, wallpaper)
1149}
1150
1151fn persist_wallpaper_at(
1152    root: &std::path::Path,
1153    name: &str,
1154    wallpaper: &std::path::Path,
1155) -> std::io::Result<()> {
1156    let previous = read_wallpaper_state(root, "last_wallpaper", name)
1157        .filter(|current| current.exists() && current != wallpaper);
1158    write_wallpaper_state(root, "last_wallpaper", name, wallpaper)?;
1159    if let Some(previous) = previous {
1160        write_wallpaper_state(root, "previous_wallpaper", name, &previous)?;
1161    }
1162    Ok(())
1163}
1164
1165/// Presents one frame per vsync until the wall-clock duration elapses. With
1166/// PresentMode::Fifo, `get_current_texture` blocks until the previous frame
1167/// is presented, so this loop is paced to the monitor refresh rate, and the
1168/// transition lasts exactly `duration_ms` on any refresh rate — frame-count
1169/// pacing would run too fast on high-refresh panels and too slow when the
1170/// present rate is low. If the compositor stops presenting, the loop can park
1171/// inside a present; that is fine here because the task is detached.
1172#[allow(clippy::too_many_arguments)]
1173fn render_transition(
1174    renderer: std::sync::Arc<Renderer>,
1175    surface: &'static wgpu::Surface<'static>,
1176    render_lock: std::sync::Arc<std::sync::Mutex<()>>,
1177    playback_gen: std::sync::Arc<std::sync::atomic::AtomicU64>,
1178    pacer: std::sync::Arc<LivePacer>,
1179    video_playback: std::sync::Arc<crate::video::VideoPlayback>,
1180    gif_paused: std::sync::Arc<std::sync::atomic::AtomicBool>,
1181    mut commit: CommitData,
1182    effect: crate::animation::Effect,
1183    duration_ms: u32,
1184    per_output_uniforms: &crate::renderer::PerOutputUniforms,
1185) {
1186    let _guard = render_lock
1187        .lock()
1188        .unwrap_or_else(|poisoned| poisoned.into_inner());
1189
1190    let duration = std::time::Duration::from_millis(u64::from(duration_ms.max(1)));
1191    let start = std::time::Instant::now();
1192    loop {
1193        let progress = start.elapsed().as_secs_f32() / duration.as_secs_f32();
1194        let uniforms = crate::animation::compute_effect_uniforms(&effect, progress.clamp(0.0, 1.0));
1195        let status = renderer.render_frame(
1196            crate::renderer::FrameRequest {
1197                surface,
1198                format: commit.format,
1199                bg_bind: &commit.bg_bind,
1200                new_bind: &commit.new_bind,
1201                effect: &uniforms,
1202                width: commit.width,
1203                height: commit.height,
1204                img_width: commit.img_width,
1205                img_height: commit.img_height,
1206                old_img_width: commit.old_img_width,
1207                old_img_height: commit.old_img_height,
1208                scaling_mode: commit.scaling_mode,
1209            },
1210            per_output_uniforms,
1211        );
1212        let status = match status {
1213            Ok(status) => status,
1214            Err(err) => {
1215                eprintln!("wallr: transition render failed: {err}");
1216                break;
1217            }
1218        };
1219        if progress >= 1.0 || status != crate::renderer::FrameStatus::Presented {
1220            break;
1221        }
1222    }
1223
1224    // The transition ended; if the committed wallpaper is an animated GIF and
1225    // nothing superseded it while we rendered, keep the render lock and play
1226    // the frames live until the next commit bumps the generation.
1227    let mut animated = commit.animated.take();
1228    if let Some(animated) = animated.as_mut()
1229        && playback_gen.load(Ordering::SeqCst) == commit.generation
1230    {
1231        play_live(
1232            &renderer,
1233            surface,
1234            &commit,
1235            animated,
1236            &playback_gen,
1237            &pacer,
1238            &gif_paused,
1239            per_output_uniforms,
1240        );
1241    } else if commit.is_video && playback_gen.load(Ordering::SeqCst) == commit.generation {
1242        play_video(
1243            &renderer,
1244            surface,
1245            &commit,
1246            &video_playback,
1247            &playback_gen,
1248            &pacer,
1249            per_output_uniforms,
1250        );
1251    }
1252}
1253
1254/// Presents live wallpaper frames until the next commit. One frame is
1255/// presented per GIF frame boundary instead of at the monitor refresh rate.
1256/// Two textures are double-buffered and frames are decompressed directly
1257/// into a mapped staging ring (no intermediate copy), so the wake path only
1258/// presents and the pacing sleep hides the decode/upload entirely.
1259#[allow(clippy::too_many_arguments)]
1260fn play_live(
1261    renderer: &Renderer,
1262    surface: &'static wgpu::Surface<'static>,
1263    commit: &CommitData,
1264    animated: &mut crate::animated::AnimatedImage,
1265    playback_gen: &std::sync::atomic::AtomicU64,
1266    pacer: &LivePacer,
1267    gif_paused: &std::sync::Arc<std::sync::atomic::AtomicBool>,
1268    per_output_uniforms: &crate::renderer::PerOutputUniforms,
1269) {
1270    let Ok((tex_a, bind_a)) = renderer.create_texture(animated.width, animated.height) else {
1271        tracing::warn!(
1272            "GIF dimensions {}x{} exceed the GPU texture limit",
1273            animated.width,
1274            animated.height
1275        );
1276        return;
1277    };
1278    let Ok((tex_b, bind_b)) = renderer.create_texture(animated.width, animated.height) else {
1279        tracing::warn!(
1280            "GIF dimensions {}x{} exceed the GPU texture limit",
1281            animated.width,
1282            animated.height
1283        );
1284        return;
1285    };
1286    let (frame_w, frame_h) = (animated.width, animated.height);
1287    let (bytes_per_row, rows) = (frame_w * 4, frame_h);
1288    let frame_bytes = bytes_per_row as u64 * rows as u64;
1289
1290    // Map+decompress+copy path needs a byte-per-row multiple of the copy
1291    // alignment; fall back to write_texture for odd widths.
1292    let direct_upload = bytes_per_row % 256 == 0;
1293    let staging: Vec<wgpu::Buffer> = if direct_upload {
1294        (0..2)
1295            .map(|_| {
1296                renderer.device.create_buffer(&wgpu::BufferDescriptor {
1297                    label: Some("wallr-gif-staging"),
1298                    size: frame_bytes,
1299                    usage: wgpu::BufferUsages::MAP_WRITE | wgpu::BufferUsages::COPY_SRC,
1300                    mapped_at_creation: false,
1301                })
1302            })
1303            .collect()
1304    } else {
1305        Vec::new()
1306    };
1307
1308    let first = animated.first_frame();
1309    if !first.is_empty() {
1310        renderer.update_texture(&tex_a, first, frame_w, frame_h);
1311        renderer.update_texture(&tex_b, first, frame_w, frame_h);
1312    }
1313    let binds = [bind_a, bind_b];
1314    let textures = [tex_a, tex_b];
1315
1316    // Uploads frame `index` into `textures[tgt]`. Returns true when the GPU
1317    // copy was recorded.
1318    let upload = |renderer: &Renderer,
1319                  tgt: usize,
1320                  index: usize,
1321                  slot: usize,
1322                  animated: &mut crate::animated::AnimatedImage|
1323     -> bool {
1324        if direct_upload {
1325            let buffer = &staging[slot];
1326            let slice = buffer.slice(..);
1327            slice.map_async(wgpu::MapMode::Write, |_| {});
1328            renderer.device.poll(wgpu::Maintain::Wait);
1329            let ok = {
1330                let mut mapped = slice.get_mapped_range_mut();
1331                animated.decompress_into(index, &mut mapped)
1332            };
1333            buffer.unmap();
1334            if ok {
1335                let mut encoder = renderer
1336                    .device
1337                    .create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
1338                encoder.copy_buffer_to_texture(
1339                    wgpu::TexelCopyBufferInfo {
1340                        buffer,
1341                        layout: wgpu::TexelCopyBufferLayout {
1342                            offset: 0,
1343                            bytes_per_row: Some(bytes_per_row),
1344                            rows_per_image: Some(rows),
1345                        },
1346                    },
1347                    wgpu::TexelCopyTextureInfo {
1348                        texture: &textures[tgt],
1349                        mip_level: 0,
1350                        origin: wgpu::Origin3d::ZERO,
1351                        aspect: wgpu::TextureAspect::All,
1352                    },
1353                    wgpu::Extent3d {
1354                        width: frame_w,
1355                        height: frame_h,
1356                        depth_or_array_layers: 1,
1357                    },
1358                );
1359                renderer.queue.submit([encoder.finish()]);
1360                return true;
1361            }
1362        } else {
1363            let frame = animated.frame_at(index);
1364            if !frame.is_empty() {
1365                renderer.update_texture(&textures[tgt], frame, frame_w, frame_h);
1366                return true;
1367            }
1368        }
1369        false
1370    };
1371
1372    let mut cur = 0usize; // texture index currently holding the presented frame
1373    let mut cur_frame = 0usize; // frame index currently in texture `cur`
1374    let mut next_frame = 0usize; // frame index currently in the idle texture
1375    let mut slot = 0usize; // staging ring slot for the next upload
1376    let start = std::time::Instant::now();
1377    let mut paused_elapsed = std::time::Duration::ZERO; // Accumulated pause time
1378    let static_effect = crate::animation::Effect::Fade(crate::animation::FadeParams::default());
1379    loop {
1380        if playback_gen.load(Ordering::SeqCst) != commit.generation {
1381            return;
1382        }
1383
1384        // Check if paused - if so, keep presenting the current frame but don't advance
1385        if gif_paused.load(Ordering::SeqCst) {
1386            let pause_start = std::time::Instant::now();
1387            // Keep presenting the current frame while paused
1388            let uniforms = crate::animation::compute_effect_uniforms(&static_effect, 1.0);
1389            let status = renderer.render_frame(
1390                crate::renderer::FrameRequest {
1391                    surface,
1392                    format: commit.format,
1393                    bg_bind: &binds[cur],
1394                    new_bind: &binds[cur],
1395                    effect: &uniforms,
1396                    width: commit.width,
1397                    height: commit.height,
1398                    img_width: animated.width,
1399                    img_height: animated.height,
1400                    old_img_width: animated.width,
1401                    old_img_height: animated.height,
1402                    scaling_mode: commit.scaling_mode,
1403                },
1404                per_output_uniforms,
1405            );
1406            match status {
1407                Ok(crate::renderer::FrameStatus::Presented) => {}
1408                _ => return,
1409            }
1410            // Wait a bit before checking again
1411            std::thread::sleep(std::time::Duration::from_millis(16));
1412            paused_elapsed += pause_start.elapsed();
1413            continue;
1414        }
1415
1416        let index = animated.frame_index_at(start.elapsed() - paused_elapsed);
1417        if index != cur_frame {
1418            if next_frame != index {
1419                upload(renderer, cur ^ 1, index, slot, animated);
1420                slot ^= 1;
1421                next_frame = index;
1422            }
1423            cur ^= 1;
1424            cur_frame = index;
1425        }
1426        let uniforms = crate::animation::compute_effect_uniforms(&static_effect, 1.0);
1427        let status = renderer.render_frame(
1428            crate::renderer::FrameRequest {
1429                surface,
1430                format: commit.format,
1431                bg_bind: &binds[cur],
1432                new_bind: &binds[cur],
1433                effect: &uniforms,
1434                width: commit.width,
1435                height: commit.height,
1436                img_width: animated.width,
1437                img_height: animated.height,
1438                old_img_width: animated.width,
1439                old_img_height: animated.height,
1440                scaling_mode: commit.scaling_mode,
1441            },
1442            per_output_uniforms,
1443        );
1444        match status {
1445            Ok(crate::renderer::FrameStatus::Presented) => {}
1446            _ => return,
1447        }
1448
1449        // Pace to the next GIF frame boundary instead of presenting at the
1450        // monitor refresh rate: an animated wallpaper only needs a present
1451        // when its frame changes. A commit wakes us via the pacer. The
1452        // boundary is computed in absolute time (frame_start is loop-relative,
1453        // so add the completed loops) to stay correct after the animation
1454        // wraps. While waiting, warm the idle texture with the next frame so
1455        // the wake path stays on the hot critical section.
1456        let elapsed = start.elapsed() - paused_elapsed;
1457        let total: std::time::Duration = animated.total_duration();
1458        let loops = (elapsed.as_millis() / total.as_millis().max(1)) as u64;
1459        let next_change = animated.frame_start(index + 1) + total * (loops as u32);
1460        let wait = next_change.saturating_sub(elapsed);
1461        if wait > std::time::Duration::ZERO {
1462            let next = index + 1;
1463            if next_frame != next {
1464                upload(renderer, cur ^ 1, next, slot, animated);
1465                slot ^= 1;
1466                next_frame = next;
1467            }
1468            pacer.wait_until(std::time::Instant::now() + wait);
1469        }
1470    }
1471}
1472
1473#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1474enum VideoPresentAction {
1475    Presented,
1476    Retry,
1477    Reconfigure,
1478}
1479
1480fn video_present_action(status: crate::renderer::FrameStatus) -> VideoPresentAction {
1481    match status {
1482        crate::renderer::FrameStatus::Presented => VideoPresentAction::Presented,
1483        crate::renderer::FrameStatus::TimedOut => VideoPresentAction::Retry,
1484        crate::renderer::FrameStatus::Outdated | crate::renderer::FrameStatus::Lost => {
1485            VideoPresentAction::Reconfigure
1486        }
1487    }
1488}
1489
1490/// Live video playback loop: continuously updates texture with decoded frames.
1491fn play_video(
1492    renderer: &Renderer,
1493    surface: &'static wgpu::Surface<'static>,
1494    commit: &CommitData,
1495    video_playback: &std::sync::Arc<crate::video::VideoPlayback>,
1496    playback_gen: &std::sync::atomic::AtomicU64,
1497    pacer: &LivePacer,
1498    per_output_uniforms: &crate::renderer::PerOutputUniforms,
1499) {
1500    let (width, height) = (commit.img_width, commit.img_height);
1501
1502    let Some(texture) = commit.video_texture.as_ref() else {
1503        tracing::warn!("Video conversion resources unavailable");
1504        return;
1505    };
1506    let static_effect = crate::animation::Effect::Fade(crate::animation::FadeParams::default());
1507
1508    let min_frame_interval = commit
1509        .max_fps
1510        .filter(|fps| *fps > 0)
1511        .map(|fps| std::time::Duration::from_secs_f64(1.0 / f64::from(fps)));
1512    let mut last_present: Option<std::time::Instant> = None;
1513    let mut warned_size_mismatch = false;
1514
1515    loop {
1516        // A newer commit superseded us. Do NOT touch the shared
1517        // `video_playback` here: the successor commit already replaced the
1518        // decoder (video) or stopped it (static image), and stopping it now
1519        // would kill the successor's playback too.
1520        if playback_gen.load(Ordering::SeqCst) != commit.generation {
1521            return;
1522        }
1523
1524        if let (Some(interval), Some(previous)) = (min_frame_interval, last_present) {
1525            pacer.wait_until(previous + interval);
1526            if playback_gen.load(Ordering::SeqCst) != commit.generation {
1527                return;
1528            }
1529        }
1530
1531        // Pull the next displayable frame. The decoder queue is bounded, so
1532        // this never blocks; unchanged frames need no upload or presentation.
1533        let frame_uploaded = if let Some(frame) =
1534            video_playback.next_frame_in_generation(commit.generation)
1535        {
1536            // The shared decoder can be replaced between commits; never
1537            // upload a frame whose size does not match this task's texture.
1538            if frame.width != width || frame.height != height {
1539                if !warned_size_mismatch {
1540                    tracing::warn!(
1541                        "Skipping video frame with unexpected size {}x{} (expected {}x{})",
1542                        frame.width,
1543                        frame.height,
1544                        width,
1545                        height
1546                    );
1547                    warned_size_mismatch = true;
1548                }
1549                pacer.wait_until(std::time::Instant::now() + std::time::Duration::from_millis(2));
1550                continue;
1551            }
1552            if let Err(err) = renderer.update_video_texture(texture, &frame.data) {
1553                tracing::warn!("Video frame upload failed: {err}");
1554                return;
1555            }
1556            true
1557        } else {
1558            false
1559        };
1560
1561        if !frame_uploaded {
1562            if playback_gen.load(Ordering::SeqCst) != commit.generation {
1563                return;
1564            }
1565            let wait = video_playback
1566                .time_until_next_frame_in_generation(commit.generation)
1567                .unwrap_or(std::time::Duration::from_millis(2));
1568            if playback_gen.load(Ordering::SeqCst) != commit.generation {
1569                return;
1570            }
1571            pacer.wait_until(std::time::Instant::now() + wait);
1572            continue;
1573        }
1574
1575        let uniforms = crate::animation::compute_effect_uniforms(&static_effect, 1.0);
1576        let status = renderer.render_frame(
1577            crate::renderer::FrameRequest {
1578                surface,
1579                format: commit.format,
1580                bg_bind: texture.bind_group(),
1581                new_bind: texture.bind_group(),
1582                effect: &uniforms,
1583                width: commit.width,
1584                height: commit.height,
1585                img_width: width,
1586                img_height: height,
1587                old_img_width: width,
1588                old_img_height: height,
1589                scaling_mode: commit.scaling_mode,
1590            },
1591            per_output_uniforms,
1592        );
1593
1594        match status.map(video_present_action) {
1595            Ok(VideoPresentAction::Presented) => {
1596                last_present = Some(std::time::Instant::now());
1597            }
1598            Ok(action) => {
1599                if action == VideoPresentAction::Reconfigure {
1600                    surface.configure(
1601                        &renderer.device,
1602                        &wgpu::SurfaceConfiguration {
1603                            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1604                            format: commit.format,
1605                            width: commit.width,
1606                            height: commit.height,
1607                            present_mode: wgpu::PresentMode::Fifo,
1608                            alpha_mode: wgpu::CompositeAlphaMode::Opaque,
1609                            view_formats: vec![],
1610                            desired_maximum_frame_latency: 2,
1611                        },
1612                    );
1613                }
1614                pacer.wait_until(std::time::Instant::now() + std::time::Duration::from_millis(100));
1615            }
1616            Err(err) => {
1617                tracing::warn!("Video present failed ({err}), stopping playback");
1618                video_playback.stop();
1619                return;
1620            }
1621        }
1622    }
1623}
1624
1625/// Resolve target render states from an optional monitor name.
1626/// Returns all states when `monitor` is None, or the specific named state.
1627/// Returns an empty vec for unknown monitor names (letting callers return
1628/// an error).
1629async fn resolve_targets(
1630    render_states: &std::collections::HashMap<
1631        String,
1632        std::sync::Arc<tokio::sync::Mutex<RenderState>>,
1633    >,
1634    monitor: Option<&str>,
1635) -> Vec<std::sync::Arc<tokio::sync::Mutex<RenderState>>> {
1636    match monitor {
1637        Some(name) => {
1638            if let Some(rs) = render_states.get(name) {
1639                vec![rs.clone()]
1640            } else {
1641                Vec::new()
1642            }
1643        }
1644        None => render_states.values().cloned().collect(),
1645    }
1646}
1647
1648fn resolve_named_targets(
1649    render_states: &std::collections::HashMap<
1650        String,
1651        std::sync::Arc<tokio::sync::Mutex<RenderState>>,
1652    >,
1653    monitor: Option<&str>,
1654) -> Vec<(String, std::sync::Arc<tokio::sync::Mutex<RenderState>>)> {
1655    match monitor {
1656        Some(name) => render_states
1657            .get(name)
1658            .map(|state| vec![(name.to_string(), state.clone())])
1659            .unwrap_or_default(),
1660        None => render_states
1661            .iter()
1662            .map(|(name, state)| (name.clone(), state.clone()))
1663            .collect(),
1664    }
1665}
1666
1667pub struct Daemon {
1668    config: WallrConfig,
1669    paused: Arc<AtomicBool>,
1670    engine: Arc<Mutex<WallpaperEngine>>,
1671}
1672
1673impl Daemon {
1674    pub fn new(config: WallrConfig) -> Result<Self, DaemonError> {
1675        let engine = WallpaperEngine::new(config.clone())?;
1676        Ok(Self {
1677            config,
1678            paused: Arc::new(AtomicBool::new(false)),
1679            engine: Arc::new(Mutex::new(engine)),
1680        })
1681    }
1682
1683    pub async fn start(self) -> Result<(), DaemonError> {
1684        let socket_path = crate::config::expand_path(&self.config.daemon.socket);
1685        if socket_path.exists() {
1686            if tokio::net::UnixStream::connect(&socket_path).await.is_ok() {
1687                return Err(DaemonError::AlreadyRunning(
1688                    socket_path.to_string_lossy().to_string(),
1689                ));
1690            }
1691            let _ = std::fs::remove_file(&socket_path);
1692        }
1693
1694        let renderer = Renderer::new()
1695            .await
1696            .map_err(|e| DaemonError::StartError(format!("GPU init failed: {e}")))?;
1697
1698        let conn = Connection::connect_to_env()
1699            .map_err(|e| DaemonError::StartError(format!("Failed to connect to Wayland: {e:?}")))?;
1700        let backend = conn.backend();
1701        let display_ptr = backend.display_ptr() as *mut std::ffi::c_void;
1702
1703        let (globals, mut event_queue) = registry_queue_init(&conn)
1704            .map_err(|e| DaemonError::StartError(format!("registry_queue_init failed: {e:?}")))?;
1705        let qh = event_queue.handle();
1706
1707        let compositor_state = CompositorState::bind(&globals, &qh)
1708            .map_err(|e| DaemonError::StartError(format!("compositor bind failed: {e:?}")))?;
1709        let layer_shell = LayerShell::bind(&globals, &qh)
1710            .map_err(|e| DaemonError::StartError(format!("layer_shell bind failed: {e:?}")))?;
1711        let shm = Shm::bind(&globals, &qh)
1712            .map_err(|e| DaemonError::StartError(format!("shm bind failed: {e:?}")))?;
1713
1714        // Bind compositor once for creating empty input regions (passthrough).
1715        let compositor = globals
1716            .bind::<wl_compositor::WlCompositor, WaylandState, smithay_client_toolkit::globals::GlobalData>(
1717                &qh,
1718                1..=4,
1719                smithay_client_toolkit::globals::GlobalData,
1720            )
1721            .map_err(|e| DaemonError::StartError(format!("compositor bind failed: {e:?}")))?;
1722        let viewporter = globals
1723            .bind::<WpViewporter, WaylandState, ()>(&qh, 1..=1, ())
1724            .ok();
1725        if viewporter.is_none() {
1726            tracing::warn!(
1727                "wp_viewporter is unavailable; fractional outputs will use integer buffer scaling"
1728            );
1729        }
1730
1731        let mut wayland_state = WaylandState {
1732            registry_state: RegistryState::new(&globals),
1733            output_state: OutputState::new(&globals, &qh),
1734            compositor_state,
1735            shm,
1736            outputs: std::collections::HashMap::new(),
1737            surfaces: Vec::new(),
1738            viewporter,
1739            viewports: std::collections::HashMap::new(),
1740            output_lifecycles: std::collections::HashMap::new(),
1741            pending_restores: std::collections::HashSet::new(),
1742            layer_shell,
1743            compositor,
1744            hotplug: None,
1745        };
1746
1747        // Multiple roundtrips: some compositors deliver output events lazily
1748        // across several dispatch cycles. Five roundtrips ensures all outputs
1749        // are discovered and their modes/scale are populated.
1750        for i in 0..5 {
1751            event_queue
1752                .roundtrip(&mut wayland_state)
1753                .map_err(|e| DaemonError::StartError(format!("roundtrip {i} failed: {e:?}")))?;
1754        }
1755
1756        if wayland_state.outputs.is_empty() {
1757            return Err(DaemonError::StartError(
1758                "no outputs detected after roundtrip".into(),
1759            ));
1760        }
1761
1762        tracing::info!(
1763            "Detected {} output(s): {:?}",
1764            wayland_state.outputs.len(),
1765            wayland_state
1766                .outputs
1767                .values()
1768                .map(|o| format!("{} ({}x{})", o.name, o.width, o.height))
1769                .collect::<Vec<_>>()
1770        );
1771
1772        let renderer = std::sync::Arc::new(renderer);
1773
1774        // Create a LayerSurface, wgpu Surface, and RenderState for every
1775        // known output. The key is the output's human-readable name (e.g.
1776        // "DP-1", "eDP-1") so IPC can target a specific monitor.
1777        let render_states_map: std::collections::HashMap<String, Arc<Mutex<RenderState>>> =
1778            std::collections::HashMap::new();
1779
1780        // Collect output info first so we can pass &mut wayland_state to the
1781        // helper (we need &mut to push LayerSurfaces into the surfaces vec).
1782        let output_info: Vec<(u32, OutputInfo)> = wayland_state
1783            .outputs
1784            .iter()
1785            .map(|(k, v)| {
1786                (
1787                    *k,
1788                    OutputInfo {
1789                        name: v.name.clone(),
1790                        width: v.width,
1791                        height: v.height,
1792                        scale_factor: v.scale_factor,
1793                        wl_output: v.wl_output.clone(),
1794                    },
1795                )
1796            })
1797            .collect();
1798
1799        for (proto_id, info) in &output_info {
1800            let name = info.name.clone();
1801            let rs = Self::create_render_state_for_output(
1802                &renderer,
1803                display_ptr,
1804                &mut wayland_state,
1805                &qh,
1806                info,
1807                &self.config,
1808            )
1809            .await?;
1810            let rs = Arc::new(Mutex::new(rs));
1811            wayland_state.output_lifecycles.insert(
1812                *proto_id,
1813                OutputLifecycle {
1814                    name: name.clone(),
1815                    render_state: rs,
1816                    active: Arc::new(std::sync::atomic::AtomicBool::new(true)),
1817                },
1818            );
1819            wayland_state.pending_restores.insert(*proto_id);
1820
1821            tracing::info!("Output ready: {name} ({proto_id})");
1822        }
1823
1824        // Wrap the render-state map in Arc<Mutex<...>> so it can be shared
1825        // between the Wayland event loop (hotplug) and the IPC handler.
1826        let render_states: std::sync::Arc<
1827            tokio::sync::Mutex<std::collections::HashMap<String, Arc<Mutex<RenderState>>>>,
1828        > = std::sync::Arc::new(tokio::sync::Mutex::new(render_states_map));
1829
1830        // Store the hotplug context in WaylandState so output callbacks can
1831        // create/destroy render states when outputs appear or disappear.
1832        wayland_state.hotplug = Some(DaemonHotplug {
1833            renderer: renderer.clone(),
1834            config: self.config.clone(),
1835            display_ptr: SendDisplayPtr(display_ptr),
1836            render_states: render_states.clone(),
1837        });
1838
1839        // One more roundtrip to catch outputs that appeared between the
1840        // initial roundtrips and the hotplug context being stored.
1841        event_queue
1842            .roundtrip(&mut wayland_state)
1843            .map_err(|e| DaemonError::StartError(format!("hotplug roundtrip failed: {e:?}")))?;
1844
1845        let paused_clone = self.paused.clone();
1846        let engine_clone = self.engine.clone();
1847        let render_states_clone = render_states.clone();
1848
1849        // Graceful shutdown on POSIX signals: stop video decoding, remove the
1850        // IPC socket, and exit. The compositor releases the layer-shell
1851        // surface automatically when the process exits.
1852        {
1853            let rs_map = render_states.clone();
1854            let socket_path = socket_path.clone();
1855            tokio::spawn(async move {
1856                use tokio::signal::unix::{SignalKind, signal};
1857                let mut term = signal(SignalKind::terminate()).expect("SIGTERM handler");
1858                let mut int = signal(SignalKind::interrupt()).expect("SIGINT handler");
1859                let mut hup = signal(SignalKind::hangup()).expect("SIGHUP handler");
1860                tokio::select! {
1861                    _ = term.recv() => {}
1862                    _ = int.recv() => {}
1863                    _ = hup.recv() => {}
1864                }
1865                tracing::info!("Signal received, shutting down gracefully");
1866                let states = rs_map.lock().await;
1867                for rs in states.values() {
1868                    if let Ok(state) = rs.try_lock() {
1869                        state.video_playback.stop();
1870                    }
1871                }
1872                drop(states);
1873                let _ = std::fs::remove_file(&socket_path);
1874                std::process::exit(0);
1875            });
1876        }
1877
1878        let ipc_socket_path = socket_path.clone();
1879        start_ipc_server(&socket_path, move |cmd| {
1880            let paused = paused_clone.clone();
1881            let engine = engine_clone.clone();
1882            let render_states = render_states_clone.clone();
1883            let stop_socket = ipc_socket_path.clone();
1884            async move {
1885                let render_states = render_states.lock().await;
1886                match cmd {
1887                    IpcCommand::Pause { monitor } => {
1888                        let targets = resolve_targets(&render_states, monitor.as_deref()).await;
1889                        if targets.is_empty() {
1890                            return IpcResponse {
1891                                success: false,
1892                                message: Some("No matching outputs".into()),
1893                            };
1894                        }
1895                        if monitor.is_none() {
1896                            paused.store(true, Ordering::SeqCst);
1897                        }
1898                        for rs in targets {
1899                            let rs_lock = rs.lock().await;
1900                            rs_lock.video_playback.pause();
1901                            rs_lock.gif_paused.store(true, Ordering::SeqCst);
1902                        }
1903                        IpcResponse {
1904                            success: true,
1905                            message: Some("Paused".into()),
1906                        }
1907                    }
1908                    IpcCommand::Resume { monitor } => {
1909                        let targets = resolve_targets(&render_states, monitor.as_deref()).await;
1910                        if targets.is_empty() {
1911                            return IpcResponse {
1912                                success: false,
1913                                message: Some("No matching outputs".into()),
1914                            };
1915                        }
1916                        if monitor.is_none() {
1917                            paused.store(false, Ordering::SeqCst);
1918                        }
1919                        for rs in targets {
1920                            let rs_lock = rs.lock().await;
1921                            rs_lock.video_playback.resume();
1922                            rs_lock.gif_paused.store(false, Ordering::SeqCst);
1923                        }
1924                        IpcResponse {
1925                            success: true,
1926                            message: Some("Resumed".into()),
1927                        }
1928                    }
1929                    IpcCommand::Reload => {
1930                        let lock = engine.lock().await;
1931                        match lock.reload() {
1932                            Ok(_) => IpcResponse {
1933                                success: true,
1934                                message: Some("Reloaded".into()),
1935                            },
1936                            Err(e) => IpcResponse {
1937                                success: false,
1938                                message: Some(e.to_string()),
1939                            },
1940                        }
1941                    }
1942                    IpcCommand::Preview {
1943                        path,
1944                        effect,
1945                        duration_ms,
1946                        no_theme,
1947                        theme_override,
1948                        monitor,
1949                        scaling_mode,
1950                    } => {
1951                        if paused.load(Ordering::SeqCst) {
1952                            return IpcResponse {
1953                                success: false,
1954                                message: Some("Daemon is paused".into()),
1955                            };
1956                        }
1957                        let p = std::path::PathBuf::from(&path);
1958                        if !p.exists() {
1959                            return IpcResponse {
1960                                success: false,
1961                                message: Some(format!("File not found: {}", path)),
1962                            };
1963                        }
1964
1965                        // Resolve targets: unknown monitor = error, no monitor = all outputs.
1966                        let targets = resolve_named_targets(&render_states, monitor.as_deref());
1967                        if targets.is_empty() {
1968                            return IpcResponse {
1969                                success: false,
1970                                message: match &monitor {
1971                                    Some(name) => Some(format!("Unknown monitor: {name}")),
1972                                    None => Some("No outputs available".into()),
1973                                },
1974                            };
1975                        }
1976
1977                        let effect = effect.unwrap_or_else(|| {
1978                            crate::animation::Effect::Fade(crate::animation::FadeParams::default())
1979                        });
1980                        // Live playback only starts after the transition, so for
1981                        // videos an unrequested 2s fade reads as a long "load".
1982                        // Default to a short fade unless the user asked for one.
1983                        let is_video = crate::video::VideoDecoder::is_video_file(&p);
1984                        let duration = duration_ms.unwrap_or(if is_video { 150 } else { 2000 });
1985                        let sm = scaling_mode.unwrap_or(crate::config::ScalingMode::Fill);
1986                        let scaling_mode_u32 = match sm {
1987                            crate::config::ScalingMode::Fill => 0u32,
1988                            crate::config::ScalingMode::Fit => 1,
1989                            crate::config::ScalingMode::Stretch => 2,
1990                            crate::config::ScalingMode::Center => 3,
1991                            crate::config::ScalingMode::Tile => 4,
1992                        };
1993
1994                        let mut last_err = None;
1995                        for (name, rs) in &targets {
1996                            let result = set_wallpaper_with_retry(
1997                                rs,
1998                                &p,
1999                                &effect,
2000                                duration,
2001                                scaling_mode_u32,
2002                            )
2003                            .await;
2004
2005                            match result {
2006                                Err(e) => last_err = Some(format!("Render failed: {e}")),
2007                                Ok(()) => {
2008                                    if let Err(err) = persist_wallpaper(name, &p) {
2009                                        tracing::warn!(
2010                                            "Wallpaper changed on {name}, but state persistence failed: {err}"
2011                                        );
2012                                    }
2013                                }
2014                            }
2015                        }
2016
2017                        match last_err {
2018                            Some(e) => IpcResponse {
2019                                success: false,
2020                                message: Some(e),
2021                            },
2022                            None => {
2023                                let opts = SetOptions {
2024                                    no_theme,
2025                                    theme_provider: theme_override,
2026                                    monitor,
2027                                };
2028                                let mut eng = engine.lock().await;
2029                                match eng.set_wallpaper(&p, &opts).await {
2030                                    Ok(()) => IpcResponse {
2031                                        success: true,
2032                                        message: None,
2033                                    },
2034                                    Err(e) => IpcResponse {
2035                                        success: true,
2036                                        message: Some(format!(
2037                                            "Wallpaper set, but hooks/theme failed: {e}"
2038                                        )),
2039                                    },
2040                                }
2041                            }
2042                        }
2043                    }
2044                    IpcCommand::Stop => {
2045                        for rs in render_states.values() {
2046                            let state = rs.lock().await;
2047                            state.playback_gen.fetch_add(1, Ordering::SeqCst);
2048                            state.pacer.notify();
2049                            state.video_playback.stop();
2050                        }
2051                        let sp = stop_socket.clone();
2052                        tokio::spawn(async move {
2053                            tokio::time::sleep(std::time::Duration::from_millis(300)).await;
2054                            let _ = std::fs::remove_file(&sp);
2055                            std::process::exit(0);
2056                        });
2057                        IpcResponse {
2058                            success: true,
2059                            message: Some("Stopping".into()),
2060                        }
2061                    }
2062                    IpcCommand::Status => {
2063                        let state = if paused.load(Ordering::SeqCst) {
2064                            "paused"
2065                        } else {
2066                            "running"
2067                        };
2068                        IpcResponse {
2069                            success: true,
2070                            message: Some(format!("wallr daemon {}", state)),
2071                        }
2072                    }
2073                    IpcCommand::Seek {
2074                        timestamp_ms,
2075                        monitor,
2076                    } => {
2077                        let targets = resolve_targets(&render_states, monitor.as_deref()).await;
2078                        if targets.is_empty() {
2079                            return IpcResponse {
2080                                success: false,
2081                                message: match &monitor {
2082                                    Some(name) => Some(format!("Unknown monitor: {name}")),
2083                                    None => Some("No outputs available".into()),
2084                                },
2085                            };
2086                        }
2087                        // When monitor is unspecified, seek all outputs
2088                        let mut seek_count = 0u32;
2089                        let mut errors = Vec::new();
2090                        for (name, rs) in render_states.iter() {
2091                            if monitor.as_deref() != Some(name.as_str()) && monitor.is_some() {
2092                                continue;
2093                            }
2094                            let rs_lock = rs.lock().await;
2095                            match rs_lock
2096                                .video_playback
2097                                .seek(std::time::Duration::from_millis(timestamp_ms))
2098                            {
2099                                Ok(()) => {
2100                                    seek_count += 1;
2101                                }
2102                                Err(e) => {
2103                                    errors.push(format!("{}: {}", name, e));
2104                                }
2105                            }
2106                        }
2107                        if seek_count == 0 {
2108                            IpcResponse {
2109                                success: false,
2110                                message: Some(format!(
2111                                    "Seek failed on all outputs: {}",
2112                                    errors.join("; ")
2113                                )),
2114                            }
2115                        } else if !errors.is_empty() {
2116                            IpcResponse {
2117                                success: true,
2118                                message: Some(format!(
2119                                    "Seeked {} output(s) to {}ms, {} failed: {}",
2120                                    seek_count,
2121                                    timestamp_ms,
2122                                    errors.len(),
2123                                    errors.join("; ")
2124                                )),
2125                            }
2126                        } else {
2127                            IpcResponse {
2128                                success: true,
2129                                message: Some(format!(
2130                                    "Seeked {} output(s) to {}ms",
2131                                    seek_count, timestamp_ms
2132                                )),
2133                            }
2134                        }
2135                    }
2136                    IpcCommand::Info { monitor } => {
2137                        let targets = resolve_targets(&render_states, monitor.as_deref()).await;
2138                        if targets.is_empty() {
2139                            return IpcResponse {
2140                                success: false,
2141                                message: match &monitor {
2142                                    Some(name) => Some(format!("Unknown monitor: {name}")),
2143                                    None => Some("No outputs available".into()),
2144                                },
2145                            };
2146                        }
2147
2148                        let mut lines = vec![
2149                            format!("wallr v{}", env!("CARGO_PKG_VERSION")),
2150                            String::new(),
2151                            format!("Outputs: {}", render_states.len()),
2152                        ];
2153                        for name in render_states.keys() {
2154                            lines.push(format!("  - {name}"));
2155                        }
2156
2157                        // Collect target output info
2158                        for (name, rs) in render_states.iter() {
2159                            if monitor.is_some() && monitor.as_deref() != Some(name.as_str()) {
2160                                continue;
2161                            }
2162                            let rs_lock = rs.lock().await;
2163                            let gpu_info =
2164                                crate::video::gpu::adapter_diagnostics(&rs_lock.renderer.adapter);
2165
2166                            lines.push(String::new());
2167                            lines.push(format!("[{name}] {}x{}", rs_lock.width, rs_lock.height));
2168                            lines.push(gpu_info);
2169
2170                            match rs_lock.video_playback.metadata() {
2171                                Some(meta) => {
2172                                    let decoder_info = rs_lock.video_playback.decoder_info();
2173                                    let hw = rs_lock.video_playback.hw_accel_in_use();
2174                                    let state = if rs_lock.video_playback.is_paused() {
2175                                        "paused"
2176                                    } else {
2177                                        "playing"
2178                                    };
2179                                    let position = rs_lock
2180                                        .video_playback
2181                                        .position()
2182                                        .map(|p| format!("{:.2}s", p.as_secs_f64()))
2183                                        .unwrap_or_else(|| "?".to_string());
2184                                    lines.push(String::new());
2185                                    lines.push("Video:".into());
2186                                    lines.push(format!(
2187                                        "  Resolution: {}x{}",
2188                                        meta.width, meta.height
2189                                    ));
2190                                    lines.push(format!("  FPS: {:.2}", meta.fps));
2191                                    lines.push(format!(
2192                                        "  Duration: {:.2}s",
2193                                        meta.duration.as_secs_f64()
2194                                    ));
2195                                    lines.push(format!(
2196                                        "  Codec: {}",
2197                                        decoder_info
2198                                            .as_ref()
2199                                            .map(|d| d.codec_name.as_str())
2200                                            .unwrap_or("unknown")
2201                                    ));
2202                                    lines.push(format!("  Container: {}", meta.format));
2203                                    lines.push(format!("  Decoder: {}", hw.name()));
2204                                    lines.push(format!(
2205                                        "  GPU Decode: {}",
2206                                        if hw == crate::video::HwAccel::Software {
2207                                            "disabled"
2208                                        } else {
2209                                            "enabled"
2210                                        }
2211                                    ));
2212                                    lines.push(format!("  State: {} @ {}", state, position));
2213                                }
2214                                None => {
2215                                    lines.push(String::new());
2216                                    lines.push("Video: none active".into());
2217                                    lines.push("Decoder: idle".into());
2218                                }
2219                            }
2220                        }
2221
2222                        IpcResponse {
2223                            success: true,
2224                            message: Some(lines.join("\n")),
2225                        }
2226                    }
2227                    IpcCommand::MonitorList => {
2228                        let mut lines = Vec::new();
2229                        for (name, rs) in render_states.iter() {
2230                            let lock = rs.lock().await;
2231                            lines.push(format!("{}: {}x{}", name, lock.width, lock.height));
2232                        }
2233                        if lines.is_empty() {
2234                            IpcResponse {
2235                                success: true,
2236                                message: Some("No monitors connected".into()),
2237                            }
2238                        } else {
2239                            IpcResponse {
2240                                success: true,
2241                                message: Some(lines.join("\n")),
2242                            }
2243                        }
2244                    }
2245                    IpcCommand::MonitorCurrent => {
2246                        // Return info for the first output as "current".
2247                        if let Some((name, rs)) = render_states.iter().next() {
2248                            let lock = rs.lock().await;
2249                            IpcResponse {
2250                                success: true,
2251                                message: Some(format!("{}: {}x{}", name, lock.width, lock.height)),
2252                            }
2253                        } else {
2254                            IpcResponse {
2255                                success: false,
2256                                message: Some("No monitors connected".into()),
2257                            }
2258                        }
2259                    }
2260                    IpcCommand::Blank {
2261                        monitor,
2262                        effect,
2263                        duration_ms,
2264                    } => {
2265                        let targets = resolve_named_targets(&render_states, monitor.as_deref());
2266                        if targets.is_empty() {
2267                            return IpcResponse {
2268                                success: false,
2269                                message: match &monitor {
2270                                    Some(name) => Some(format!("Unknown monitor: {name}")),
2271                                    None => Some("No outputs available".into()),
2272                                },
2273                            };
2274                        }
2275                        let mut blanked_count = 0u32;
2276                        let mut errors = Vec::new();
2277                        let black_effect = effect.unwrap_or_else(|| {
2278                            crate::animation::Effect::Fade(crate::animation::FadeParams::default())
2279                        });
2280                        let duration = duration_ms.unwrap_or(800);
2281                        let mut blank_file = match tempfile::Builder::new()
2282                            .prefix("wallr_blank-")
2283                            .suffix(".png")
2284                            .tempfile()
2285                        {
2286                            Ok(file) => file,
2287                            Err(e) => {
2288                                return IpcResponse {
2289                                    success: false,
2290                                    message: Some(format!("Failed to create blank image: {e}")),
2291                                };
2292                            }
2293                        };
2294                        let blank_path = blank_file.path().to_path_buf();
2295                        let blank = image::DynamicImage::ImageRgba8(
2296                            image::RgbaImage::from_pixel(1, 1, image::Rgba([0, 0, 0, 255])),
2297                        );
2298                        if let Err(e) = blank
2299                            .write_to(blank_file.as_file_mut(), image::ImageFormat::Png)
2300                        {
2301                            return IpcResponse {
2302                                success: false,
2303                                message: Some(format!("Failed to write blank image: {e}")),
2304                            };
2305                        }
2306
2307                        for (name, rs) in &targets {
2308                            let rs = Arc::clone(rs);
2309                            let blank_path = blank_path.clone();
2310                            let black_effect = black_effect.clone();
2311                            let blanked = tokio::task::spawn_blocking(move || {
2312                                let mut lock = rs.blocking_lock();
2313                                if lock.blanked {
2314                                    return Ok(false);
2315                                }
2316                                let previous = (
2317                                    lock.last_wallpaper.clone().unwrap_or_default(),
2318                                    lock.scaling_mode,
2319                                );
2320                                lock.set_wallpaper(&blank_path, &black_effect, duration, 0)?;
2321                                lock.pre_blank = Some(previous);
2322                                lock.blanked = true;
2323                                Ok::<bool, anyhow::Error>(true)
2324                            })
2325                            .await;
2326                            match blanked {
2327                                Ok(Ok(true)) => blanked_count += 1,
2328                                Ok(Ok(false)) => {}
2329                                Ok(Err(e)) => errors.push(format!("{name}: blank failed: {e}")),
2330                                Err(e) => errors.push(format!("{name}: blank task failed: {e}")),
2331                            }
2332                        }
2333                        if errors.is_empty() {
2334                            IpcResponse {
2335                                success: true,
2336                                message: Some(format!("Blanked {blanked_count} output(s)")),
2337                            }
2338                        } else {
2339                            IpcResponse {
2340                                success: false,
2341                                message: Some(format!(
2342                                    "Blanked {blanked_count} output(s), {} error(s): {}",
2343                                    errors.len(),
2344                                    errors.join("; ")
2345                                )),
2346                            }
2347                        }
2348                    }
2349                    IpcCommand::Restore {
2350                        monitor,
2351                        effect,
2352                        duration_ms,
2353                    } => {
2354                        let targets = resolve_targets(&render_states, monitor.as_deref()).await;
2355                        if targets.is_empty() {
2356                            return IpcResponse {
2357                                success: false,
2358                                message: match &monitor {
2359                                    Some(name) => Some(format!("Unknown monitor: {name}")),
2360                                    None => Some("No outputs available".into()),
2361                                },
2362                            };
2363                        }
2364                        let mut restored_count = 0u32;
2365                        let mut errors = Vec::new();
2366                        let restore_effect = effect.unwrap_or_else(|| {
2367                            crate::animation::Effect::Fade(crate::animation::FadeParams::default())
2368                        });
2369                        let duration = duration_ms.unwrap_or(800);
2370
2371                        for (name, rs) in render_states.iter() {
2372                            if monitor.as_deref() != Some(name.as_str()) && monitor.is_some() {
2373                                continue;
2374                            }
2375                            let rs = Arc::clone(rs);
2376                            let restore_effect = restore_effect.clone();
2377                            let result = tokio::task::spawn_blocking(move || {
2378                                let mut lock = rs.blocking_lock();
2379                                if !lock.blanked {
2380                                    return None;
2381                                }
2382                                let result = match lock.pre_blank.clone() {
2383                                    Some((path, scaling_mode)) if path.exists() => lock
2384                                        .set_wallpaper(
2385                                            &path,
2386                                            &restore_effect,
2387                                            duration,
2388                                            scaling_mode,
2389                                        )
2390                                        .map_err(|e| format!("restore failed: {e}")),
2391                                    Some(_) => Err("wallpaper path no longer exists".to_string()),
2392                                    None => Err("no previous wallpaper to restore".to_string()),
2393                                };
2394                                if result.is_ok() {
2395                                    lock.blanked = false;
2396                                    lock.pre_blank = None;
2397                                }
2398                                Some(result)
2399                            })
2400                            .await;
2401                            match result {
2402                                Ok(Some(Ok(()))) => restored_count += 1,
2403                                Ok(Some(Err(e))) => errors.push(format!("{name}: {e}")),
2404                                Ok(None) => {}
2405                                Err(e) => errors.push(format!("{name}: restore task failed: {e}")),
2406                            }
2407                        }
2408                        if !errors.is_empty() {
2409                            IpcResponse {
2410                                success: restored_count > 0,
2411                                message: Some(format!(
2412                                    "Restored {} output(s), {} error(s): {}",
2413                                    restored_count,
2414                                    errors.len(),
2415                                    errors.join("; ")
2416                                )),
2417                            }
2418                        } else {
2419                            IpcResponse {
2420                                success: true,
2421                                message: Some(format!("Restored {restored_count} output(s)")),
2422                            }
2423                        }
2424                    }
2425                }
2426            }
2427        })
2428        .await?;
2429
2430        // Start file watcher if configured
2431        if self.config.watch.enabled
2432            && let Some(ref watch_dir) = self.config.watch.dir
2433        {
2434            let watch_path = crate::config::expand_path(watch_dir);
2435            self.start_watcher(watch_path, render_states.clone())
2436                .await?;
2437        }
2438
2439        tokio::task::spawn_blocking(move || {
2440            loop {
2441                if let Err(e) = event_queue.blocking_dispatch(&mut wayland_state) {
2442                    eprintln!("Wayland dispatch error: {e:?}");
2443                    break;
2444                }
2445            }
2446            // The compositor connection is dead (e.g. the compositor exited
2447            // or killed our layer surface with a protocol error). Rendering
2448            // can never recover, so exit and let the supervisor restart us.
2449            eprintln!("wallr: Wayland connection lost, exiting");
2450            std::process::exit(1);
2451        });
2452
2453        loop {
2454            tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
2455        }
2456    }
2457
2458    async fn start_watcher(
2459        &self,
2460        dir: PathBuf,
2461        render_states: std::sync::Arc<
2462            tokio::sync::Mutex<std::collections::HashMap<String, Arc<Mutex<RenderState>>>>,
2463        >,
2464    ) -> Result<(), DaemonError> {
2465        let engine = self.engine.clone();
2466        let paused = self.paused.clone();
2467        let debounce = crate::config::parse_duration(&self.config.watch.debounce)
2468            .unwrap_or(std::time::Duration::from_millis(500));
2469
2470        let (tx, mut rx) = tokio::sync::mpsc::channel(100);
2471
2472        let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
2473            if let Ok(event) = res
2474                && let EventKind::Create(_) = event.kind
2475            {
2476                for path in event.paths {
2477                    let _ = tx.blocking_send(path);
2478                }
2479            }
2480        })
2481        .map_err(|e| DaemonError::StartError(e.to_string()))?;
2482
2483        watcher
2484            .watch(&dir, RecursiveMode::NonRecursive)
2485            .map_err(|e| DaemonError::StartError(e.to_string()))?;
2486
2487        tokio::spawn(async move {
2488            let _watcher = watcher;
2489            let mut last: Option<(PathBuf, std::time::Instant)> = None;
2490
2491            while let Some(path) = rx.recv().await {
2492                if paused.load(Ordering::SeqCst) {
2493                    continue;
2494                }
2495                if let Some((ref lp, ref lt)) = last
2496                    && lp == &path
2497                    && lt.elapsed() < debounce
2498                {
2499                    continue;
2500                }
2501                let ext = path
2502                    .extension()
2503                    .unwrap_or_default()
2504                    .to_string_lossy()
2505                    .to_lowercase();
2506                if !["jpg", "jpeg", "png", "gif", "webp"].contains(&ext.as_str()) {
2507                    continue;
2508                }
2509                last = Some((path.clone(), std::time::Instant::now()));
2510
2511                // Apply new wallpaper to every connected output.
2512                let states = render_states.lock().await;
2513                for (name, rs) in states.iter() {
2514                    let rs = rs.clone();
2515                    let eng = engine.clone();
2516                    let p = path.clone();
2517                    let name = name.clone();
2518                    tokio::spawn(async move {
2519                        let effect =
2520                            crate::animation::Effect::Fade(crate::animation::FadeParams::default());
2521                        if let Err(err) = set_wallpaper_with_retry(&rs, &p, &effect, 600, 0).await {
2522                            tracing::warn!("Watcher wallpaper render failed for {p:?}: {err}");
2523                            return;
2524                        }
2525                        let opts = SetOptions {
2526                            no_theme: false,
2527                            theme_provider: None,
2528                            monitor: Some(name),
2529                        };
2530                        let mut elock = eng.lock().await;
2531                        let _ = elock.set_wallpaper(&p, &opts).await;
2532                    });
2533                }
2534            }
2535        });
2536
2537        Ok(())
2538    }
2539
2540    /// Creates a LayerSurface, wgpu Surface, and RenderState for a single
2541    /// Wayland output.
2542    #[allow(clippy::too_many_arguments)]
2543    async fn create_render_state_for_output(
2544        renderer: &std::sync::Arc<Renderer>,
2545        display_ptr: *mut std::ffi::c_void,
2546        wayland_state: &mut WaylandState,
2547        qh: &QueueHandle<WaylandState>,
2548        output: &OutputInfo,
2549        config: &WallrConfig,
2550    ) -> Result<RenderState, DaemonError> {
2551        let wl_surface = wayland_state.compositor_state.create_surface(qh);
2552        let layer_surface = wayland_state.layer_shell.create_layer_surface(
2553            qh,
2554            wl_surface,
2555            Layer::Background,
2556            Some("wallr"),
2557            Some(&output.wl_output),
2558        );
2559        layer_surface.set_anchor(Anchor::TOP | Anchor::BOTTOM | Anchor::LEFT | Anchor::RIGHT);
2560        layer_surface.set_exclusive_zone(-1);
2561        layer_surface.set_keyboard_interactivity(KeyboardInteractivity::None);
2562
2563        // Empty input region so clicks pass through to the desktop.
2564        let empty_region = wayland_state.compositor.create_region(qh, ());
2565        layer_surface
2566            .wl_surface()
2567            .set_input_region(Some(&empty_region));
2568        let output_id = output.wl_output.id().protocol_id();
2569        let viewport = wayland_state
2570            .viewporter
2571            .as_ref()
2572            .map(|viewporter| viewporter.get_viewport(layer_surface.wl_surface(), qh, ()));
2573        let scale_factor = if viewport.is_some() {
2574            1
2575        } else if output.scale_factor > 0 {
2576            output.scale_factor
2577        } else {
2578            1
2579        };
2580        layer_surface.wl_surface().set_buffer_scale(scale_factor);
2581        layer_surface.commit();
2582        empty_region.destroy();
2583        if let Some(viewport) = viewport {
2584            wayland_state.viewports.insert(output_id, viewport);
2585        }
2586
2587        // mode.dimensions already returns physical pixels; do not multiply by scale.
2588        let width = output.width;
2589        let height = output.height;
2590
2591        let raw_surface = layer_surface.wl_surface().id().as_ptr() as *mut std::ffi::c_void;
2592        wayland_state.surfaces.push((output_id, layer_surface));
2593
2594        let window_handle = WaylandWindow {
2595            display: display_ptr,
2596            surface: raw_surface,
2597        };
2598
2599        let wgpu_surface = renderer
2600            .instance
2601            .create_surface(&window_handle)
2602            .map_err(|e| DaemonError::StartError(format!("wgpu surface creation failed: {e:?}")))?;
2603
2604        let adapter = renderer
2605            .instance
2606            .request_adapter(&wgpu::RequestAdapterOptions {
2607                compatible_surface: Some(&wgpu_surface),
2608                power_preference: wgpu::PowerPreference::HighPerformance,
2609                force_fallback_adapter: false,
2610            })
2611            .await;
2612        let surf_format = adapter
2613            .as_ref()
2614            .map(|a| {
2615                let caps = wgpu_surface.get_capabilities(a);
2616                caps.formats
2617                    .into_iter()
2618                    .next()
2619                    .unwrap_or(wgpu::TextureFormat::Bgra8UnormSrgb)
2620            })
2621            .unwrap_or(wgpu::TextureFormat::Bgra8UnormSrgb);
2622
2623        let surf_config = wgpu::SurfaceConfiguration {
2624            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
2625            format: surf_format,
2626            width,
2627            height,
2628            present_mode: wgpu::PresentMode::Fifo,
2629            alpha_mode: wgpu::CompositeAlphaMode::Opaque,
2630            view_formats: vec![],
2631            desired_maximum_frame_latency: 2,
2632        };
2633        wgpu_surface.configure(&renderer.device, &surf_config);
2634
2635        // SAFETY: The surface is tied to wayland_state + window_handle, both
2636        // of which live for the entire process.
2637        let wgpu_surface: wgpu::Surface<'static> = unsafe { std::mem::transmute(wgpu_surface) };
2638        let surface: &'static wgpu::Surface<'static> = Box::leak(Box::new(wgpu_surface));
2639
2640        Ok(RenderState {
2641            renderer: renderer.clone(),
2642            surface,
2643            render_lock: std::sync::Arc::new(std::sync::Mutex::new(())),
2644            playback_gen: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
2645            pacer: std::sync::Arc::new(LivePacer::new()),
2646            current_bind: None,
2647            current_tex: None,
2648            width,
2649            height,
2650            current_width: 0,
2651            current_height: 0,
2652            format: surf_format,
2653            video_playback: std::sync::Arc::new(crate::video::VideoPlayback::new()),
2654            hw_accel: crate::video::HwAccel::from_config(&config.video.hw_decode),
2655            preload_frames: config.video.preload_frames,
2656            max_fps: config.daemon.max_fps,
2657            scaling_mode: 0,
2658            per_output_uniforms: std::sync::Arc::new(renderer.create_per_output_uniforms()),
2659            last_wallpaper: None,
2660            pre_blank: None,
2661            blanked: false,
2662            gif_paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
2663        })
2664    }
2665}
2666
2667/// Synchronous version of `Daemon::create_render_state_for_output` for hotplug.
2668/// Reuses the existing adapter from the renderer instead of requesting a
2669/// new one, avoiding the async requirement.
2670#[allow(clippy::too_many_arguments)]
2671fn create_render_state_for_output_sync(
2672    renderer: &std::sync::Arc<Renderer>,
2673    display_ptr: *mut std::ffi::c_void,
2674    wayland_state: &mut WaylandState,
2675    qh: &QueueHandle<WaylandState>,
2676    output: &OutputInfo,
2677    config: &WallrConfig,
2678) -> Result<RenderState, DaemonError> {
2679    let wl_surface = wayland_state.compositor_state.create_surface(qh);
2680    let layer_surface = wayland_state.layer_shell.create_layer_surface(
2681        qh,
2682        wl_surface,
2683        Layer::Background,
2684        Some("wallr"),
2685        Some(&output.wl_output),
2686    );
2687    layer_surface.set_anchor(Anchor::TOP | Anchor::BOTTOM | Anchor::LEFT | Anchor::RIGHT);
2688    layer_surface.set_exclusive_zone(-1);
2689    layer_surface.set_keyboard_interactivity(KeyboardInteractivity::None);
2690
2691    let empty_region = wayland_state.compositor.create_region(qh, ());
2692    layer_surface
2693        .wl_surface()
2694        .set_input_region(Some(&empty_region));
2695    let output_id = output.wl_output.id().protocol_id();
2696    let viewport = wayland_state
2697        .viewporter
2698        .as_ref()
2699        .map(|viewporter| viewporter.get_viewport(layer_surface.wl_surface(), qh, ()));
2700    let scale_factor = if viewport.is_some() {
2701        1
2702    } else if output.scale_factor > 0 {
2703        output.scale_factor
2704    } else {
2705        1
2706    };
2707    layer_surface.wl_surface().set_buffer_scale(scale_factor);
2708    layer_surface.commit();
2709    empty_region.destroy();
2710    if let Some(viewport) = viewport {
2711        wayland_state.viewports.insert(output_id, viewport);
2712    }
2713
2714    // mode.dimensions already returns physical pixels; do not multiply by scale.
2715    let width = output.width;
2716    let height = output.height;
2717
2718    let raw_surface = layer_surface.wl_surface().id().as_ptr() as *mut std::ffi::c_void;
2719    wayland_state.surfaces.push((output_id, layer_surface));
2720
2721    let window_handle = WaylandWindow {
2722        display: display_ptr,
2723        surface: raw_surface,
2724    };
2725
2726    let wgpu_surface = renderer
2727        .instance
2728        .create_surface(&window_handle)
2729        .map_err(|e| DaemonError::StartError(format!("wgpu surface creation failed: {e:?}")))?;
2730
2731    let surf_format = {
2732        let caps = wgpu_surface.get_capabilities(&renderer.adapter);
2733        caps.formats
2734            .into_iter()
2735            .next()
2736            .unwrap_or(wgpu::TextureFormat::Bgra8UnormSrgb)
2737    };
2738
2739    let surf_config = wgpu::SurfaceConfiguration {
2740        usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
2741        format: surf_format,
2742        width,
2743        height,
2744        present_mode: wgpu::PresentMode::Fifo,
2745        alpha_mode: wgpu::CompositeAlphaMode::Opaque,
2746        view_formats: vec![],
2747        desired_maximum_frame_latency: 2,
2748    };
2749    wgpu_surface.configure(&renderer.device, &surf_config);
2750
2751    let wgpu_surface: wgpu::Surface<'static> = unsafe { std::mem::transmute(wgpu_surface) };
2752    let surface: &'static wgpu::Surface<'static> = Box::leak(Box::new(wgpu_surface));
2753
2754    Ok(RenderState {
2755        renderer: renderer.clone(),
2756        surface,
2757        render_lock: std::sync::Arc::new(std::sync::Mutex::new(())),
2758        playback_gen: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
2759        pacer: std::sync::Arc::new(LivePacer::new()),
2760        current_bind: None,
2761        current_tex: None,
2762        width,
2763        height,
2764        current_width: 0,
2765        current_height: 0,
2766        format: surf_format,
2767        video_playback: std::sync::Arc::new(crate::video::VideoPlayback::new()),
2768        hw_accel: crate::video::HwAccel::from_config(&config.video.hw_decode),
2769        preload_frames: config.video.preload_frames,
2770        max_fps: config.daemon.max_fps,
2771        scaling_mode: 0,
2772        per_output_uniforms: std::sync::Arc::new(renderer.create_per_output_uniforms()),
2773        last_wallpaper: None,
2774        pre_blank: None,
2775        blanked: false,
2776        gif_paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
2777    })
2778}