diff --git a/CLAUDE.md b/CLAUDE.md index 736e696..573b851 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonlock - `i18n.rs` — Locale-Erkennung (OnceLock-cached) und String-Tabellen (DE/EN), faillock_warning mit konfigurierbarem max_attempts - `config.rs` — TOML-Config (background_path, background_blur clamped [0,100], fingerprint_enabled als Option) + Wallpaper-Fallback + Symlink-Rejection via symlink_metadata + Parse-Error-Logging - `lockscreen.rs` — GTK4 UI via LockscreenHandles, PAM-Auth via gio::spawn_blocking mit 30s Timeout und Generation Counter, FP-Label/Start separat verdrahtet mit pam_acct_mgmt-Check und auto-resume, Zeroizing für Passwort, Power-Confirm, GPU-Blur via GskBlurNode (Downscale auf max 1920px), Blur/Avatar-Cache für Multi-Monitor -- `main.rs` — Entry Point, Panic-Hook (vor Logging), Root-Check, ext-session-lock-v1 (Pflicht in Release), Multi-Monitor mit shared Blur/Avatar-Caches, systemd-Journal-Logging, Debug-Level per `MOONLOCK_DEBUG` Env-Var, async fprintd-Init nach window.present(), Wallpaper-Laden nach lock() +- `main.rs` — Entry Point, Panic-Hook (vor Logging), Root-Check, ext-session-lock-v1 (Pflicht in Release), Monitor-Hotplug via `connect_monitor`-Signal (v1_2), shared Blur/Avatar-Caches in Rc, systemd-Journal-Logging, Debug-Level per `MOONLOCK_DEBUG` Env-Var, async fprintd-Init nach window.present() ## Sicherheit @@ -55,7 +55,7 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonlock - fprintd: D-Bus Signal-Sender wird gegen fprintd's unique bus name validiert (Anti-Spoofing) - Passwort: Zeroizing ab GTK-Entry-Extraktion, Zeroizing im PAM-FFI-Layer (bekannte Einschränkung: GLib-GString und strdup-Kopie in PAM werden nicht gezeroized — inhärente GTK/libc-Limitierung) - Fingerprint-Unlock: pam_acct_mgmt-Check nach verify-match erzwingt Account-Policies (Lockout, Ablauf), resume_async() startet FP bei transientem Fehler neu (mit failed_attempts-Reset und Signal-Handler-Cleanup) -- Wallpaper wird nach lock() geladen — Disk-I/O verzögert nicht die Lock-Akquisition +- Wallpaper wird vor lock() geladen — connect_monitor feuert während lock() und braucht die Textur; lokales JPEG-Laden ist schnell genug - PAM-Timeout: 30s Timeout verhindert permanentes Aussperren bei hängenden PAM-Modulen, Generation Counter verhindert Interferenz paralleler Auth-Versuche - Root-Check: Exit mit Fehler wenn als root gestartet - Faillock: UI-Warnung nach 3 Fehlversuchen, aber PAM entscheidet über Lockout (Entry bleibt aktiv) diff --git a/Cargo.toml b/Cargo.toml index 133e84b..0acd58a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "moonlock" -version = "0.6.8" +version = "0.6.9" edition = "2024" description = "A secure Wayland lockscreen with GTK4, PAM and fingerprint support" license = "MIT" [dependencies] gtk4 = { version = "0.11", features = ["v4_10"] } -gtk4-session-lock = { version = "0.4", features = ["v1_1"] } +gtk4-session-lock = { version = "0.4", features = ["v1_2"] } glib = "0.22" gdk4 = "0.11" gdk-pixbuf = "0.22" diff --git a/DECISIONS.md b/DECISIONS.md index 739be9d..0d59952 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2,6 +2,13 @@ Architectural and design decisions for Moonlock, in reverse chronological order. +## 2026-04-09 – Monitor hotplug via connect_monitor signal + +- **Who**: Nyx, Dom +- **Why**: moonlock crashed with segfault in libgtk-4.so after suspend/resume — HDMI monitor disconnect/reconnect invalidated GDK monitor objects, and the statically created windows referenced destroyed surfaces. Crash at consistent GTK4 offset (0x278 NULL dereference), 3x reproduced. +- **Tradeoffs**: Wallpaper texture now loaded before `lock()` instead of after (connect_monitor fires during lock() and needs the texture). Local JPEG loading is fast enough that the delay is negligible. Shared state moved to Rc's for the signal closure — slightly more indirection but necessary for dynamic window creation. +- **How**: (1) Bump gtk4-session-lock feature from `v1_1` to `v1_2` to enable `Instance::connect_monitor`. (2) Replace manual monitor iteration with `lock.connect_monitor()` signal handler that creates windows on demand. (3) Signal fires once per existing monitor at `lock()` and again on hotplug. (4) Windows auto-unmap when their monitor disappears (ext-session-lock-v1 guarantee). (5) Fingerprint listener published to shared Rc so hotplugged monitors get FP labels. + ## 2026-03-31 – Fourth audit: peek icon, blur limit, GResource compression, sync markers - **Who**: Ragnar, Dom diff --git a/README.md b/README.md index e3101fb..07088e4 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,13 @@ Part of the Moonarch ecosystem. - **ext-session-lock-v1** — Protocol-guaranteed screen locking (compositor keeps screen locked on crash, `exit(1)` in release if unsupported) - **PAM authentication** — Uses system PAM stack (`/etc/pam.d/moonlock`) with 30s timeout and generation counter - **Fingerprint unlock** — fprintd D-Bus integration with sender validation, async init (window appears instantly), `pam_acct_mgmt` check after verify, auto-resume on transient errors -- **Multi-monitor** — Lockscreen on every monitor with shared blur and avatar caches +- **Multi-monitor + hotplug** — Lockscreen on every monitor with shared blur and avatar caches; monitors added after suspend/resume get windows automatically via `connect_monitor` signal - **GPU blur** — Background blur via GskBlurNode (downscale to max 1920px, configurable 0–100) - **i18n** — German and English (auto-detected) - **Faillock warning** — Progressive UI warning after failed attempts, PAM decides lockout - **Panic safety** — Panic hook logs but never unlocks (installed before logging) - **Password wiping** — `Zeroize` on drop from GTK entry through PAM FFI layer - **Journal logging** — `journalctl -t moonlock`, debug level via `MOONLOCK_DEBUG` env var -- **Lock-first architecture** — Wallpaper loaded after `lock()` so disk I/O never delays lock acquisition ## Requirements diff --git a/src/main.rs b/src/main.rs index f50dade..adea4c2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,17 +59,18 @@ fn activate(app: >k::Application) { fn activate_with_session_lock( app: >k::Application, - display: &gdk::Display, + _display: &gdk::Display, config: &config::Config, ) { let lock = gtk4_session_lock::Instance::new(); - lock.lock(); - // Load wallpaper AFTER lock — disk I/O must not delay the lock acquisition - let bg_texture = config::resolve_background_path(config) - .and_then(|path| lockscreen::load_background_texture(&path)); - - let monitors = display.monitors(); + // Load wallpaper before lock — connect_monitor fires during lock() and needs the + // texture. This means disk I/O happens before locking, but loading a local JPEG + // is fast enough that the delay is negligible. + let bg_texture: Rc> = Rc::new( + config::resolve_background_path(config) + .and_then(|path| lockscreen::load_background_texture(&path)), + ); // Shared unlock callback — unlocks session and quits. // Guard prevents double-unlock if PAM and fingerprint succeed simultaneously. @@ -91,50 +92,89 @@ fn activate_with_session_lock( let blur_cache: Rc>> = Rc::new(RefCell::new(None)); let avatar_cache: Rc>> = Rc::new(RefCell::new(None)); - // Create all monitor windows immediately — no D-Bus calls here - let mut all_handles = Vec::new(); - let mut created_any = false; - for i in 0..monitors.n_items() { - if let Some(monitor) = monitors - .item(i) - .and_then(|obj| obj.downcast::().ok()) - { + // Shared config for use in the monitor signal handler + let config = Rc::new(config.clone()); + + // Shared handles list — populated by connect_monitor, read by fingerprint init + let all_handles: Rc>> = + Rc::new(RefCell::new(Vec::new())); + + // Shared fingerprint listener — None until async init completes. + // The monitor handler checks this to wire up FP labels on hotplugged monitors. + let shared_fp: Rc>>>> = + Rc::new(RefCell::new(None)); + + // The ::monitor signal fires once per existing monitor at lock(), and again + // whenever a monitor is hotplugged (e.g. after suspend/resume). This replaces + // the old manual monitor iteration and handles hotplug automatically. + let lock_for_signal = lock.clone(); + lock.connect_monitor(glib::clone!( + #[strong] + app, + #[strong] + config, + #[strong] + bg_texture, + #[strong] + unlock_callback, + #[strong] + blur_cache, + #[strong] + avatar_cache, + #[strong] + all_handles, + #[strong] + shared_fp, + move |_instance, monitor| { + log::debug!("Monitor signal: creating lockscreen window"); let handles = lockscreen::create_lockscreen_window( - bg_texture.as_ref(), - config, - app, + bg_texture.as_ref().as_ref(), + &config, + &app, unlock_callback.clone(), &blur_cache, &avatar_cache, ); - lock.assign_window_to_monitor(&handles.window, &monitor); + lock_for_signal.assign_window_to_monitor(&handles.window, monitor); handles.window.present(); - all_handles.push(handles); - created_any = true; - } - } - if !created_any { - log::error!("No lockscreen windows created — screen stays locked (compositor policy)"); - std::process::exit(1); - } + // If fingerprint is already initialized, wire up the label + if let Some(ref fp_rc) = *shared_fp.borrow() { + lockscreen::show_fingerprint_label(&handles, fp_rc); + } + + all_handles.borrow_mut().push(handles); + } + )); + + lock.lock(); // Async fprintd initialization — runs after windows are visible if config.fingerprint_enabled { - init_fingerprint_async(all_handles); + init_fingerprint_async(all_handles, shared_fp); } } /// Initialize fprintd asynchronously after windows are visible. /// Uses a single FingerprintListener shared across all monitors — -/// only the first monitor's handles get the fingerprint UI wired up. -fn init_fingerprint_async(all_handles: Vec) { +/// only the first monitor's handles get the fingerprint verification wired up. +/// The `shared_fp` is set after init so that the connect_monitor handler can +/// wire up FP labels on monitors that appear after initialization. +fn init_fingerprint_async( + all_handles: Rc>>, + shared_fp: Rc>>>>, +) { glib::spawn_future_local(async move { let mut listener = FingerprintListener::new(); listener.init_async().await; + let handles = all_handles.borrow(); + if handles.is_empty() { + return; + } + // Use the first monitor's username to check enrollment - let username = &all_handles[0].username; + let username = &handles[0].username; if username.is_empty() { return; } @@ -146,13 +186,16 @@ fn init_fingerprint_async(all_handles: Vec) { let fp_rc = Rc::new(RefCell::new(listener)); - // Show fingerprint label on all monitors - for handles in &all_handles { - lockscreen::show_fingerprint_label(handles, &fp_rc); + // Show fingerprint label on all existing monitors + for h in handles.iter() { + lockscreen::show_fingerprint_label(h, &fp_rc); } // Start verification listener on the first monitor only - lockscreen::start_fingerprint(&all_handles[0], &fp_rc); + lockscreen::start_fingerprint(&handles[0], &fp_rc); + + // Publish the listener so hotplugged monitors get FP labels too + *shared_fp.borrow_mut() = Some(fp_rc); }); } @@ -184,7 +227,9 @@ fn activate_without_lock( // Async fprintd initialization for development mode if config.fingerprint_enabled { - init_fingerprint_async(vec![handles]); + let all_handles = Rc::new(RefCell::new(vec![handles])); + let shared_fp = Rc::new(RefCell::new(None)); + init_fingerprint_async(all_handles, shared_fp); } }