From cfdd2a148a9e3ecea1ddbac385b4f609ce0aa9d7 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 7 Oct 2026 07:19:06 +0100 Subject: [PATCH] Fix lock-screen input and wallpaper handling --- compositor/src/input.rs | 40 +++++-- compositor/src/presentation.rs | 2 + compositor/src/session_lock.rs | 60 +++++++++- compositor/src/session_lock/tests.rs | 63 +++++++++++ compositor/src/wallpaper.rs | 23 +++- shell/ferese-lock/src/appearance.rs | 159 +++++++++++++++++++++++++-- shell/ferese-lock/src/main.rs | 60 ++++++---- 7 files changed, 350 insertions(+), 57 deletions(-) diff --git a/compositor/src/input.rs b/compositor/src/input.rs index ecaa3ee..8112f56 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -120,18 +120,7 @@ impl Ferese { } if self.session_lock.active() { - // An already-bound IME or drag client may install a grab after the - // lock request. Never let that grab receive subsequent lock input. - if let Some(keyboard) = seat.get_keyboard() { - keyboard.unset_grab(self); - } - if let Some(pointer) = seat.get_pointer() { - pointer.unset_grab(self, SERIAL_COUNTER.next_serial(), 0); - } - if let Some(touch) = seat.get_touch() { - touch.unset_grab(self); - } - self.focus_lock_surface(); + self.prepare_lock_input(); } if self.input_capture.active() @@ -514,6 +503,33 @@ impl Ferese { } let pointer = self.seat.get_pointer().expect("seat has a pointer"); let serial = SERIAL_COUNTER.next_serial(); + if self.session_lock.active() { + if event.state() == ButtonState::Pressed { + self.focus_window_at(pointer.current_location(), serial, true); + if !pointer.is_grabbed() { + pointer.motion( + self, + self.lock_surface_under(pointer.current_location()), + &MotionEvent { + location: pointer.current_location(), + serial, + time: event.time_msec(), + }, + ); + } + } + pointer.button( + self, + &ButtonEvent { + button: event.button_code(), + state: event.state(), + serial, + time: event.time_msec(), + }, + ); + pointer.frame(self); + return; + } // Native popup_done destroys Iced's window immediately. For // effects-capable shell popups, release input now but defer diff --git a/compositor/src/presentation.rs b/compositor/src/presentation.rs index 9800c9a..c3046f5 100644 --- a/compositor/src/presentation.rs +++ b/compositor/src/presentation.rs @@ -2,6 +2,8 @@ //! logical pixels. Content, clips and decorations share those exact edges. use std::time::{Duration, Instant}; +use ferese_animation::AnimatedRect; + use smithay::backend::renderer::element::{Element, Id, Kind, RenderElement, RenderElementStates}; use smithay::backend::renderer::gles::{GlesError, GlesFrame, GlesRenderer, GlesTexProgram, GlesTexture, Uniform}; use smithay::backend::renderer::utils::{CommitCounter, DamageSet}; diff --git a/compositor/src/session_lock.rs b/compositor/src/session_lock.rs index eae50a8..15d3d30 100644 --- a/compositor/src/session_lock.rs +++ b/compositor/src/session_lock.rs @@ -273,6 +273,58 @@ impl Ferese { }) } + pub(crate) fn prepare_lock_input(&mut self) { + // Reject desktop grabs installed after acquisition, but keep the lock + // owner's implicit click grab intact until its button release. + if let Some(keyboard) = self.seat.get_keyboard() + && keyboard.is_grabbed() + { + keyboard.unset_grab(self); + } + if let Some(pointer) = self.seat.get_pointer() { + let lock_click = pointer + .with_grab(|_, grab| { + grab.is::>() + && grab.start_data().focus.as_ref().is_some_and(|(surface, _)| { + self.session_lock + .surfaces + .values() + .any(|lock| lock.alive() && lock.wl_surface() == surface) + }) + }) + .unwrap_or(false); + if pointer.is_grabbed() && !lock_click { + pointer.unset_grab(self, SERIAL_COUNTER.next_serial(), self.last_pointer_time); + } + let stale_focus = pointer.current_focus().is_some_and(|surface| { + !self + .session_lock + .surfaces + .values() + .any(|lock| lock.alive() && lock.wl_surface() == &surface) + }); + if stale_focus { + let location = pointer.current_location(); + pointer.motion( + self, + self.lock_surface_under(location), + &smithay::input::pointer::MotionEvent { + location, + serial: SERIAL_COUNTER.next_serial(), + time: self.last_pointer_time, + }, + ); + pointer.frame(self); + } + } + if let Some(touch) = self.seat.get_touch() + && touch.is_grabbed() + { + touch.unset_grab(self); + } + self.focus_lock_surface(); + } + pub(crate) fn focus_lock_surface(&mut self) { let surface = self .focused_output() @@ -289,10 +341,10 @@ impl Ferese { .map(|(_, surface)| surface) }) .map(|surface| surface.wl_surface().clone()); - self.seat - .get_keyboard() - .unwrap() - .set_focus(self, surface, SERIAL_COUNTER.next_serial()); + let keyboard = self.seat.get_keyboard().unwrap(); + if keyboard.current_focus() != surface { + keyboard.set_focus(self, surface, SERIAL_COUNTER.next_serial()); + } } pub(crate) fn lock_frame_presented(&mut self, output: &Output) { diff --git a/compositor/src/session_lock/tests.rs b/compositor/src/session_lock/tests.rs index c6f658e..e4c799d 100644 --- a/compositor/src/session_lock/tests.rs +++ b/compositor/src/session_lock/tests.rs @@ -626,3 +626,66 @@ fn acquiring_owner_disconnect_keeps_lock_and_replacement_waits_for_frame() { assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Locked(_))); replacement.expect_locked(); } + +#[test] +fn lock_input_preserves_owner_click_grabs_and_rejects_retired_surfaces() { + let Some(mut fixture) = + Fixture::new("session_lock::tests::lock_input_preserves_owner_click_grabs_and_rejects_retired_surfaces") + else { + return; + }; + let output = fixture.output("lock-pointer"); + let mut owner = fixture.acquire(); + let surface = owner.surface(&mut fixture, &output); + let pointer = fixture.state.seat.get_pointer().unwrap(); + let serial = SERIAL_COUNTER.next_serial(); + pointer.motion( + &mut fixture.state, + Some((surface.wl_surface().clone(), (0.0, 0.0).into())), + &smithay::input::pointer::MotionEvent { + location: (10.0, 10.0).into(), + serial, + time: 1, + }, + ); + pointer.button( + &mut fixture.state, + &smithay::input::pointer::ButtonEvent { + button: 0x110, + state: smithay::backend::input::ButtonState::Pressed, + serial, + time: 2, + }, + ); + assert!(pointer.is_grabbed()); + fixture.state.prepare_lock_input(); + assert!( + pointer.is_grabbed(), + "the lock owner's click must survive until release" + ); + pointer.button( + &mut fixture.state, + &smithay::input::pointer::ButtonEvent { + button: 0x110, + state: smithay::backend::input::ButtonState::Released, + serial: SERIAL_COUNTER.next_serial(), + time: 3, + }, + ); + assert!(!pointer.is_grabbed()); + pointer.button( + &mut fixture.state, + &smithay::input::pointer::ButtonEvent { + button: 0x110, + state: smithay::backend::input::ButtonState::Pressed, + serial: SERIAL_COUNTER.next_serial(), + time: 4, + }, + ); + assert!(pointer.is_grabbed()); + fixture.state.session_lock.surfaces.clear(); + fixture.state.prepare_lock_input(); + assert!(!pointer.is_grabbed(), "a retired surface cannot keep a lock grab"); + assert_eq!(pointer.current_focus(), None); + assert_eq!(fixture.state.seat.get_keyboard().unwrap().current_focus(), None); +} diff --git a/compositor/src/wallpaper.rs b/compositor/src/wallpaper.rs index 04456eb..d4fb12a 100644 --- a/compositor/src/wallpaper.rs +++ b/compositor/src/wallpaper.rs @@ -1,5 +1,6 @@ -//! One decoded image and one texture per GPU context, not double-buffered -//! full-screen UI surfaces per monitor. Resize changes sampling only. +//! 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; @@ -279,7 +280,7 @@ impl WallpaperState { width = pixels.width(), height = pixels.height(), bytes = pixels.as_raw().len(), - "decoded compositor wallpaper once" + "decoded compositor wallpaper for texture upload" ); self.pixels = Some(pixels); @@ -540,6 +541,10 @@ mod tests { 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() @@ -548,11 +553,19 @@ mod tests { }) .unwrap(); let signal = event_loop.get_signal(); - let mut state = WallpaperState::with_wakeup(WallpaperConfig::default(), Some(signal.clone())); + 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() { - assert!(state.pixels.is_some()); + let decoded = state.pixels.as_ref().unwrap(); + assert_eq!(decoded.dimensions(), expected.dimensions()); + assert_eq!(&decoded.as_raw()[..], expected.as_raw()); signal.stop(); } }) diff --git a/shell/ferese-lock/src/appearance.rs b/shell/ferese-lock/src/appearance.rs index aab0e41..17162ea 100644 --- a/shell/ferese-lock/src/appearance.rs +++ b/shell/ferese-lock/src/appearance.rs @@ -20,6 +20,8 @@ pub struct Appearance { pub wallpaper_path: std::path::PathBuf, pub wallpaper_blur: f32, pub wallpaper_revision: u64, + wallpaper_loaded: bool, + wallpaper_pending: Option<(std::path::PathBuf, bool)>, pub reduce_transparency: bool, pub avatar: Option, } @@ -75,10 +77,11 @@ impl Appearance { .unwrap_or(18.) .clamp(0., 40.) as f32; let reduce_transparency = theme.accessibility.reduce_transparency; - let wallpaper = - load_wallpaper(&wallpaper_path, if reduce_transparency { 0. } else { blur }).unwrap_or_else(|_| { - image::Handle::from_bytes(include_bytes!("../../../assets/wallpapers/ferese.png").as_slice()) - }); + let loaded = load_wallpaper(&wallpaper_path, if reduce_transparency { 0. } else { blur }); + let wallpaper_loaded = loaded.is_ok(); + let wallpaper = loaded.unwrap_or_else(|_| { + image::Handle::from_bytes(include_bytes!("../../../assets/wallpapers/ferese.png").as_slice()) + }); let boolean = |key, fallback| { doc.as_ref() .and_then(|d| d.get(key)) @@ -107,11 +110,60 @@ impl Appearance { wallpaper_path, wallpaper_blur: blur, wallpaper_revision: 0, + wallpaper_loaded, + wallpaper_pending: None, reduce_transparency, avatar: account_picture(user), } } + pub fn request_wallpaper( + &mut self, + theme: &ferese_config::theme::ResolvedTheme, + ) -> Option<(u64, std::path::PathBuf, f32)> { + let path = theme + .tokens + .background + .lock_path + .as_ref() + .or(theme.tokens.background.path.as_ref()) + .cloned() + .unwrap_or_else(|| ferese_config::default_wallpaper_for(theme.appearance).into()); + let reduce = theme.accessibility.reduce_transparency; + let desired = (path.clone(), reduce); + if self.wallpaper_pending.as_ref() == Some(&desired) { + return None; + } + if self.wallpaper_loaded && path == self.wallpaper_path && reduce == self.reduce_transparency { + if self.wallpaper_pending.take().is_some() { + self.wallpaper_revision = self.wallpaper_revision.wrapping_add(1); + } + return None; + } + self.wallpaper_pending = Some(desired); + self.wallpaper_revision = self.wallpaper_revision.wrapping_add(1); + Some(( + self.wallpaper_revision, + path, + if reduce { 0.0 } else { self.wallpaper_blur }, + )) + } + + pub fn finish_wallpaper(&mut self, revision: u64, result: Result) -> Result<(), String> { + if revision != self.wallpaper_revision { + return Ok(()); + } + let Some((path, reduce)) = self.wallpaper_pending.take() else { + return Ok(()); + }; + let wallpaper = result?; + self.wallpaper = wallpaper; + self.wallpaper_path = path; + self.reduce_transparency = reduce; + self.wallpaper_loaded = true; + Ok(()) + } + pub fn clock_format(&self) -> &'static str { if self.twelve_hour { "%I:%M %p" } else { "%H:%M" } } @@ -215,6 +267,17 @@ fn decode_avatar(path: &std::path::Path) -> Result<::image::RgbaImage, String> { } pub fn load_wallpaper(path: &std::path::Path, blur: f32) -> Result { + let result = prepare_wallpaper(path, blur); + // Decode and resize scratch buffers have been dropped. Return their freed + // pages even when glibc would otherwise retain them in an arena. + #[cfg(all(target_os = "linux", target_env = "gnu"))] + unsafe { + libc::malloc_trim(0); + } + result +} + +fn prepare_wallpaper(path: &std::path::Path, blur: f32) -> Result { use ::image::ImageDecoder; let mut reader = ::image::ImageReader::open(path) .map_err(|e| e.to_string())? @@ -225,16 +288,18 @@ pub fn load_wallpaper(path: &std::path::Path, blur: f32) -> Result 256 * 1024 * 1024 { + if width == 0 || height == 0 || u64::from(width) * u64::from(height) > (256 * 1024 * 1024) / 4 { return Err("Lock wallpaper exceeds the decode limit".into()); } - let pixels = ::image::DynamicImage::from_decoder(decoder) - .map_err(|e| e.to_string())? - .into_rgba8(); - let pixels = if blur > 0. { - ::image::imageops::blur(&::image::imageops::thumbnail(&pixels, 640, 640), blur) - } else { - pixels + if blur <= 0. { + // Iced decodes the path and releases its host buffer after GPU upload. + // An RGBA handle would retain those pixels for the handle's lifetime. + return Ok(image::Handle::from_path(path)); + } + let pixels = { + let decoded = ::image::DynamicImage::from_decoder(decoder).map_err(|e| e.to_string())?; + let thumbnail = decoded.thumbnail(640, 640).into_rgba8(); + ::image::imageops::blur(&thumbnail, blur) }; Ok(image::Handle::from_rgba( pixels.width(), @@ -246,6 +311,76 @@ pub fn load_wallpaper(path: &std::path::Path, blur: f32) -> Result assert_eq!(path, file.path()), + _ => panic!("unblurred wallpaper must not retain RGBA pixels"), + } + match load_wallpaper(file.path(), 2.).unwrap() { + image::Handle::Rgba { + width, height, pixels, .. + } => { + assert_eq!((width, height), (640, 360)); + assert_eq!(pixels.len(), 640 * 360 * 4); + } + _ => panic!("blurred wallpaper must retain only the processed thumbnail"), + } + } + + #[test] + fn wallpaper_path_handles_require_a_valid_image_header() { + let file = tempfile::NamedTempFile::new().unwrap(); + assert!(load_wallpaper(file.path(), 0.).is_err()); + std::fs::write(file.path(), b"not an image").unwrap(); + assert!(load_wallpaper(file.path(), 0.).is_err()); + assert!(load_wallpaper(&file.path().with_extension("missing"), 0.).is_err()); + } + + #[test] + fn wallpaper_requests_retry_failures_and_ignore_superseded_results() { + let directory = tempfile::tempdir().unwrap(); + let initial = directory.path().join("initial.png"); + ::image::RgbaImage::new(2, 2).save(&initial).unwrap(); + let mut theme = ferese_config::theme::default_theme(); + theme.tokens.background.path = Some(initial.clone()); + theme.tokens.background.lock_path = None; + let document = Document::parse("").unwrap(); + let mut appearance = Appearance::from_document("no-such-test-user", Some(document), &theme); + assert!(appearance.request_wallpaper(&theme).is_none()); + let next = directory.path().join("next.png"); + theme.tokens.background.path = Some(next.clone()); + let (failed, _, _) = appearance.request_wallpaper(&theme).unwrap(); + assert!(appearance.request_wallpaper(&theme).is_none()); + assert!( + appearance + .finish_wallpaper(failed, Err("decode failed".into())) + .is_err() + ); + assert_eq!(appearance.wallpaper_path, initial); + let (retry, path, _) = appearance.request_wallpaper(&theme).unwrap(); + assert_eq!(path, next); + let last = directory.path().join("last.png"); + theme.tokens.background.path = Some(last.clone()); + let (latest, _, _) = appearance.request_wallpaper(&theme).unwrap(); + appearance + .finish_wallpaper(retry, Ok(image::Handle::from_rgba(1, 1, vec![255; 4]))) + .unwrap(); + assert_eq!(appearance.wallpaper_path, initial); + appearance + .finish_wallpaper(latest, Ok(image::Handle::from_rgba(1, 1, vec![255; 4]))) + .unwrap(); + assert_eq!(appearance.wallpaper_path, last); + assert!(appearance.request_wallpaper(&theme).is_none()); + theme.tokens.background.lock_path = Some(initial.clone()); + let (_, path, _) = appearance.request_wallpaper(&theme).unwrap(); + assert_eq!(path, initial, "an explicit lock image overrides the desktop wallpaper"); + } + #[test] fn avatar_decoding_bounds_dimensions_and_encoded_size() { let file = tempfile::NamedTempFile::new().unwrap(); diff --git a/shell/ferese-lock/src/main.rs b/shell/ferese-lock/src/main.rs index 0d79e69..f98c903 100644 --- a/shell/ferese-lock/src/main.rs +++ b/shell/ferese-lock/src/main.rs @@ -88,6 +88,7 @@ enum Message { Authenticated(bool), Tick, RetryReady, + FocusPassword, } // Never derive Debug for password-bearing messages. @@ -111,6 +112,15 @@ impl AuthState { matches!(self, Self::Ready) } + fn retry_ready(&mut self, now: Instant) -> bool { + if matches!(self, Self::Rejected(at) if now.saturating_duration_since(*at) >= Duration::from_secs(2)) { + *self = Self::Ready; + true + } else { + false + } + } + fn complete(&mut self, success: bool) -> bool { if *self != Self::Checking { return false; @@ -228,22 +238,7 @@ impl cosmic::Application for Locker { self.appearance.radius = palette.radius; self.appearance.font = ferese_theme::font(Some(&snapshot.presented.tokens.typography.font_family)); let theme = cosmic::command::set_theme(self.appearance.theme()); - let target = &snapshot.theme; - let path = target - .tokens - .background - .lock_path - .as_ref() - .or(target.tokens.background.path.as_ref()) - .cloned() - .unwrap_or_else(|| ferese_config::default_wallpaper().into()); - let reduce = target.accessibility.reduce_transparency; - if path != self.appearance.wallpaper_path || reduce != self.appearance.reduce_transparency { - self.appearance.wallpaper_path = path.clone(); - self.appearance.reduce_transparency = reduce; - self.appearance.wallpaper_revision = self.appearance.wallpaper_revision.wrapping_add(1); - let revision = self.appearance.wallpaper_revision; - let blur = if reduce { 0. } else { self.appearance.wallpaper_blur }; + if let Some((revision, path, blur)) = self.appearance.request_wallpaper(&snapshot.theme) { let wallpaper = cosmic::task::future(async move { let result = tokio::task::spawn_blocking(move || appearance::load_wallpaper(&path, blur)) .await @@ -255,11 +250,8 @@ impl cosmic::Application for Locker { return theme; } Message::WallpaperLoaded(revision, result) => { - if revision == self.appearance.wallpaper_revision { - match result { - Ok(image) => self.appearance.wallpaper = image, - Err(error) => eprintln!("ferese-lock: retained wallpaper: {error}"), - } + if let Err(error) = self.appearance.finish_wallpaper(revision, result) { + eprintln!("ferese-lock: retained wallpaper: {error}"); } } Message::Event(event) => return self.handle_event(*event), @@ -269,8 +261,13 @@ impl cosmic::Application for Locker { self.date = now.strftime("%A, %B %-d").to_string(); } Message::RetryReady => { - if matches!(self.state, AuthState::Rejected(at) if at.elapsed() >= Duration::from_secs(2)) { - self.state = AuthState::Ready; + if self.state.retry_ready(Instant::now()) { + return focus(); + } + } + Message::FocusPassword => { + if self.state.may_submit() { + return focus(); } } Message::Input(value) => { @@ -546,7 +543,9 @@ impl Locker { .height(Length::Fill) .content_fit(iced::ContentFit::Cover) .into(); - iced::widget::stack![background, foreground].into() + widget::mouse_area(iced::widget::stack![background, foreground]) + .on_press(Message::FocusPassword) + .into() } } @@ -567,6 +566,19 @@ fn ferese_symbol() -> widget::icon::Icon { #[cfg(test)] mod tests { use super::*; + #[test] + fn retry_reenables_input_only_after_the_deadline() { + let now = Instant::now(); + let mut state = AuthState::Rejected(now); + assert!(!state.retry_ready(now + Duration::from_millis(1999))); + assert!(!state.may_submit()); + assert!(state.retry_ready(now + Duration::from_secs(2))); + assert!(state.may_submit()); + assert!(!state.retry_ready(now + Duration::from_secs(3))); + state = AuthState::Checking; + assert!(!state.retry_ready(now + Duration::from_secs(3))); + } + #[test] fn only_an_active_successful_attempt_unlocks() { let mut state = AuthState::Ready;