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:
2026-03-27 23:09:54 +01:00
parent 7de3737a61
commit 817a9547ad
41 changed files with 3075 additions and 2264 deletions
+522
View File
@@ -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: &gtk::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: &gtk::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: &gtk::Image, window: &gtk::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: &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);
}
}