// ABOUTME: GTK4 lockscreen UI — avatar, password entry, fingerprint indicator, power buttons. // ABOUTME: Connects PAM auth and fprintd listener, handles unlock flow. 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 std::cell::{Cell, RefCell}; use std::path::Path; use std::rc::Rc; use zeroize::Zeroizing; use crate::auth; use crate::config::Config; use crate::fingerprint::FingerprintListener; use crate::i18n::{faillock_warning, load_strings, Strings}; use crate::power::{self, PowerError}; use crate::users; /// Handles returned from create_lockscreen_window for post-creation wiring. pub struct LockscreenHandles { pub window: gtk::ApplicationWindow, pub fp_label: gtk::Label, pub password_entry: gtk::PasswordEntry, pub unlock_callback: Rc, pub username: String, state: Rc>, } const AVATAR_SIZE: i32 = 128; const FAILLOCK_MAX_ATTEMPTS: u32 = 3; const PAM_TIMEOUT_SECS: u64 = 30; /// Shared mutable state for the lockscreen. struct LockscreenState { failed_attempts: u32, fp_listener_rc: Option>>, } /// Create a lockscreen window for a single monitor. /// Fingerprint is not initialized here — use `wire_fingerprint()` after async init. /// The `blur_cache` and `avatar_cache` are shared across monitors for multi-monitor /// setups, avoiding redundant GPU renders and SVG rasterizations. pub fn create_lockscreen_window( bg_texture: Option<&gdk::Texture>, config: &Config, app: >k::Application, unlock_callback: Rc, blur_cache: &Rc>>, avatar_cache: &Rc>>, ) -> LockscreenHandles { let window = gtk::ApplicationWindow::builder() .application(app) .build(); window.add_css_class("lockscreen"); let strings = load_strings(None); let user = match users::get_current_user() { Some(u) => u, None => { log::error!("Failed to get current user"); let fp_label = gtk::Label::new(None); fp_label.set_visible(false); return LockscreenHandles { window, fp_label, password_entry: gtk::PasswordEntry::new(), unlock_callback, username: String::new(), state: Rc::new(RefCell::new(LockscreenState { failed_attempts: 0, fp_listener_rc: None, })), }; } }; let state = Rc::new(RefCell::new(LockscreenState { failed_attempts: 0, fp_listener_rc: None, })); // Root overlay for background + centered content let overlay = gtk::Overlay::new(); window.set_child(Some(&overlay)); // Background wallpaper (if available — otherwise GTK background color shows through) if let Some(texture) = bg_texture { let background = create_background_picture(texture, config.background_blur, blur_cache); overlay.set_child(Some(&background)); } // Centered vertical box let main_box = gtk::Box::new(gtk::Orientation::Vertical, 0); main_box.set_halign(gtk::Align::Center); main_box.set_valign(gtk::Align::Center); overlay.add_overlay(&main_box); // Login box let login_box = gtk::Box::new(gtk::Orientation::Vertical, 8); login_box.set_halign(gtk::Align::Center); login_box.add_css_class("login-box"); main_box.append(&login_box); // Avatar let avatar_frame = gtk::Box::new(gtk::Orientation::Horizontal, 0); avatar_frame.set_size_request(AVATAR_SIZE, AVATAR_SIZE); avatar_frame.set_halign(gtk::Align::Center); avatar_frame.set_overflow(gtk::Overflow::Hidden); avatar_frame.add_css_class("avatar"); let avatar_image = gtk::Image::new(); avatar_image.set_pixel_size(AVATAR_SIZE); avatar_frame.append(&avatar_image); login_box.append(&avatar_frame); // Load avatar — use shared cache to avoid redundant loading on multi-monitor setups. // The cache is populated by the first monitor and reused by subsequent ones. if let Some(ref cached) = *avatar_cache.borrow() { avatar_image.set_paintable(Some(cached)); } else { let avatar_path = users::get_avatar_path(&user.home, &user.username); if let Some(path) = avatar_path { set_avatar_from_file(&avatar_image, &path, avatar_cache); } else { set_default_avatar(&avatar_image, &window, avatar_cache); } } // Username label let username_label = gtk::Label::new(Some(&user.display_name)); username_label.add_css_class("username-label"); login_box.append(&username_label); // Password entry let password_entry = gtk::PasswordEntry::builder() .placeholder_text(strings.password_placeholder) .show_peek_icon(true) .hexpand(true) .build(); password_entry.add_css_class("password-entry"); login_box.append(&password_entry); // Error label let error_label = gtk::Label::new(None); error_label.add_css_class("error-label"); error_label.set_visible(false); login_box.append(&error_label); // Fingerprint label — hidden until async fprintd init completes let fp_label = gtk::Label::new(None); fp_label.add_css_class("fingerprint-label"); fp_label.set_visible(false); login_box.append(&fp_label); // Confirm box area (for power confirm) let confirm_area = gtk::Box::new(gtk::Orientation::Vertical, 0); confirm_area.set_halign(gtk::Align::Center); login_box.append(&confirm_area); let confirm_box: Rc>> = Rc::new(RefCell::new(None)); // Power buttons (bottom right) let power_box = gtk::Box::new(gtk::Orientation::Horizontal, 4); power_box.set_halign(gtk::Align::End); power_box.set_valign(gtk::Align::End); power_box.set_hexpand(true); power_box.set_vexpand(true); power_box.set_margin_end(16); power_box.set_margin_bottom(16); let reboot_btn = gtk::Button::new(); reboot_btn.set_icon_name("system-reboot-symbolic"); reboot_btn.add_css_class("power-button"); reboot_btn.set_tooltip_text(Some(strings.reboot_tooltip)); reboot_btn.connect_clicked(clone!( #[weak] confirm_area, #[strong] confirm_box, #[weak] error_label, move |_| { show_power_confirm( strings.reboot_confirm, power::reboot, strings.reboot_failed, strings, &confirm_area, &confirm_box, &error_label, ); } )); power_box.append(&reboot_btn); let shutdown_btn = gtk::Button::new(); shutdown_btn.set_icon_name("system-shutdown-symbolic"); shutdown_btn.add_css_class("power-button"); shutdown_btn.set_tooltip_text(Some(strings.shutdown_tooltip)); shutdown_btn.connect_clicked(clone!( #[weak] confirm_area, #[strong] confirm_box, #[weak] error_label, move |_| { show_power_confirm( strings.shutdown_confirm, power::shutdown, strings.shutdown_failed, strings, &confirm_area, &confirm_box, &error_label, ); } )); power_box.append(&shutdown_btn); overlay.add_overlay(&power_box); // Password entry "activate" handler. // A generation counter tracks which auth attempt is current. When the user // submits a new password, the generation increments — stale PAM results from // prior attempts are ignored (except success: a correct password always unlocks). let username = user.username.clone(); let auth_generation = Rc::new(Cell::new(0u32)); password_entry.connect_activate(clone!( #[strong] state, #[strong] unlock_callback, #[strong] auth_generation, #[weak] error_label, #[weak] password_entry, move |entry| { let password = Zeroizing::new(entry.text().to_string()); if password.is_empty() { return; } entry.set_sensitive(false); let username = username.clone(); let unlock_cb = unlock_callback.clone(); // Invalidate stale timeouts/results from prior attempts let auth_gen = auth_generation.get().wrapping_add(1); auth_generation.set(auth_gen); let gen_timeout = auth_generation.clone(); let gen_result = auth_generation.clone(); // If PAM hangs (e.g. broken LDAP module), the timeout re-enables the UI glib::timeout_add_local_once( std::time::Duration::from_secs(PAM_TIMEOUT_SECS), clone!( #[weak] error_label, #[weak] password_entry, move || { if gen_timeout.get() != auth_gen { return; } log::error!("PAM authentication timed out after {PAM_TIMEOUT_SECS}s"); let strings = load_strings(None); password_entry.set_text(""); password_entry.set_sensitive(true); password_entry.grab_focus(); error_label.set_text(strings.auth_timeout); error_label.set_visible(true); } ), ); glib::spawn_future_local(clone!( #[strong] state, #[weak] error_label, #[weak] password_entry, async move { let user = username.clone(); let result = gio::spawn_blocking(move || { auth::authenticate(&user, &password) }).await; // Stale result from a superseded attempt — only unlock on success // (a correct password should always unlock, regardless of timing) if gen_result.get() != auth_gen { if matches!(result, Ok(true)) { let s = state.borrow(); if let Some(ref fp_rc) = s.fp_listener_rc { fp_rc.borrow_mut().stop(); } drop(s); unlock_cb(); } return; } match result { Ok(true) => { let s = state.borrow(); if let Some(ref fp_rc) = s.fp_listener_rc { fp_rc.borrow_mut().stop(); } drop(s); unlock_cb(); } _ => { let mut s = state.borrow_mut(); s.failed_attempts += 1; let count = s.failed_attempts; let strings = load_strings(None); password_entry.set_text(""); if count >= FAILLOCK_MAX_ATTEMPTS { // Show warning but keep entry active — PAM decides lockout error_label.set_text(strings.faillock_locked); error_label.set_visible(true); password_entry.set_sensitive(true); password_entry.grab_focus(); } else { password_entry.set_sensitive(true); password_entry.grab_focus(); if let Some(warning) = faillock_warning(count, FAILLOCK_MAX_ATTEMPTS, strings) { error_label.set_text(&warning); } else { error_label.set_text(strings.wrong_password); } error_label.set_visible(true); } } } } )); } )); // Keyboard handling — Escape clears password and error let key_controller = gtk::EventControllerKey::new(); key_controller.connect_key_pressed(clone!( #[weak] password_entry, #[weak] error_label, #[upgrade_or] glib::Propagation::Proceed, move |_, keyval, _, _| { if keyval == gdk::Key::Escape { password_entry.set_text(""); error_label.set_visible(false); password_entry.grab_focus(); glib::Propagation::Stop } else { glib::Propagation::Proceed } } )); window.add_controller(key_controller); // Fade-in on map window.connect_map(|w| { glib::idle_add_local_once(clone!( #[weak] w, move || { w.add_css_class("visible"); } )); }); // Focus password entry on realize window.connect_realize(clone!( #[weak] password_entry, move |_| { glib::idle_add_local_once(move || { password_entry.grab_focus(); }); } )); LockscreenHandles { window, fp_label, password_entry: password_entry.clone(), unlock_callback, username: user.username, state: state.clone(), } } /// Show the fingerprint label and store the listener reference for stop-on-unlock. /// Does NOT start verification — call `start_fingerprint()` on one monitor for that. pub fn show_fingerprint_label( handles: &LockscreenHandles, fp_rc: &Rc>, ) { let strings = load_strings(None); handles.fp_label.set_text(strings.fingerprint_prompt); handles.fp_label.set_visible(true); // Store the Rc reference for stop() on unlock handles.state.borrow_mut().fp_listener_rc = Some(fp_rc.clone()); } /// Start fingerprint verification on a single monitor's handles. /// Wires up on_success/on_failure callbacks and calls start_async. pub fn start_fingerprint( handles: &LockscreenHandles, fp_rc: &Rc>, ) { let fp_label_success = handles.fp_label.clone(); let fp_label_fail = handles.fp_label.clone(); let unlock_cb_fp = handles.unlock_callback.clone(); let fp_rc_success = fp_rc.clone(); let fp_username = handles.username.clone(); let on_success = move || { let label = fp_label_success.clone(); let cb = unlock_cb_fp.clone(); let fp = fp_rc_success.clone(); let username = fp_username.clone(); glib::idle_add_local_once(move || { let strings = load_strings(None); label.set_text(strings.fingerprint_success); label.add_css_class("success"); // stop() is idempotent — cleanup_dbus() already ran inside on_verify_status, // but this mirrors the PAM success path for defense-in-depth. fp.borrow_mut().stop(); // Enforce PAM account policies (lockout, expiry) before unlocking. // Fingerprint auth bypasses pam_authenticate, so we must explicitly // check account restrictions via pam_acct_mgmt. glib::spawn_future_local(async move { let user = username.clone(); let result = gio::spawn_blocking(move || { auth::check_account(&user) }).await; match result { Ok(true) => cb(), _ => { log::error!("PAM account check failed after fingerprint auth"); let strings = load_strings(None); label.set_text(strings.wrong_password); label.remove_css_class("success"); label.add_css_class("failed"); // Restart FP verification after delay — the failure may be // transient (e.g. PAM module timeout). If the account is truly // locked, check_account will fail again on next match. glib::timeout_add_local_once( std::time::Duration::from_secs(2), move || { label.set_text(load_strings(None).fingerprint_prompt); label.remove_css_class("failed"); glib::spawn_future_local(async move { FingerprintListener::resume_async(&fp, &username).await; }); }, ); } } }); }); }; let on_failure = move || { let label = fp_label_fail.clone(); glib::idle_add_local_once(clone!( #[weak] label, move || { let strings = load_strings(None); label.set_text(strings.fingerprint_failed); label.add_css_class("failed"); // Reset after 2 seconds glib::timeout_add_local_once( std::time::Duration::from_secs(2), clone!( #[weak] label, move || { label.set_text(load_strings(None).fingerprint_prompt); label.remove_css_class("success"); label.remove_css_class("failed"); } ), ); } )); }; let fp_label_exhausted = handles.fp_label.clone(); let on_exhausted = move || { let label = fp_label_exhausted.clone(); glib::idle_add_local_once(move || { label.set_visible(false); }); }; let username = handles.username.clone(); let fp_rc_clone = fp_rc.clone(); glib::spawn_future_local(async move { FingerprintListener::start_async( &fp_rc_clone, &username, on_success, on_failure, on_exhausted, ).await; }); } /// Load the wallpaper as a texture once, for sharing across all windows. /// Returns None if no wallpaper path is provided or the file cannot be loaded. /// Blur is applied at render time via GPU (GskBlurNode), not here. pub fn load_background_texture(bg_path: &Path) -> Option { let file = gio::File::for_path(bg_path); match gdk::Texture::from_file(&file) { Ok(texture) => Some(texture), Err(e) => { log::warn!("Failed to load wallpaper {}: {e}", bg_path.display()); None } } } /// 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. /// The `blur_cache` is shared across monitors — the first to realize renders the /// blur, subsequent monitors reuse the cached texture. fn create_background_picture( texture: &gdk::Texture, blur_radius: Option, blur_cache: &Rc>>, ) -> 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(); let cache = blur_cache.clone(); background.connect_realize(move |picture| { if let Some(ref cached) = *cache.borrow() { picture.set_paintable(Some(cached)); return; } if let Some(blurred) = render_blurred_texture(picture, &texture, sigma) { picture.set_paintable(Some(&blurred)); *cache.borrow_mut() = Some(blurred); } }); } } background } /// Maximum texture dimension for blur input. Textures larger than this are // SYNC: MAX_BLUR_DIMENSION, render_blurred_texture, and create_background_picture // are duplicated in moongreet/src/greeter.rs and moonset/src/panel.rs. // Changes here must be mirrored to the other two projects. /// downscaled before blurring — the blur destroys detail anyway, so there is /// no visible quality loss, but GPU work is reduced significantly. const MAX_BLUR_DIMENSION: f32 = 1920.0; /// Render a blurred texture using the widget's GPU renderer. /// Returns None if the renderer is not available. /// /// To avoid edge darkening (blur samples transparent pixels outside bounds), /// the texture is rendered with padding equal to 3x the blur sigma. The blur /// is applied to the padded area, then cropped back to the original size. /// /// Large textures (> MAX_BLUR_DIMENSION) are downscaled before blurring to /// reduce GPU work. The sigma is scaled proportionally. fn render_blurred_texture( widget: &impl IsA, texture: &gdk::Texture, sigma: f32, ) -> Option { let native = widget.native()?; let renderer = native.renderer()?; let orig_w = texture.width() as f32; let orig_h = texture.height() as f32; // Downscale large textures to reduce GPU blur work let max_dim = orig_w.max(orig_h); let scale = if max_dim > MAX_BLUR_DIMENSION { MAX_BLUR_DIMENSION / max_dim } else { 1.0 }; let w = (orig_w * scale).round(); let h = (orig_h * scale).round(); let scaled_sigma = sigma * scale; // Padding must cover the blur kernel radius (typically ~3x sigma) let pad = (scaled_sigma * 3.0).ceil(); let snapshot = gtk::Snapshot::new(); // Clip output to scaled texture size snapshot.push_clip(&graphene::Rect::new(pad, pad, w, h)); snapshot.push_blur(scaled_sigma as f64); // Render texture with padding on all sides (edges repeat via oversized bounds) snapshot.append_texture(texture, &graphene::Rect::new(-pad, -pad, w + 2.0 * pad, h + 2.0 * pad)); snapshot.pop(); // blur snapshot.pop(); // clip let node = snapshot.to_node()?; let viewport = graphene::Rect::new(pad, pad, w, h); Some(renderer.render_texture(&node, Some(&viewport))) } /// Load an image file and set it as the avatar. Stores the texture in the cache. /// Decoding runs via GIO async I/O + async pixbuf stream loader so the GTK main /// loop stays responsive — avatars may be loaded inside the `connect_monitor` /// signal handler at hotplug time, which must not block. The fallback icon is /// shown immediately; the decoded texture replaces it when ready. fn set_avatar_from_file( image: >k::Image, path: &Path, cache: &Rc>>, ) { image.set_icon_name(Some("avatar-default-symbolic")); let display_path = path.to_path_buf(); let file = gio::File::for_path(path); let image_clone = image.clone(); let cache_clone = cache.clone(); glib::spawn_future_local(async move { let stream = match file.read_future(glib::Priority::default()).await { Ok(s) => s, Err(e) => { log::warn!("Failed to open avatar {}: {e}", display_path.display()); return; } }; match Pixbuf::from_stream_at_scale_future(&stream, AVATAR_SIZE, AVATAR_SIZE, true).await { Ok(pixbuf) => { let texture = gdk::Texture::for_pixbuf(&pixbuf); image_clone.set_paintable(Some(&texture)); *cache_clone.borrow_mut() = Some(texture); } Err(e) => { log::warn!("Failed to decode avatar from {}: {e}", display_path.display()); } } }); } /// Load the default avatar SVG from GResources, tinted with the foreground color. /// Stores the texture in the cache for reuse on additional monitors. fn set_default_avatar( image: >k::Image, window: >k::ApplicationWindow, cache: &Rc>>, ) { let resource_path = users::get_default_avatar_path(); if let Ok(bytes) = gio::resources_lookup_data(&resource_path, gio::ResourceLookupFlags::NONE) { let svg_text = String::from_utf8_lossy(&bytes); let rgba = window.color(); let fg_color = format!( "#{:02x}{:02x}{:02x}", (rgba.red() * 255.0) as u8, (rgba.green() * 255.0) as u8, (rgba.blue() * 255.0) as u8, ); let tinted = svg_text.replace("#PLACEHOLDER", &fg_color); let svg_bytes = tinted.as_bytes(); if let Ok(loader) = gdk_pixbuf::PixbufLoader::with_type("svg") { loader.set_size(AVATAR_SIZE, AVATAR_SIZE); if loader.write(svg_bytes).is_ok() { let _ = loader.close(); if let Some(pixbuf) = loader.pixbuf() { let texture = gdk::Texture::for_pixbuf(&pixbuf); image.set_paintable(Some(&texture)); *cache.borrow_mut() = Some(texture); return; } } } } image.set_icon_name(Some("avatar-default-symbolic")); } /// Show inline power confirmation. fn show_power_confirm( message: &'static str, action_fn: fn() -> Result<(), PowerError>, error_message: &'static str, strings: &'static Strings, confirm_area: >k::Box, confirm_box: &Rc>>, error_label: >k::Label, ) { dismiss_power_confirm(confirm_area, confirm_box); let new_box = gtk::Box::new(gtk::Orientation::Vertical, 8); new_box.set_halign(gtk::Align::Center); new_box.set_margin_top(16); let confirm_label = gtk::Label::new(Some(message)); confirm_label.add_css_class("confirm-label"); new_box.append(&confirm_label); let button_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); button_row.set_halign(gtk::Align::Center); let yes_btn = gtk::Button::with_label(strings.confirm_yes); yes_btn.add_css_class("confirm-yes"); yes_btn.connect_clicked(clone!( #[weak] confirm_area, #[strong] confirm_box, #[weak] error_label, move |_| { dismiss_power_confirm(&confirm_area, &confirm_box); execute_power_action(action_fn, error_message, &error_label); } )); button_row.append(&yes_btn); let no_btn = gtk::Button::with_label(strings.confirm_no); no_btn.add_css_class("confirm-no"); no_btn.connect_clicked(clone!( #[weak] confirm_area, #[strong] confirm_box, move |_| { dismiss_power_confirm(&confirm_area, &confirm_box); } )); button_row.append(&no_btn); new_box.append(&button_row); confirm_area.append(&new_box); *confirm_box.borrow_mut() = Some(new_box); no_btn.grab_focus(); } /// Remove the power confirmation prompt. fn dismiss_power_confirm(confirm_area: >k::Box, confirm_box: &Rc>>) { if let Some(box_widget) = confirm_box.borrow_mut().take() { confirm_area.remove(&box_widget); } } /// Execute a power action in a background thread. fn execute_power_action( action_fn: fn() -> Result<(), PowerError>, error_message: &'static str, error_label: >k::Label, ) { glib::spawn_future_local(clone!( #[weak] error_label, async move { let result = gio::spawn_blocking(move || action_fn()).await; match result { Ok(Ok(())) => {} Ok(Err(e)) => { log::error!("Power action failed: {e}"); error_label.set_text(error_message); error_label.set_visible(true); } Err(_) => { log::error!("Power action panicked"); error_label.set_text(error_message); error_label.set_visible(true); } } } )); } #[cfg(test)] mod tests { use super::*; #[test] fn faillock_threshold() { assert_eq!(FAILLOCK_MAX_ATTEMPTS, 3); } #[test] fn avatar_size_matches_css() { assert_eq!(AVATAR_SIZE, 128); } }