moonlock/src/lockscreen.rs
nevaforget 39d9cbb624 fix: audit fixes — RefCell across await, async avatar decode (v0.6.10)
- init_fingerprint_async: hoist username before the await so a concurrent
  connect_monitor signal (hotplug / suspend-resume) cannot cause a RefCell
  panic. Re-borrow after the await for signal wiring.
- set_avatar_from_file: decode via gio::File::read_future +
  Pixbuf::from_stream_at_scale_future so the GTK main thread stays
  responsive during monitor hotplug. Default icon shown while loading.
2026-04-24 12:34:00 +02:00

807 lines
29 KiB
Rust

// 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<dyn Fn()>,
pub username: String,
state: Rc<RefCell<LockscreenState>>,
}
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<Rc<RefCell<FingerprintListener>>>,
}
/// 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: &gtk::Application,
unlock_callback: Rc<dyn Fn()>,
blur_cache: &Rc<RefCell<Option<gdk::Texture>>>,
avatar_cache: &Rc<RefCell<Option<gdk::Texture>>>,
) -> 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<RefCell<Option<gtk::Box>>> = 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<RefCell<FingerprintListener>>,
) {
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<RefCell<FingerprintListener>>,
) {
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<gdk::Texture> {
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<f32>,
blur_cache: &Rc<RefCell<Option<gdk::Texture>>>,
) -> 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<gtk::Widget>,
texture: &gdk::Texture,
sigma: f32,
) -> Option<gdk::Texture> {
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: &gtk::Image,
path: &Path,
cache: &Rc<RefCell<Option<gdk::Texture>>>,
) {
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: &gtk::Image,
window: &gtk::ApplicationWindow,
cache: &Rc<RefCell<Option<gdk::Texture>>>,
) {
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: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::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: &gtk::Box, confirm_box: &Rc<RefCell<Option<gtk::Box>>>) {
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: &gtk::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);
}
}