From 4026f6dafad21c7f70eda3cf0ad9a574e2e5e1b7 Mon Sep 17 00:00:00 2001 From: nevaforget Date: Sat, 28 Mar 2026 22:06:38 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20audit=20fixes=20=E2=80=94=20double-unloc?= =?UTF-8?q?k=20guard,=20PAM=20OOM=20code,=20GPU=20blur,=20async=20fp=20sto?= =?UTF-8?q?p=20(v0.5.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: prevent double unlock() when PAM and fingerprint succeed simultaneously (ext-session-lock protocol error). Fix PAM callback returning PAM_AUTH_ERR instead of PAM_BUF_ERR on calloc OOM. Performance: replace CPU-side Gaussian blur (image crate) with GPU blur via GskBlurNode + GskRenderer::render_texture(). Eliminates 500ms-2s main-thread blocking on cold cache for 4K wallpapers. Remove image and dirs dependencies (~15 transitive crates). Make fingerprint stop() fire-and-forget async to avoid 6s UI block after successful auth. --- CLAUDE.md | 6 +- Cargo.lock | 219 +-------------------------------------- Cargo.toml | 5 +- DECISIONS.md | 9 +- src/auth.rs | 5 +- src/fingerprint.rs | 26 ++--- src/lockscreen.rs | 250 +++++++++------------------------------------ src/main.rs | 14 ++- 8 files changed, 87 insertions(+), 447 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 38e1f79..26318c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,12 +38,12 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonlock ## Architektur - `auth.rs` — PAM-Authentifizierung via Raw FFI (unsafe extern "C" conv callback, msg_style-aware, Zeroizing>) -- `fingerprint.rs` — fprintd D-Bus Listener, async init/claim/verify via gio futures, sync stop with 3s timeout, on_exhausted callback after MAX_FP_ATTEMPTS +- `fingerprint.rs` — fprintd D-Bus Listener, async init/claim/verify via gio futures, async fire-and-forget stop, on_exhausted callback after MAX_FP_ATTEMPTS - `users.rs` — Aktuellen User via nix getuid, Avatar-Loading mit Symlink-Rejection - `power.rs` — Reboot/Shutdown via /usr/bin/systemctl - `i18n.rs` — Locale-Erkennung (OnceLock-cached) und String-Tabellen (DE/EN), faillock_warning mit konfigurierbarem max_attempts -- `config.rs` — TOML-Config (background_path, fingerprint_enabled als Option) + Wallpaper-Fallback -- `lockscreen.rs` — GTK4 UI via LockscreenHandles, PAM-Auth via gio::spawn_blocking, FP-Label/Start separat verdrahtet, Zeroizing für Passwort, Power-Confirm +- `config.rs` — TOML-Config (background_path, background_blur, fingerprint_enabled als Option) + Wallpaper-Fallback +- `lockscreen.rs` — GTK4 UI via LockscreenHandles, PAM-Auth via gio::spawn_blocking, FP-Label/Start separat verdrahtet, Zeroizing für Passwort, Power-Confirm, GPU-Blur via GskBlurNode - `main.rs` — Entry Point, Panic-Hook, Root-Check, ext-session-lock-v1 (Pflicht in Release), Multi-Monitor, systemd-Journal-Logging, async fprintd-Init nach window.present() ## Sicherheit diff --git a/Cargo.lock b/Cargo.lock index 77c25e8..8dc56d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "anyhow" version = "1.0.102" @@ -26,18 +20,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - [[package]] name = "cairo-rs" version = "0.22.0" @@ -83,36 +65,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -135,15 +87,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - [[package]] name = "field-offset" version = "0.3.6" @@ -154,16 +97,6 @@ dependencies = [ "rustc_version", ] -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - [[package]] name = "foldhash" version = "0.1.5" @@ -290,17 +223,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - [[package]] name = "getrandom" version = "0.4.2" @@ -588,21 +510,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "moxcms", - "num-traits", - "png", - "zune-core", - "zune-jpeg", -] - [[package]] name = "indexmap" version = "2.13.0" @@ -639,15 +546,6 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" -[[package]] -name = "libredox" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" -dependencies = [ - "libc", -] - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -675,29 +573,18 @@ dependencies = [ "autocfg", ] -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "moonlock" version = "0.5.0" dependencies = [ - "dirs", "gdk-pixbuf", "gdk4", "gio", "glib", "glib-build-tools", + "graphene-rs", "gtk4", "gtk4-session-lock", - "image", "libc", "log", "nix", @@ -708,16 +595,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - [[package]] name = "nix" version = "0.29.0" @@ -730,27 +607,12 @@ dependencies = [ "libc", ] -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "pango" version = "0.22.0" @@ -787,19 +649,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -828,12 +677,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "pxfm" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" - [[package]] name = "quote" version = "1.0.45" @@ -849,17 +692,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror", -] - [[package]] name = "rustc_version" version = "0.4.1" @@ -949,12 +781,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - [[package]] name = "slab" version = "0.4.12" @@ -1014,32 +840,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom", "once_cell", "rustix", "windows-sys", ] -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "toml" version = "0.8.23" @@ -1159,12 +965,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -1369,18 +1169,3 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-jpeg" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" -dependencies = [ - "zune-core", -] diff --git a/Cargo.toml b/Cargo.toml index 0f9caae..6723937 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "moonlock" -version = "0.5.0" +version = "0.5.1" edition = "2024" description = "A secure Wayland lockscreen with GTK4, PAM and fingerprint support" license = "MIT" @@ -14,11 +14,10 @@ gdk-pixbuf = "0.22" gio = "0.22" toml = "0.8" serde = { version = "1", features = ["derive"] } -dirs = "6" +graphene-rs = { version = "0.22", package = "graphene-rs" } nix = { version = "0.29", features = ["user"] } zeroize = { version = "1", features = ["derive"] } libc = "0.2" -image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } log = "0.4" systemd-journal-logger = "2.2" diff --git a/DECISIONS.md b/DECISIONS.md index 3dd8b8e..d7d5b60 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2,7 +2,14 @@ Architectural and design decisions for Moonlock, in reverse chronological order. -## 2026-03-28 – Optional background blur via `image` crate +## 2026-03-28 – GPU blur via GskBlurNode replaces CPU blur + +- **Who**: Nyx, Dom +- **Why**: CPU-side Gaussian blur (`image` crate) blocked the GTK main thread for 500ms–2s on 4K wallpapers at cold cache. Disk cache mitigated repeat starts but added ~100 lines of complexity. +- **Tradeoffs**: GPU blur quality is slightly different (box-blur approximation vs true Gaussian), acceptable for wallpaper. Removes `image` and `dirs` dependencies entirely. No disk cache needed. +- **How**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` on `connect_realize`. Blur happens once on the GPU when the widget gets its renderer, producing a concrete `gdk::Texture`. Zero startup latency. + +## 2026-03-28 – Optional background blur via `image` crate (superseded) - **Who**: Nyx, Dom - **Why**: Consistent with moonset/moongreet — blurred wallpaper as lockscreen background is a common UX pattern diff --git a/src/auth.rs b/src/auth.rs index a3e927e..960b9f3 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -8,6 +8,7 @@ use zeroize::Zeroizing; // PAM return codes const PAM_SUCCESS: i32 = 0; const PAM_BUF_ERR: i32 = 5; +const PAM_AUTH_ERR: i32 = 7; // PAM message styles const PAM_PROMPT_ECHO_OFF: libc::c_int = 1; @@ -73,7 +74,7 @@ unsafe extern "C" fn pam_conv_callback( // Safety: appdata_ptr was set to a valid *const CString in authenticate() let password = appdata_ptr as *const CString; if password.is_null() { - return 7; // PAM_AUTH_ERR + return PAM_AUTH_ERR; } // Safety: calloc returns zeroed memory for num_msg PamResponse structs. @@ -84,7 +85,7 @@ unsafe extern "C" fn pam_conv_callback( ) as *mut PamResponse; if resp_array.is_null() { - return 7; // PAM_AUTH_ERR + return PAM_BUF_ERR; } for i in 0..num_msg as isize { diff --git a/src/fingerprint.rs b/src/fingerprint.rs index c73847f..d494e4b 100644 --- a/src/fingerprint.rs +++ b/src/fingerprint.rs @@ -290,7 +290,8 @@ impl FingerprintListener { } /// Stop listening and release the device. - /// Uses a short timeout (3s) to avoid blocking the UI indefinitely. + /// Signal disconnect is synchronous to prevent further callbacks. + /// D-Bus cleanup (VerifyStop + Release) is fire-and-forget to avoid blocking the UI. pub fn stop(&mut self) { if !self.running { return; @@ -301,20 +302,15 @@ impl FingerprintListener { if let Some(id) = self.signal_id.take() { proxy.disconnect(id); } - let _ = proxy.call_sync( - "VerifyStop", - None, - gio::DBusCallFlags::NONE, - 3000, - gio::Cancellable::NONE, - ); - let _ = proxy.call_sync( - "Release", - None, - gio::DBusCallFlags::NONE, - 3000, - gio::Cancellable::NONE, - ); + let proxy = proxy.clone(); + glib::spawn_future_local(async move { + let _ = proxy + .call_future("VerifyStop", None, gio::DBusCallFlags::NONE, 3000) + .await; + let _ = proxy + .call_future("Release", None, gio::DBusCallFlags::NONE, 3000) + .await; + }); } } diff --git a/src/lockscreen.rs b/src/lockscreen.rs index 5d9f0ba..6741b9c 100644 --- a/src/lockscreen.rs +++ b/src/lockscreen.rs @@ -4,16 +4,12 @@ use gdk4 as gdk; use gdk_pixbuf::Pixbuf; use glib::clone; +use graphene_rs as graphene; use gtk4::prelude::*; use gtk4::{self as gtk, gio}; -use image::imageops; use std::cell::RefCell; -use std::fs; -use std::io::Write; -use std::os::unix::fs::OpenOptionsExt; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::rc::Rc; -use std::time::SystemTime; use zeroize::Zeroizing; @@ -47,7 +43,7 @@ struct LockscreenState { /// Fingerprint is not initialized here — use `wire_fingerprint()` after async init. pub fn create_lockscreen_window( bg_texture: &gdk::Texture, - _config: &Config, + config: &Config, app: >k::Application, unlock_callback: Rc, ) -> LockscreenHandles { @@ -87,7 +83,7 @@ pub fn create_lockscreen_window( window.set_child(Some(&overlay)); // Background wallpaper - let background = create_background_picture(bg_texture); + let background = create_background_picture(bg_texture, config.background_blur); overlay.set_child(Some(&background)); // Centered vertical box @@ -419,11 +415,11 @@ pub fn start_fingerprint( } /// Load the wallpaper as a texture once, for sharing across all windows. -/// When `blur_radius` is `Some(sigma)` with sigma > 0, a Gaussian blur is applied. -pub fn load_background_texture(bg_path: &Path, blur_radius: Option) -> gdk::Texture { +/// Blur is applied at render time via GPU (GskBlurNode), not here. +pub fn load_background_texture(bg_path: &Path) -> gdk::Texture { let fallback = "/dev/moonarch/moonlock/wallpaper.jpg"; - let texture = if bg_path.starts_with("/dev/moonarch/moonlock") { + if bg_path.starts_with("/dev/moonarch/moonlock") { let resource_path = bg_path.to_str().unwrap_or(fallback); gdk::Texture::from_resource(resource_path) } else { @@ -431,152 +427,56 @@ pub fn load_background_texture(bg_path: &Path, blur_radius: Option) -> gdk: gdk::Texture::from_file(&file).unwrap_or_else(|_| { gdk::Texture::from_resource(fallback) }) - }; - - match blur_radius { - Some(sigma) if sigma > 0.0 => load_blurred_with_cache(bg_path, &texture, sigma), - _ => texture, } } -// -- Blur cache ---------------------------------------------------------------- - -const CACHE_PNG: &str = "blur-cache.png"; -const CACHE_META: &str = "blur-cache.meta"; - -fn blur_cache_dir() -> Option { - dirs::cache_dir().map(|d| d.join("moonlock")) -} - -/// Build the cache key string for the current wallpaper + sigma. -fn build_cache_meta(bg_path: &Path, sigma: f32) -> Option { - if bg_path.starts_with("/dev/moonarch/") { - let binary = std::env::current_exe().ok()?; - let binary_mtime = fs::metadata(&binary) - .ok()? - .modified() - .ok()? - .duration_since(SystemTime::UNIX_EPOCH) - .ok()? - .as_secs(); - Some(format!( - "path={}\nbinary_mtime={}\nsigma={}\n", - bg_path.display(), binary_mtime, sigma, - )) - } else { - let meta = fs::metadata(bg_path).ok()?; - let mtime = meta - .modified() - .ok()? - .duration_since(SystemTime::UNIX_EPOCH) - .ok()? - .as_secs(); - Some(format!( - "path={}\nsize={}\nmtime={}\nsigma={}\n", - bg_path.display(), meta.len(), mtime, sigma, - )) - } -} - -/// Try to load a cached blurred texture if the cache key matches. -fn load_cached_blur(cache_dir: &Path, expected_meta: &str) -> Option { - let stored_meta = fs::read_to_string(cache_dir.join(CACHE_META)).ok()?; - if stored_meta != expected_meta { - log::debug!("Blur cache meta mismatch, will re-blur"); - return None; - } - let file = gio::File::for_path(cache_dir.join(CACHE_PNG)); - match gdk::Texture::from_file(&file) { - Ok(texture) => { - log::debug!("Loaded blurred wallpaper from cache"); - Some(texture) - } - Err(e) => { - log::debug!("Failed to load cached blur PNG: {e}"); - None - } - } -} - -/// Save a blurred texture to the cache directory. -fn save_blur_cache(cache_dir: &Path, texture: &gdk::Texture, meta: &str) { - if let Err(e) = save_blur_cache_inner(cache_dir, texture, meta) { - log::debug!("Failed to save blur cache: {e}"); - } -} - -fn save_blur_cache_inner( - cache_dir: &Path, - texture: &gdk::Texture, - meta: &str, -) -> Result<(), Box> { - fs::create_dir_all(cache_dir)?; - - let png_bytes = texture.save_to_png_bytes(); - - let mut f = fs::OpenOptions::new() - .create(true).write(true).truncate(true).mode(0o600) - .open(cache_dir.join(CACHE_PNG))?; - f.write_all(&png_bytes)?; - - let mut f = fs::OpenOptions::new() - .create(true).write(true).truncate(true).mode(0o600) - .open(cache_dir.join(CACHE_META))?; - f.write_all(meta.as_bytes())?; - - log::debug!("Saved blur cache to {}", cache_dir.display()); - Ok(()) -} - -/// Load blurred texture, using disk cache when available. -fn load_blurred_with_cache(bg_path: &Path, texture: &gdk::Texture, sigma: f32) -> gdk::Texture { - if let Some(cache_dir) = blur_cache_dir() { - if let Some(meta) = build_cache_meta(bg_path, sigma) { - if let Some(cached) = load_cached_blur(&cache_dir, &meta) { - return cached; - } - let blurred = apply_blur(texture, sigma); - save_blur_cache(&cache_dir, &blurred, &meta); - return blurred; - } - } - apply_blur(texture, sigma) -} - -// -- Blur implementation ------------------------------------------------------- - -/// Apply Gaussian blur to a texture and return a blurred texture. -fn apply_blur(texture: &gdk::Texture, sigma: f32) -> gdk::Texture { - let width = texture.width() as u32; - let height = texture.height() as u32; - let stride = width as usize * 4; - let mut pixel_data = vec![0u8; stride * height as usize]; - texture.download(&mut pixel_data, stride); - - let img = image::RgbaImage::from_raw(width, height, pixel_data) - .expect("pixel buffer size matches texture dimensions"); - let blurred = imageops::blur(&image::DynamicImage::ImageRgba8(img), sigma); - - let bytes = glib::Bytes::from(blurred.as_raw()); - let mem_texture = gdk::MemoryTexture::new( - width as i32, - height as i32, - gdk::MemoryFormat::B8g8r8a8Premultiplied, - &bytes, - stride, - ); - mem_texture.upcast() -} - -/// Create a Picture widget for the wallpaper background from a shared texture. -fn create_background_picture(texture: &gdk::Texture) -> gtk::Picture { +/// Create a Picture widget for the wallpaper background. +/// When `blur_radius` is `Some(sigma)` with sigma > 0, blur is applied via GPU +/// (GskBlurNode). The blur is rendered to a concrete texture on `realize` (when +/// the GPU renderer is available), avoiding lazy-render artifacts. +fn create_background_picture(texture: &gdk::Texture, blur_radius: Option) -> gtk::Picture { let background = gtk::Picture::for_paintable(texture); background.set_content_fit(gtk::ContentFit::Cover); background.set_hexpand(true); background.set_vexpand(true); + + if let Some(sigma) = blur_radius { + if sigma > 0.0 { + let texture = texture.clone(); + background.connect_realize(move |picture| { + if let Some(blurred) = render_blurred_texture(picture, &texture, sigma) { + picture.set_paintable(Some(&blurred)); + } + }); + } + } + background } +/// Render a blurred texture using the widget's GPU renderer. +/// Returns None if the renderer is not available. +fn render_blurred_texture( + widget: &impl IsA, + texture: &gdk::Texture, + sigma: f32, +) -> Option { + let native = widget.native()?; + let renderer = native.renderer()?; + let snapshot = gtk::Snapshot::new(); + let bounds = graphene::Rect::new( + 0.0, + 0.0, + texture.width() as f32, + texture.height() as f32, + ); + snapshot.push_blur(sigma as f64); + snapshot.append_texture(texture, &bounds); + snapshot.pop(); + let node = snapshot.to_node()?; + Some(renderer.render_texture(&node, None)) +} + /// Load an image file and set it as the avatar. fn set_avatar_from_file(image: >k::Image, path: &Path) { match Pixbuf::from_file_at_scale(path.to_str().unwrap_or(""), AVATAR_SIZE, AVATAR_SIZE, true) { @@ -727,60 +627,4 @@ mod tests { fn avatar_size_matches_css() { assert_eq!(AVATAR_SIZE, 128); } - - // -- Blur cache tests -- - - #[test] - fn build_cache_meta_for_file() { - let dir = tempfile::tempdir().unwrap(); - let file = dir.path().join("wallpaper.jpg"); - fs::write(&file, b"fake image").unwrap(); - let meta = build_cache_meta(&file, 20.0); - assert!(meta.is_some()); - let meta = meta.unwrap(); - assert!(meta.contains("path=")); - assert!(meta.contains("size=10")); - assert!(meta.contains("sigma=20")); - } - - #[test] - fn build_cache_meta_for_gresource() { - let path = Path::new("/dev/moonarch/moonlock/wallpaper.jpg"); - let meta = build_cache_meta(path, 15.0); - assert!(meta.is_some()); - let meta = meta.unwrap(); - assert!(meta.contains("binary_mtime=")); - assert!(meta.contains("sigma=15")); - assert!(!meta.contains("size=")); - } - - #[test] - fn build_cache_meta_missing_file() { - let meta = build_cache_meta(Path::new("/nonexistent/wallpaper.jpg"), 20.0); - assert!(meta.is_none()); - } - - #[test] - fn cache_meta_mismatch_returns_none() { - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join(CACHE_META), - "path=/old.jpg\nsize=100\nmtime=1\nsigma=20\n", - ).unwrap(); - let result = load_cached_blur( - dir.path(), - "path=/new.jpg\nsize=200\nmtime=2\nsigma=20\n", - ); - assert!(result.is_none()); - } - - #[test] - fn cache_missing_meta_returns_none() { - let dir = tempfile::tempdir().unwrap(); - let result = load_cached_blur( - dir.path(), - "path=/any.jpg\nsize=1\nmtime=1\nsigma=20\n", - ); - assert!(result.is_none()); - } } diff --git a/src/main.rs b/src/main.rs index 85019ec..fcfae7a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ use gdk4 as gdk; use gtk4::prelude::*; use gtk4::{self as gtk, gio}; use gtk4_session_lock; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use crate::fingerprint::FingerprintListener; @@ -41,7 +41,7 @@ fn activate(app: >k::Application) { let config = config::load_config(None); let bg_path = config::resolve_background_path(&config); - let bg_texture = lockscreen::load_background_texture(&bg_path, config.background_blur); + let bg_texture = lockscreen::load_background_texture(&bg_path); if gtk4_session_lock::is_supported() { activate_with_session_lock(app, &display, &bg_texture, &config); @@ -70,10 +70,18 @@ fn activate_with_session_lock( let monitors = display.monitors(); - // Shared unlock callback — unlocks session and quits + // Shared unlock callback — unlocks session and quits. + // Guard prevents double-unlock if PAM and fingerprint succeed simultaneously. let lock_clone = lock.clone(); let app_clone = app.clone(); + let already_unlocked = Rc::new(Cell::new(false)); + let au = already_unlocked.clone(); let unlock_callback: Rc = Rc::new(move || { + if au.get() { + log::debug!("Unlock already triggered, ignoring duplicate"); + return; + } + au.set(true); lock_clone.unlock(); app_clone.quit(); });