Fix lock-screen input and wallpaper handling

This commit is contained in:
Samuel Onoja committed 2026-10-07 07:19:06 +01:00
1 parent bbc5b27c25
commit cfdd2a148a
7 files changed
+350 -57

No files matched your search

+147 -12
View File
@@ -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<image::Handle>,
}
@@ -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<image::Handle, String>) -> 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<image::Handle, String> {
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<image::Handle, String> {
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<image::Handle
reader.limits(limits);
let decoder = reader.into_decoder().map_err(|e| e.to_string())?;
let (width, height) = decoder.dimensions();
if u64::from(width) * u64::from(height) * 4 > 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<image::Handle
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wallpaper_handles_keep_only_paths_or_blurred_thumbnails() {
let file = tempfile::NamedTempFile::new().unwrap();
::image::RgbaImage::from_pixel(1280, 720, ::image::Rgba([32, 80, 160, 255]))
.save_with_format(file.path(), ::image::ImageFormat::Png)
.unwrap();
match load_wallpaper(file.path(), 0.).unwrap() {
image::Handle::Path(_, path) => 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();
+36 -24
View File
@@ -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;