//! One wallpaper texture per GPU context. Decoded pixels are staging data, //! released after upload and decoded again when a new context needs them. //! Resize changes sampling only. use std::cell::Cell; use std::collections::HashMap; use std::path::PathBuf; use std::rc::Rc; use std::sync::mpsc; use std::time::{Duration, Instant}; use serde::Deserialize; use smithay::backend::allocator::Fourcc; use smithay::backend::renderer::element::Id; use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture}; use smithay::backend::renderer::utils::CommitCounter; use smithay::backend::renderer::{ErasedContextId, ImportMem, Renderer}; use smithay::output::Output; use smithay::utils::{Buffer, Physical, Rectangle, Size}; use crate::presentation::NativeTextureElement; mod decoder; mod pixels; use decoder::Decoder; use pixels::Pixels; use std::sync::Arc; const UPLOAD_RETRY_DELAY: Duration = Duration::from_secs(5); #[derive(Clone, Debug, Deserialize, PartialEq, schemars::JsonSchema, serde::Serialize)] pub(crate) struct WallpaperConfig { #[serde(default = "default_wallpaper_path")] pub path: Option, #[serde(default)] pub mode: WallpaperMode, } fn default_wallpaper_path() -> Option { Some(PathBuf::from(ferese_config::default_wallpaper())) } impl Default for WallpaperConfig { fn default() -> Self { Self { path: default_wallpaper_path(), mode: WallpaperMode::default(), } } } #[derive( Clone, Copy, Debug, Default, Deserialize, PartialEq, schemars::JsonSchema, serde::Serialize, )] #[serde(rename_all = "snake_case")] pub(crate) enum WallpaperMode { #[default] Fill, Fit, } struct WallpaperTexture { texture: GlesTexture, id: Id, size: Size, } pub(crate) struct WallpaperState { config: WallpaperConfig, pending: Option, loading: Option, decoder: Arc, commit: CommitCounter, owned: bool, mode: WallpaperMode, receiver: Option>>, pixels: Option, textures: HashMap, upload_retries: HashMap, // Prevent a missing/corrupt source from being decoded on every frame when // a new renderer context needs the wallpaper. decode_failed: bool, wakeup: Option, retry_timer_pending: Rc>, retry_wakeup: Rc>, } impl WallpaperState { #[cfg(test)] pub fn new(config: WallpaperConfig) -> Self { Self::with_wakeup(config, None) } pub fn with_wakeup( config: WallpaperConfig, wakeup: Option, ) -> Self { Self::with_decoder(config, wakeup, Decoder::new(pixels::load)) } pub(crate) fn replacement(&self, config: WallpaperConfig) -> Self { Self::with_decoder(config, self.wakeup.clone(), self.decoder.clone()) } fn with_decoder( config: WallpaperConfig, wakeup: Option, decoder: Arc, ) -> Self { let retained = config.clone(); let owned = config.path.as_ref().is_some_and(|path| path.is_file()); let decode_failed = config.path.is_some() && !owned; let receiver = if owned { Some( decoder.submit( config .path .clone() .expect("owned wallpaper decoding requires a configured image path"), wakeup.clone(), ), ) } else { decoder.cancel_pending(); None }; Self { config: retained, pending: None, loading: None, decoder, commit: CommitCounter::default(), owned, mode: config.mode, receiver, pixels: None, textures: HashMap::new(), upload_retries: HashMap::new(), decode_failed, wakeup, retry_timer_pending: Rc::default(), retry_wakeup: Rc::default(), } } fn ensure_pixels(&mut self) { if self.pixels.is_some() || self.receiver.is_some() || self.decode_failed { return; } let Some(path) = self.config.path.clone() else { return; }; self.receiver = Some(self.decoder.submit(path, self.wakeup.clone())) } pub(crate) fn configuration(&self) -> &WallpaperConfig { &self.config } pub(crate) fn failed(&self) -> bool { self.config.path.is_some() && self.receiver.is_none() && self.pixels.is_none() && self.textures.is_empty() && self.decode_failed } #[cfg(test)] pub fn owns_background(&self) -> bool { self.owned } pub fn reload(&mut self, config: WallpaperConfig) { if self.config.path != config.path && config.path.as_ref().is_some_and(|path| !path.is_file()) { tracing::warn!("new wallpaper is unavailable; retaining previous image"); return; } if self.receiver.is_some() { if self.loading.is_some() && self.config == config { self.receiver = None; self.loading = None; self.pending = None; self.decoder.cancel_pending(); return; } let requested = self.loading.as_ref().unwrap_or(&self.config); self.pending = (requested != &config).then_some(config); return; } if self.config == config { return; } if self.config.path == config.path { self.mode = config.mode; self.config = config; self.commit.increment(); return; } if let Some(path) = config.path.clone() { self.receiver = Some(self.decoder.submit(path, self.wakeup.clone())); self.loading = Some(config); } else { self.decoder.cancel_pending(); self.config = config; self.mode = self.config.mode; self.owned = false; self.pixels = None; self.textures.clear(); self.upload_retries.clear(); self.decode_failed = false; self.commit.increment(); } } pub fn forget_context(&mut self, context: &ErasedContextId) { self.textures.remove(context); self.upload_retries.remove(context); } fn upload_ready(&self, context: &ErasedContextId, now: Instant) -> bool { self.upload_retries .get(context) .is_none_or(|retry| now >= *retry) } pub(crate) fn take_retry_wakeup(&self) -> bool { self.retry_wakeup.replace(false) && !self.upload_retries.is_empty() } fn next_retry_deadline(&self, now: Instant) -> Option { self.upload_retries.values().copied().min().map(|deadline| { // A suspended/hidden output may not have attempted an upload when // the timer fired. Avoid immediately rescheduling an expired timer. if deadline <= now { now + UPLOAD_RETRY_DELAY } else { deadline } }) } pub(crate) fn arm_retry_timer( &self, handle: &smithay::reexports::calloop::LoopHandle<'static, Data>, now: Instant, ) -> Result<(), Box> { use smithay::reexports::calloop::timer::{TimeoutAction, Timer}; if self.retry_timer_pending.get() { return Ok(()); } let Some(deadline) = self.next_retry_deadline(now) else { return Ok(()); }; let pending = self.retry_timer_pending.clone(); let wakeup = self.retry_wakeup.clone(); handle.insert_source(Timer::from_deadline(deadline), move |_, _, _| { pending.set(false); wakeup.set(true); TimeoutAction::Drop })?; self.retry_timer_pending.set(true); Ok(()) } pub fn poll(&mut self) -> bool { let Some(receiver) = &self.receiver else { return false; }; match receiver.try_recv() { Ok(result) => { self.receiver = None; let loading = self.loading.take(); let replacing_wallpaper = loading.is_some(); match result { Ok(pixels) => { if let Some(config) = loading { self.config = config; self.mode = self.config.mode; self.owned = true; } tracing::info!( width = pixels.width(), height = pixels.height(), bytes = pixels.as_raw().len(), "decoded compositor wallpaper for texture upload" ); self.pixels = Some(pixels); self.decode_failed = false; // A decode for a new wallpaper invalidates textures from // the old image. A lazy re-decode for another context does // not: those existing textures are still valid. if replacing_wallpaper { self.textures.clear(); } self.upload_retries.clear(); self.commit.increment(); } Err(error) => { // A failed replacement does not poison the currently // accepted wallpaper. Only failure to decode the accepted // source itself should suppress repeated lazy re-decodes. if !replacing_wallpaper { self.decode_failed = true; } tracing::warn!(%error, "wallpaper decode failed; retaining previous image") } } if let Some(pending) = self.pending.take() { self.reload(pending); } true } Err(mpsc::TryRecvError::Disconnected) => { self.receiver = None; self.loading = None; false } Err(mpsc::TryRecvError::Empty) => false, } } pub fn element( &mut self, renderer: &mut GlesRenderer, output: &Output, ) -> Option { let context = renderer.context_id().erased(); if !self.textures.contains_key(&context) { if !self.upload_ready(&context, Instant::now()) { return None; } if self.pixels.is_none() { self.ensure_pixels(); return None; } let (size, bytes, texture) = { let pixels = self.pixels.as_ref()?; let size = Size::from((pixels.width() as i32, pixels.height() as i32)); let bytes = pixels.as_raw().len(); let texture = renderer.import_memory(pixels.as_raw(), Fourcc::Abgr8888, size, false); (size, bytes, texture) }; match texture { Ok(texture) => { self.upload_retries.remove(&context); self.textures.insert( context.clone(), WallpaperTexture { texture, id: Id::new(), size, }, ); // import_memory() has produced a renderer-owned texture. // The decoded RGBA mapping is only staging and no longer // needs to stay resident. self.pixels = None; tracing::debug!( bytes, "uploaded shared wallpaper texture and released CPU staging pixels" ); } Err(error) => { // Keep pixels alive on failure so the retry does not require // another decode. self.upload_retries .insert(context, Instant::now() + UPLOAD_RETRY_DELAY); tracing::debug!(%error, "wallpaper texture import failed"); return None; } } } let cached = self.textures.get(&context)?; let output_size = output .current_transform() .transform_size(output.current_mode()?.size); let (geometry, source) = image_geometry(cached.size, output_size, self.mode); Some(NativeTextureElement { id: cached.id.clone(), commit: self.commit, texture: cached.texture.clone(), geometry, source, alpha: 1.0, program: None, uniforms: vec![], }) } } fn image_geometry( image: Size, output: Size, mode: WallpaperMode, ) -> (Rectangle, Rectangle) { let sx = f64::from(output.w) / f64::from(image.w); let sy = f64::from(output.h) / f64::from(image.h); match mode { WallpaperMode::Fill => { let scale = sx.max(sy); let width = f64::from(output.w) / scale; let height = f64::from(output.h) / scale; ( Rectangle::from_size(output), Rectangle::new( ( (f64::from(image.w) - width) / 2.0, (f64::from(image.h) - height) / 2.0, ) .into(), (width, height).into(), ), ) } WallpaperMode::Fit => { let scale = sx.min(sy); let size: Size = ( (f64::from(image.w) * scale).round() as i32, (f64::from(image.h) * scale).round() as i32, ) .into(); ( Rectangle::new( ((output.w - size.w) / 2, (output.h - size.h) / 2).into(), size, ), Rectangle::from_size(image.to_f64()), ) } } } #[cfg(test)] mod tests { use super::*; #[test] fn failed_reload_preserves_accepted_config_and_unavailable_pending_keeps_success() { let directory = tempfile::tempdir().unwrap(); let mut state = WallpaperState::new(WallpaperConfig { path: None, mode: WallpaperMode::Fill, }); let accepted = state.config.clone(); state.pixels = Some(pixels::from_rgba(image::RgbaImage::new(2, 2)).unwrap()); let bad = directory.path().join("bad-image"); std::fs::write(&bad, b"not an image").unwrap(); state.reload(WallpaperConfig { path: Some(bad), mode: WallpaperMode::Fit, }); let deadline = Instant::now() + Duration::from_secs(2); while !state.poll() { assert!(Instant::now() < deadline); std::thread::sleep(Duration::from_millis(1)); } assert_eq!(state.config, accepted); assert_eq!(state.pixels.as_ref().unwrap().dimensions(), (2, 2)); let (sender, receiver) = mpsc::channel(); state.receiver = Some(receiver); state.reload(WallpaperConfig { path: Some(directory.path().join("missing")), mode: WallpaperMode::Fit, }); sender .send(Ok(pixels::from_rgba(image::RgbaImage::new(3, 4)).unwrap())) .unwrap(); assert!(state.poll()); assert_eq!(state.pixels.as_ref().unwrap().dimensions(), (3, 4)); assert_eq!(state.config, accepted); let replacement = state.replacement(accepted); assert!(Arc::ptr_eq(&state.decoder, &replacement.decoder)); } #[test] fn portal_wallpaper_decodes_without_a_filename_extension() { let directory = tempfile::tempdir().unwrap(); let expected = image::RgbaImage::from_pixel(3, 2, image::Rgba([32, 80, 160, 255])); for name in ["wallpaper-portal", "wallpaper.jpg"] { let path = directory.path().join(name); expected .save_with_format(&path, image::ImageFormat::Png) .unwrap(); let mut state = WallpaperState::new(WallpaperConfig { path: Some(path), mode: WallpaperMode::Fill, }); let decoded = state .receiver .take() .unwrap() .recv_timeout(Duration::from_secs(5)) .unwrap() .unwrap(); assert_eq!(decoded.dimensions(), expected.dimensions()); assert_eq!(&decoded.as_raw()[..], expected.as_raw()); } } #[test] fn failed_upload_retry_wakes_idle_loop_and_does_not_spin_when_output_is_inactive() { use smithay::backend::renderer::ContextId; use smithay::reexports::calloop::EventLoop; let mut event_loop = EventLoop::<()>::try_new().unwrap(); let mut state = WallpaperState::new(WallpaperConfig { path: None, mode: WallpaperMode::Fill, }); let context = ContextId::::new().erased(); let now = Instant::now(); state .upload_retries .insert(context.clone(), now + Duration::from_millis(10)); state.arm_retry_timer(&event_loop.handle(), now).unwrap(); state.arm_retry_timer(&event_loop.handle(), now).unwrap(); assert!(state.retry_timer_pending.get()); assert!(!state.take_retry_wakeup()); event_loop .dispatch(Some(Duration::from_secs(1)), &mut ()) .unwrap(); assert!(!state.retry_timer_pending.get()); assert!( state.take_retry_wakeup(), "retry must request redraw without input or client activity" ); assert!(!state.take_retry_wakeup()); let after = Instant::now(); assert_eq!( state.next_retry_deadline(after), Some(after + UPLOAD_RETRY_DELAY) ); state.forget_context(&context); assert_eq!(state.next_retry_deadline(after), None); state.retry_wakeup.set(true); assert!( !state.take_retry_wakeup(), "cleared failures must not trigger stale redraws" ); } #[test] fn decoder_wakes_an_idle_event_loop() { use smithay::reexports::calloop::EventLoop; use smithay::reexports::calloop::timer::Timer; let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("wallpaper.png"); let expected = image::RgbaImage::from_pixel(2, 2, image::Rgba([32, 80, 160, 255])); expected.save(&path).unwrap(); let mut event_loop = EventLoop::::try_new().unwrap(); event_loop .handle() .insert_source(Timer::from_duration(Duration::from_secs(5)), |_, _, _| { panic!("wallpaper decoder failed to wake the idle event loop"); }) .unwrap(); let signal = event_loop.get_signal(); let mut state = WallpaperState::with_wakeup( WallpaperConfig { path: Some(path), mode: WallpaperMode::Fill, }, Some(signal.clone()), ); event_loop .run(None, &mut state, |state| { if state.poll() { let decoded = state.pixels.as_ref().unwrap(); assert_eq!(decoded.dimensions(), expected.dimensions()); assert_eq!(&decoded.as_raw()[..], expected.as_raw()); signal.stop(); } }) .unwrap(); } #[test] fn failed_uploads_back_off_per_context_and_reset_for_new_pixels() { use smithay::backend::renderer::ContextId; let context = ContextId::::new().erased(); let other = ContextId::::new().erased(); let mut state = WallpaperState::new(WallpaperConfig { path: None, mode: WallpaperMode::Fill, }); let now = Instant::now(); state .upload_retries .insert(context.clone(), now + UPLOAD_RETRY_DELAY); assert!(!state.upload_ready(&context, now)); assert!(state.upload_ready(&other, now)); assert!(state.upload_ready(&context, now + UPLOAD_RETRY_DELAY)); state.forget_context(&context); assert!(state.upload_ready(&context, now)); state .upload_retries .insert(context.clone(), now + UPLOAD_RETRY_DELAY); let (sender, receiver) = mpsc::channel(); state.receiver = Some(receiver); sender .send(Ok(pixels::from_rgba(image::RgbaImage::new(2, 2)).unwrap())) .unwrap(); assert!(state.poll()); assert!(state.upload_ready(&context, now)); } #[test] fn live_reload_coalesces_decoders_and_mode_changes_invalidate_damage() { let mut state = WallpaperState::new(WallpaperConfig::default()); let (sender, receiver) = mpsc::channel(); state.receiver = Some(receiver); state.reload(WallpaperConfig { path: state.config.path.clone(), mode: WallpaperMode::Fit, }); assert!(state.receiver.is_some()); let before = state.commit; sender .send(Ok(pixels::from_rgba(image::RgbaImage::new(2, 2)).unwrap())) .unwrap(); assert!(state.poll()); assert_eq!(state.mode, WallpaperMode::Fit); assert!(state.pixels.is_some()); assert_ne!(state.commit, before); assert!(state.receiver.is_none()); let before = state.commit; state.reload(state.config.clone()); assert_eq!(state.commit, before); } #[test] fn reverting_a_pending_reload_keeps_latest_request_and_failed_decode_keeps_pixels() { let mut state = WallpaperState::new(WallpaperConfig::default()); state.pixels = Some(pixels::from_rgba(image::RgbaImage::new(2, 2)).unwrap()); let (sender, receiver) = mpsc::channel(); state.receiver = Some(receiver); state.reload(WallpaperConfig { path: state.config.path.clone(), mode: WallpaperMode::Fit, }); state.reload(WallpaperConfig::default()); assert!(state.pending.is_none()); sender.send(Err("bad image".into())).unwrap(); assert!(state.poll()); assert!(state.pixels.is_some()); assert_eq!(state.mode, WallpaperMode::Fill); } #[test] fn fill_crops_and_fit_letterboxes_without_reallocating_image() { let (geometry, source) = image_geometry( (3840, 2160).into(), (1600, 1000).into(), WallpaperMode::Fill, ); assert_eq!(geometry.size, (1600, 1000).into()); assert_eq!(source.size.h, 2160.0); assert!(source.loc.x > 0.0); let (geometry, source) = image_geometry((3840, 2160).into(), (1600, 1000).into(), WallpaperMode::Fit); assert_eq!(geometry.size, (1600, 900).into()); assert_eq!(geometry.loc, (0, 50).into()); assert_eq!(source.size, (3840.0, 2160.0).into()); } #[test] fn missing_wallpaper_preserves_external_shell_fallback() { let state = WallpaperState::new(WallpaperConfig { path: None, mode: WallpaperMode::default(), }); assert!(!state.owns_background()); } #[test] fn default_wallpaper_is_bundled_and_decodable() { let config: WallpaperConfig = ferese_config::from_str("").unwrap(); let path = config.path.unwrap(); assert_eq!(image::image_dimensions(&path).unwrap(), (3840, 2160)); let light = ferese_config::default_wallpaper_for(ferese_config::theme::Appearance::Light); assert_eq!(image::image_dimensions(light).unwrap(), (3840, 2160)); assert!(WallpaperState::new(WallpaperConfig::default()).owns_background()); } #[test] fn released_staging_is_not_a_failed_wallpaper() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("wallpaper.png"); image::RgbaImage::from_pixel(2, 2, image::Rgba([32, 80, 160, 255])) .save(&path) .unwrap(); let mut state = WallpaperState::new(WallpaperConfig { path: Some(path), mode: WallpaperMode::Fill, }); let deadline = Instant::now() + Duration::from_secs(2); while !state.poll() { assert!(Instant::now() < deadline); std::thread::sleep(Duration::from_millis(1)); } assert!(state.pixels.is_some()); assert!(!state.failed()); // Model successful upload releasing CPU staging. state.pixels = None; // Missing staging is normal after upload, not a decode failure. assert!(!state.failed()); } #[test] fn missing_staging_is_lazily_redecoded() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("wallpaper.png"); image::RgbaImage::from_pixel(3, 2, image::Rgba([32, 80, 160, 255])) .save(&path) .unwrap(); let mut state = WallpaperState::new(WallpaperConfig { path: Some(path), mode: WallpaperMode::Fill, }); let deadline = Instant::now() + Duration::from_secs(2); while !state.poll() { assert!(Instant::now() < deadline); std::thread::sleep(Duration::from_millis(1)); } state.pixels = None; assert!(state.receiver.is_none()); state.ensure_pixels(); assert!(state.receiver.is_some()); let deadline = Instant::now() + Duration::from_secs(2); while !state.poll() { assert!(Instant::now() < deadline); std::thread::sleep(Duration::from_millis(1)); } assert_eq!(state.pixels.as_ref().unwrap().dimensions(), (3, 2)); } #[test] fn failed_lazy_decode_is_not_restarted_every_frame() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("wallpaper"); std::fs::write(&path, b"not an image").unwrap(); let mut state = WallpaperState::new(WallpaperConfig { path: Some(path), mode: WallpaperMode::Fill, }); let deadline = Instant::now() + Duration::from_secs(2); while !state.poll() { assert!(Instant::now() < deadline); std::thread::sleep(Duration::from_millis(1)); } assert!(state.decode_failed); assert!(state.failed()); state.ensure_pixels(); assert!( state.receiver.is_none(), "failed source must not restart a decode every frame" ); } }