Rewrite moonlock from Python to Rust (v0.4.0)
Complete rewrite of the Wayland lockscreen from Python/PyGObject to Rust/gtk4-rs for memory safety in security-critical PAM code and consistency with the moonset/moongreet Rust ecosystem. Modules: main, lockscreen, auth (PAM FFI), fingerprint (fprintd D-Bus), config, i18n, users, power. 37 unit tests. Security: PAM conversation callback with Zeroizing password, panic hook that never unlocks, root check, ext-session-lock-v1 compositor policy, absolute loginctl path, avatar symlink rejection.
This commit is contained in:
@@ -0,0 +1,522 @@
|
||||
// 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 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,
|
||||
}
|
||||
|
||||
/// 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 mut 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,
|
||||
}));
|
||||
|
||||
// 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 = 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 = password.clone();
|
||||
let result = gio::spawn_blocking(move || {
|
||||
auth::authenticate(&user, &pass)
|
||||
}).await;
|
||||
|
||||
match result {
|
||||
Ok(true) => {
|
||||
state.borrow_mut().fp_listener.stop();
|
||||
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 {
|
||||
error_label.set_text(strings.faillock_locked);
|
||||
error_label.set_visible(true);
|
||||
password_entry.set_sensitive(false);
|
||||
} else {
|
||||
password_entry.set_sensitive(true);
|
||||
password_entry.grab_focus();
|
||||
if let Some(warning) = faillock_warning(count, 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");
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
));
|
||||
};
|
||||
|
||||
state
|
||||
.borrow_mut()
|
||||
.fp_listener
|
||||
.start(&user.username, on_success, on_failure);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user