PAM conv callback: check msg_style (password only for ECHO_OFF), handle strdup OOM with proper cleanup, null-check PAM handle. Fingerprint: self-wire D-Bus g-signal in start() via Rc<RefCell<>> and connect_local — VerifyStatus signals are now actually dispatched. VerifyStop before VerifyStart in restart_verify. Lockscreen: password entry stays active after faillock threshold (PAM decides lockout, not UI), use Zeroizing<String> from GTK entry. Release builds exit(1) without ext-session-lock-v1 support. Config: fingerprint_enabled as Option<bool> so empty user config does not override system config. Dead code: remove unused i18n strings and fingerprint accessors, parameterize faillock_warning max_attempts.
540 lines
17 KiB
Rust
540 lines
17 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 gtk4::prelude::*;
|
|
use gtk4::{self as gtk, gio};
|
|
use std::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;
|
|
|
|
const AVATAR_SIZE: i32 = 128;
|
|
const FAILLOCK_MAX_ATTEMPTS: u32 = 3;
|
|
|
|
/// Shared mutable state for the lockscreen.
|
|
struct LockscreenState {
|
|
failed_attempts: u32,
|
|
fp_listener: FingerprintListener,
|
|
fp_listener_rc: Option<Rc<RefCell<FingerprintListener>>>,
|
|
}
|
|
|
|
/// Create a lockscreen window for a single monitor.
|
|
pub fn create_lockscreen_window(
|
|
bg_path: &Path,
|
|
config: &Config,
|
|
app: >k::Application,
|
|
unlock_callback: Rc<dyn Fn()>,
|
|
) -> gtk::ApplicationWindow {
|
|
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");
|
|
return window;
|
|
}
|
|
};
|
|
|
|
let fp_listener = FingerprintListener::new();
|
|
let fp_available = config.fingerprint_enabled
|
|
&& fp_listener.is_available(&user.username);
|
|
|
|
let state = Rc::new(RefCell::new(LockscreenState {
|
|
failed_attempts: 0,
|
|
fp_listener,
|
|
fp_listener_rc: None,
|
|
}));
|
|
|
|
// Root overlay for background + centered content
|
|
let overlay = gtk::Overlay::new();
|
|
window.set_child(Some(&overlay));
|
|
|
|
// Background wallpaper
|
|
let background = create_background_picture(bg_path);
|
|
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
|
|
let avatar_path = users::get_avatar_path(&user.home, &user.username);
|
|
if let Some(path) = avatar_path {
|
|
set_avatar_from_file(&avatar_image, &path);
|
|
} else {
|
|
set_default_avatar(&avatar_image, &window);
|
|
}
|
|
|
|
// 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
|
|
let fp_label = gtk::Label::new(None);
|
|
fp_label.add_css_class("fingerprint-label");
|
|
if fp_available {
|
|
fp_label.set_text(strings.fingerprint_prompt);
|
|
fp_label.set_visible(true);
|
|
} else {
|
|
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
|
|
let username = user.username.clone();
|
|
password_entry.connect_activate(clone!(
|
|
#[strong]
|
|
state,
|
|
#[strong]
|
|
unlock_callback,
|
|
#[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();
|
|
|
|
glib::spawn_future_local(clone!(
|
|
#[strong]
|
|
state,
|
|
#[weak]
|
|
error_label,
|
|
#[weak]
|
|
password_entry,
|
|
async move {
|
|
let user = username.clone();
|
|
let pass = Zeroizing::new((*password).clone());
|
|
let result = gio::spawn_blocking(move || {
|
|
auth::authenticate(&user, &pass)
|
|
}).await;
|
|
|
|
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);
|
|
|
|
// Start fingerprint listener
|
|
if fp_available {
|
|
let unlock_cb_fp = unlock_callback.clone();
|
|
let fp_label_success = fp_label.clone();
|
|
let fp_label_fail = fp_label.clone();
|
|
|
|
let on_success = move || {
|
|
let label = fp_label_success.clone();
|
|
let cb = unlock_cb_fp.clone();
|
|
glib::idle_add_local_once(move || {
|
|
label.set_text(load_strings(None).fingerprint_success);
|
|
label.add_css_class("success");
|
|
cb();
|
|
});
|
|
};
|
|
|
|
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");
|
|
}
|
|
),
|
|
);
|
|
}
|
|
));
|
|
};
|
|
|
|
// Extract the fp_listener into its own Rc<RefCell<>> for signal self-wiring
|
|
let fp_rc = {
|
|
let mut s = state.borrow_mut();
|
|
let listener = std::mem::replace(&mut s.fp_listener, FingerprintListener::new());
|
|
Rc::new(RefCell::new(listener))
|
|
};
|
|
|
|
FingerprintListener::start(&fp_rc, &user.username, on_success, on_failure);
|
|
|
|
// Store back the Rc reference for stop() on unlock
|
|
state.borrow_mut().fp_listener_rc = Some(fp_rc);
|
|
}
|
|
|
|
// 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();
|
|
});
|
|
}
|
|
));
|
|
|
|
window
|
|
}
|
|
|
|
/// Create a Picture widget for the wallpaper background.
|
|
fn create_background_picture(bg_path: &Path) -> gtk::Picture {
|
|
let background = if bg_path.starts_with("/dev/moonarch/moonlock") {
|
|
gtk::Picture::for_resource(bg_path.to_str().unwrap_or(""))
|
|
} else {
|
|
gtk::Picture::for_filename(bg_path.to_str().unwrap_or(""))
|
|
};
|
|
background.set_content_fit(gtk::ContentFit::Cover);
|
|
background.set_hexpand(true);
|
|
background.set_vexpand(true);
|
|
background
|
|
}
|
|
|
|
/// 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) {
|
|
Ok(pixbuf) => {
|
|
let texture = gdk::Texture::for_pixbuf(&pixbuf);
|
|
image.set_paintable(Some(&texture));
|
|
}
|
|
Err(_) => {
|
|
image.set_icon_name(Some("avatar-default-symbolic"));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Load the default avatar SVG from GResources, tinted with the foreground color.
|
|
fn set_default_avatar(image: >k::Image, window: >k::ApplicationWindow) {
|
|
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));
|
|
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<RefCell<Option<gtk::Box>>>,
|
|
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<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: >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);
|
|
}
|
|
}
|