greetd-moongreet/src/greeter.rs
nevaforget 293bba32a6 feat: add optional background blur via image crate
Gaussian blur applied at texture load time when `background-blur` is
set in the [appearance] section of moongreet.toml. Blur runs once,
result is shared across monitors.
2026-03-28 14:53:16 +01:00

1687 lines
54 KiB
Rust

// ABOUTME: Main greeter window — builds the GTK4 UI layout for the Moongreet greeter.
// ABOUTME: Handles user selection, session choice, password entry, and power actions.
use gdk4 as gdk;
use gdk_pixbuf::Pixbuf;
use glib::clone;
use gtk4::prelude::*;
use gtk4::{self as gtk, gio};
use image::imageops;
use std::cell::RefCell;
use std::collections::HashMap;
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use crate::config::Config;
use crate::i18n::{faillock_warning, load_strings, Strings};
use crate::ipc;
use crate::power::{self, PowerError};
use crate::sessions::{self, Session};
use crate::users::{self, User};
const AVATAR_SIZE: i32 = 128;
const MAX_AVATAR_FILE_SIZE: u64 = 10 * 1024 * 1024;
const MAX_WALLPAPER_FILE_SIZE: u64 = 50 * 1024 * 1024;
const LAST_USER_PATH: &str = "/var/cache/moongreet/last-user";
const LAST_SESSION_DIR: &str = "/var/cache/moongreet/last-session";
const MAX_USERNAME_LENGTH: usize = 256;
const MAX_SESSION_NAME_LENGTH: usize = 256;
const MAX_GREETD_ERROR_LENGTH: usize = 200;
/// Split a string into shell words, respecting single and double quotes.
/// Returns None if quotes are unbalanced.
fn split_shell_words(s: &str) -> Option<Vec<String>> {
let mut words = Vec::new();
let mut current = String::new();
let mut chars = s.chars().peekable();
let mut in_single = false;
let mut in_double = false;
while let Some(c) = chars.next() {
match c {
'\'' if !in_double => {
in_single = !in_single;
}
'"' if !in_single => {
in_double = !in_double;
}
'\\' if in_double => {
// In double quotes, backslash escapes the next char
match chars.next() {
Some(next) => current.push(next),
None => return None, // Trailing backslash
}
}
'\\' if !in_single && !in_double => {
// Outside quotes, backslash escapes the next char
match chars.next() {
Some(next) => current.push(next),
None => return None, // Trailing backslash
}
}
c if c.is_whitespace() && !in_single && !in_double => {
if !current.is_empty() {
words.push(std::mem::take(&mut current));
}
}
_ => {
current.push(c);
}
}
}
if in_single || in_double {
return None;
}
if !current.is_empty() {
words.push(current);
}
Some(words)
}
/// Validate a username against safe patterns.
fn is_valid_username(name: &str) -> bool {
if name.is_empty() || name.len() > MAX_USERNAME_LENGTH {
return false;
}
let first = name.chars().next().unwrap();
if !first.is_ascii_alphanumeric() && first != '_' {
return false;
}
name.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
}
/// Load the background image as a shared texture (decode once, reuse everywhere).
/// When `blur_radius` is `Some(sigma)` with sigma > 0, a Gaussian blur is applied.
pub fn load_background_texture(bg_path: &Path, blur_radius: Option<f32>) -> Option<gdk::Texture> {
let path_str = bg_path.to_str()?;
let texture = if bg_path.starts_with("/dev/moonarch/moongreet") {
match gio::resources_lookup_data(path_str, gio::ResourceLookupFlags::NONE) {
Ok(bytes) => match gdk::Texture::from_bytes(&bytes) {
Ok(texture) => Some(texture),
Err(e) => {
log::debug!("GResource texture decode error: {e}");
log::warn!("Failed to decode background texture from GResource {path_str}");
None
}
},
Err(e) => {
log::debug!("GResource lookup error: {e}");
log::warn!("Failed to load background texture from GResource {path_str}");
None
}
}
} else {
if let Ok(meta) = std::fs::metadata(bg_path)
&& meta.len() > MAX_WALLPAPER_FILE_SIZE
{
log::warn!(
"Wallpaper file too large ({} bytes), skipping: {}",
meta.len(), bg_path.display()
);
return None;
}
match gdk::Texture::from_filename(bg_path) {
Ok(texture) => Some(texture),
Err(e) => {
log::debug!("Wallpaper load error: {e}");
log::warn!("Failed to load background texture from {}", bg_path.display());
None
}
}
}?;
match blur_radius {
Some(sigma) if sigma > 0.0 => Some(apply_blur(&texture, sigma)),
_ => Some(texture),
}
}
/// Apply Gaussian blur to a texture and return a blurred texture.
fn apply_blur(texture: &gdk::Texture, sigma: f32) -> gdk::Texture {
let width = texture.width() as u32;
let height = texture.height() as u32;
let stride = width as usize * 4;
let mut pixel_data = vec![0u8; stride * height as usize];
texture.download(&mut pixel_data, stride);
let img = image::RgbaImage::from_raw(width, height, pixel_data)
.expect("pixel buffer size matches texture dimensions");
let blurred = imageops::blur(&image::DynamicImage::ImageRgba8(img), sigma);
let bytes = glib::Bytes::from(blurred.as_raw());
let mem_texture = gdk::MemoryTexture::new(
width as i32,
height as i32,
gdk::MemoryFormat::B8g8r8a8Premultiplied,
&bytes,
stride,
);
mem_texture.upcast()
}
/// Create a wallpaper-only window for secondary monitors.
pub fn create_wallpaper_window(
texture: &gdk::Texture,
app: &gtk::Application,
) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder()
.application(app)
.build();
window.add_css_class("wallpaper");
let background = create_background_picture(texture);
window.set_child(Some(&background));
window
}
/// Create a Picture widget for the wallpaper background from a pre-loaded texture.
fn create_background_picture(texture: &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);
background
}
/// Shared mutable state for the greeter UI.
struct GreeterState {
selected_user: Option<User>,
avatar_cache: HashMap<String, gdk::Texture>,
default_avatar_texture: Option<gdk::Texture>,
failed_attempts: HashMap<String, u32>,
greetd_sock: Arc<Mutex<Option<UnixStream>>>,
login_cancelled: Arc<std::sync::atomic::AtomicBool>,
}
/// Create the main greeter window with login UI.
pub fn create_greeter_window(
texture: Option<&gdk::Texture>,
config: &Config,
app: &gtk::Application,
) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder()
.application(app)
.build();
window.add_css_class("greeter");
window.set_default_size(1920, 1080);
// Apply GTK theme from config
if let Some(ref theme_name) = config.gtk_theme {
if !theme_name.is_empty()
&& theme_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '.'))
{
if let Some(settings) = gtk::Settings::default() {
settings.set_gtk_theme_name(Some(theme_name));
}
} else {
log::warn!("Ignoring invalid GTK theme name: {theme_name}");
}
}
let strings = load_strings(None);
let all_users = users::get_users(None);
let all_sessions = sessions::get_sessions(None, None);
log::debug!("Greeter window: {} user(s), {} session(s)", all_users.len(), all_sessions.len());
if let Some(ref theme) = config.gtk_theme {
log::debug!("GTK theme: {theme}");
}
let state = Rc::new(RefCell::new(GreeterState {
selected_user: None,
avatar_cache: HashMap::new(),
default_avatar_texture: None,
failed_attempts: HashMap::new(),
greetd_sock: Arc::new(Mutex::new(None)),
login_cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}));
// Root overlay for layering
let overlay = gtk::Overlay::new();
window.set_child(Some(&overlay));
// Background wallpaper
if let Some(texture) = texture {
overlay.set_child(Some(&create_background_picture(texture)));
}
// Main layout: 3 rows (top spacer, center login, bottom bar)
let main_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
main_box.set_hexpand(true);
main_box.set_vexpand(true);
overlay.add_overlay(&main_box);
// Top spacer
let top_spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0);
top_spacer.set_vexpand(true);
main_box.append(&top_spacer);
// Center: login box
let login_box = gtk::Box::new(gtk::Orientation::Vertical, 12);
login_box.add_css_class("login-box");
login_box.set_halign(gtk::Align::Center);
login_box.set_valign(gtk::Align::Center);
// 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);
// Username label
let username_label = gtk::Label::new(Some(""));
username_label.add_css_class("username-label");
login_box.append(&username_label);
// Session dropdown
let session_dropdown = gtk::DropDown::builder().build();
session_dropdown.add_css_class("session-dropdown");
session_dropdown.set_hexpand(true);
if !all_sessions.is_empty() {
let names: Vec<&str> = all_sessions.iter().map(|s| s.name.as_str()).collect();
let string_list = gtk::StringList::new(&names);
session_dropdown.set_model(Some(&string_list));
}
login_box.append(&session_dropdown);
// 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 (hidden by default)
let error_label = gtk::Label::new(None);
error_label.add_css_class("error-label");
error_label.set_visible(false);
login_box.append(&error_label);
login_box.set_halign(gtk::Align::Center);
main_box.append(&login_box);
// Bottom spacer
let bottom_spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0);
bottom_spacer.set_vexpand(true);
main_box.append(&bottom_spacer);
// Bottom bar overlay (user list left, power buttons right)
let bottom_bar = gtk::Box::new(gtk::Orientation::Horizontal, 0);
bottom_bar.set_hexpand(true);
bottom_bar.set_margin_start(16);
bottom_bar.set_margin_end(16);
bottom_bar.set_margin_bottom(16);
bottom_bar.set_valign(gtk::Align::End);
// User list (left)
let user_list_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
user_list_box.add_css_class("user-list");
user_list_box.set_halign(gtk::Align::Start);
user_list_box.set_valign(gtk::Align::End);
let sessions_rc = Rc::new(all_sessions);
let users_rc = Rc::new(all_users);
for user in users_rc.iter() {
let btn = gtk::Button::with_label(user.display_name());
btn.add_css_class("user-list-item");
let user_clone = user.clone();
btn.connect_clicked(clone!(
#[weak]
avatar_image,
#[weak]
username_label,
#[weak]
password_entry,
#[weak]
error_label,
#[weak]
session_dropdown,
#[weak]
window,
#[strong]
state,
#[strong]
sessions_rc,
move |_| {
cancel_pending_session(&state);
switch_to_user(
&user_clone,
&state,
&avatar_image,
&username_label,
&password_entry,
&error_label,
&session_dropdown,
&sessions_rc,
&window,
);
}
));
user_list_box.append(&btn);
}
bottom_bar.append(&user_list_box);
// Spacer between user list and power buttons
let bar_spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0);
bar_spacer.set_hexpand(true);
bottom_bar.append(&bar_spacer);
// Power buttons (right)
let power_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
power_box.set_halign(gtk::Align::End);
power_box.set_valign(gtk::Align::End);
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]
error_label,
move |btn| {
btn.set_sensitive(false);
execute_power_action(power::reboot, strings.reboot_failed, &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]
error_label,
move |btn| {
btn.set_sensitive(false);
execute_power_action(power::shutdown, strings.shutdown_failed, &error_label);
}
));
power_box.append(&shutdown_btn);
bottom_bar.append(&power_box);
overlay.add_overlay(&bottom_bar);
// Password entry "activate" (Enter key) handler
password_entry.connect_activate(clone!(
#[strong]
state,
#[strong]
sessions_rc,
#[weak]
session_dropdown,
#[weak]
error_label,
#[weak]
password_entry,
#[weak]
app,
move |entry| {
let user = {
let s = state.borrow();
s.selected_user.clone()
};
let Some(user) = user else { return };
let password = entry.text().to_string();
let session = get_selected_session(&session_dropdown, &sessions_rc);
let Some(session) = session else {
show_error(&error_label, &password_entry, strings.no_session_selected);
return;
};
attempt_login(
&user,
&password,
&session,
strings,
&state,
&app,
&error_label,
&password_entry,
&session_dropdown,
);
}
));
// 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);
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
}
));
window.add_controller(key_controller);
// Defer initial user selection until realized (for correct theme colors)
window.connect_realize(clone!(
#[strong]
state,
#[strong]
users_rc,
#[strong]
sessions_rc,
#[weak]
avatar_image,
#[weak]
username_label,
#[weak]
password_entry,
#[weak]
error_label,
#[weak]
session_dropdown,
#[weak]
window,
move |_| {
let state = state.clone();
let users_rc = users_rc.clone();
let sessions_rc = sessions_rc.clone();
glib::idle_add_local_once(clone!(
#[weak]
avatar_image,
#[weak]
username_label,
#[weak]
password_entry,
#[weak]
error_label,
#[weak]
session_dropdown,
#[weak]
window,
move || {
select_initial_user(
&users_rc,
&state,
&avatar_image,
&username_label,
&password_entry,
&error_label,
&session_dropdown,
&sessions_rc,
&window,
);
}
));
}
));
window
}
/// Select the last user or the first available user.
fn select_initial_user(
users: &[User],
state: &Rc<RefCell<GreeterState>>,
avatar_image: &gtk::Image,
username_label: &gtk::Label,
password_entry: &gtk::PasswordEntry,
error_label: &gtk::Label,
session_dropdown: &gtk::DropDown,
sessions: &[Session],
window: &gtk::ApplicationWindow,
) {
if users.is_empty() {
return;
}
let last_username = load_last_user();
let target = last_username
.as_ref()
.and_then(|name| users.iter().find(|u| &u.username == name))
.unwrap_or(&users[0]);
log::debug!("Initial user: {} (last_user={:?})", target.username, last_username);
switch_to_user(
target,
state,
avatar_image,
username_label,
password_entry,
error_label,
session_dropdown,
sessions,
window,
);
}
/// Update the UI to show the selected user.
fn switch_to_user(
user: &User,
state: &Rc<RefCell<GreeterState>>,
avatar_image: &gtk::Image,
username_label: &gtk::Label,
password_entry: &gtk::PasswordEntry,
error_label: &gtk::Label,
session_dropdown: &gtk::DropDown,
sessions: &[Session],
window: &gtk::ApplicationWindow,
) {
log::debug!("Switching to user: {}", user.username);
{
let mut s = state.borrow_mut();
s.selected_user = Some(user.clone());
}
username_label.set_text(user.display_name());
password_entry.set_text("");
error_label.set_visible(false);
// Update avatar
let cached = {
let s = state.borrow();
s.avatar_cache.get(&user.username).cloned()
};
if let Some(texture) = cached {
log::debug!("Avatar cache hit for {}", user.username);
avatar_image.set_paintable(Some(&texture));
} else {
log::debug!("Avatar cache miss for {}", user.username);
let avatar_path = users::get_avatar_path(&user.username, &user.home);
if let Some(path) = avatar_path {
// get_avatar_path already checks existence — go straight to loading
set_avatar_from_file(avatar_image, &path, Some(&user.username), state);
} else {
set_default_avatar(avatar_image, window, state);
}
}
// Pre-select last used session for this user
select_last_session(&user.username, session_dropdown, sessions);
password_entry.grab_focus();
}
/// Load an image file and set it as the avatar.
fn set_avatar_from_file(
image: &gtk::Image,
path: &Path,
username: Option<&str>,
state: &Rc<RefCell<GreeterState>>,
) {
// Reject oversized files
if let Ok(meta) = std::fs::metadata(path) {
if meta.len() > MAX_AVATAR_FILE_SIZE {
log::debug!("Avatar file too large ({} bytes): {}", meta.len(), path.display());
image.set_icon_name(Some("avatar-default-symbolic"));
return;
}
}
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);
if let Some(name) = username {
state
.borrow_mut()
.avatar_cache
.insert(name.to_string(), texture.clone());
}
image.set_paintable(Some(&texture));
}
Err(e) => {
log::debug!("Failed to load avatar {}: {e}", path.display());
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,
state: &Rc<RefCell<GreeterState>>,
) {
// Use cached version if available
{
let s = state.borrow();
if let Some(ref texture) = s.default_avatar_texture {
log::debug!("Default avatar: using cached texture");
image.set_paintable(Some(texture));
return;
}
}
log::debug!("Default avatar: tinting SVG from GResource");
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() {
if let Err(e) = loader.close() {
log::warn!("Failed to close SVG loader: {e}");
}
if let Some(pixbuf) = loader.pixbuf() {
let texture = gdk::Texture::for_pixbuf(&pixbuf);
state.borrow_mut().default_avatar_texture = Some(texture.clone());
image.set_paintable(Some(&texture));
return;
}
}
}
}
// Fallback
image.set_icon_name(Some("avatar-default-symbolic"));
}
/// Get the currently selected session from the dropdown.
fn get_selected_session(
dropdown: &gtk::DropDown,
sessions: &[Session],
) -> Option<Session> {
if sessions.is_empty() {
return None;
}
let idx = dropdown.selected();
if idx == gtk::INVALID_LIST_POSITION {
return None;
}
sessions.get(idx as usize).cloned()
}
/// Pre-select the last used session for a user in the dropdown.
fn select_last_session(
username: &str,
dropdown: &gtk::DropDown,
sessions: &[Session],
) {
if sessions.is_empty() {
return;
}
let last_name = load_last_session(username);
if let Some(name) = last_name {
for (i, session) in sessions.iter().enumerate() {
if session.name == name {
dropdown.set_selected(i as u32);
return;
}
}
}
}
/// Display an error message below the password field.
fn show_error(
error_label: &gtk::Label,
password_entry: &gtk::PasswordEntry,
message: &str,
) {
error_label.set_text(message);
error_label.set_visible(true);
password_entry.set_text("");
password_entry.grab_focus();
}
/// Display a greetd error, using a fallback for missing or oversized descriptions.
fn show_greetd_error(
error_label: &gtk::Label,
password_entry: &gtk::PasswordEntry,
response: &serde_json::Value,
fallback: &str,
) {
let description = response
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("");
if !description.is_empty() && description.len() <= MAX_GREETD_ERROR_LENGTH {
show_error(error_label, password_entry, description);
} else {
show_error(error_label, password_entry, fallback);
}
}
/// Cancel any in-progress greetd session.
fn cancel_pending_session(state: &Rc<RefCell<GreeterState>>) {
log::debug!("Cancelling pending greetd session");
let s = state.borrow();
s.login_cancelled
.store(true, std::sync::atomic::Ordering::SeqCst);
if let Ok(mut sock_guard) = s.greetd_sock.lock() {
if let Some(sock) = sock_guard.take() {
let _ = sock.shutdown(std::net::Shutdown::Both);
}
}
}
/// Set login controls sensitivity.
fn set_login_sensitive(
password_entry: &gtk::PasswordEntry,
session_dropdown: &gtk::DropDown,
sensitive: bool,
) {
password_entry.set_sensitive(sensitive);
session_dropdown.set_sensitive(sensitive);
}
/// Attempt to authenticate and start a session via greetd IPC.
#[allow(clippy::too_many_arguments)]
fn attempt_login(
user: &User,
password: &str,
session: &Session,
strings: &'static Strings,
state: &Rc<RefCell<GreeterState>>,
app: &gtk::Application,
error_label: &gtk::Label,
password_entry: &gtk::PasswordEntry,
session_dropdown: &gtk::DropDown,
) {
log::debug!("Login attempt for user: {}", user.username);
let sock_path = match std::env::var("GREETD_SOCK") {
Ok(p) if !p.is_empty() => p,
_ => {
show_error(error_label, password_entry, strings.greetd_sock_not_set);
return;
}
};
// Validate socket path
log::debug!("GREETD_SOCK: {sock_path}");
let sock_pathbuf = PathBuf::from(&sock_path);
if !sock_pathbuf.is_absolute() {
show_error(
error_label,
password_entry,
strings.greetd_sock_not_absolute,
);
return;
}
match std::fs::metadata(&sock_pathbuf) {
Ok(meta) => {
use std::os::unix::fs::FileTypeExt;
if !meta.file_type().is_socket() {
show_error(
error_label,
password_entry,
strings.greetd_sock_not_socket,
);
return;
}
}
Err(_) => {
show_error(
error_label,
password_entry,
strings.greetd_sock_unreachable,
);
return;
}
}
// Reset cancellation flag and disable UI
{
let s = state.borrow();
s.login_cancelled
.store(false, std::sync::atomic::Ordering::SeqCst);
}
set_login_sensitive(password_entry, session_dropdown, false);
let username = user.username.clone();
let password = password.to_string();
let exec_cmd = session.exec_cmd.clone();
let session_name = session.name.clone();
let greetd_sock = state.borrow().greetd_sock.clone();
let login_cancelled = state.borrow().login_cancelled.clone();
glib::spawn_future_local(clone!(
#[weak]
app,
#[weak]
error_label,
#[weak]
password_entry,
#[weak]
session_dropdown,
#[strong]
state,
async move {
let session_name_clone = session_name.clone();
let result = gio::spawn_blocking(move || {
login_worker(
&username,
&password,
&exec_cmd,
&sock_path,
&greetd_sock,
&login_cancelled,
strings,
)
})
.await;
match result {
Ok(Ok(LoginResult::Success { username })) => {
save_last_user(&username);
save_last_session(&username, &session_name_clone);
app.quit();
}
Ok(Ok(LoginResult::AuthError { response, username: ref uname })) => {
let mut s = state.borrow_mut();
let count = s.failed_attempts.entry(uname.clone()).or_insert(0);
*count += 1;
let warning = faillock_warning(*count, strings);
drop(s);
show_greetd_error(
&error_label,
&password_entry,
&response,
strings.wrong_password,
);
if let Some(w) = warning {
let current = error_label.text().to_string();
error_label.set_text(&format!("{current}\n{w}"));
}
set_login_sensitive(&password_entry, &session_dropdown, true);
}
Ok(Ok(LoginResult::Error { message })) => {
show_error(&error_label, &password_entry, &message);
set_login_sensitive(&password_entry, &session_dropdown, true);
}
Ok(Ok(LoginResult::Cancelled)) => {
set_login_sensitive(&password_entry, &session_dropdown, true);
}
Ok(Err(e)) => {
log::error!("Login worker error: {e}");
show_error(&error_label, &password_entry, strings.socket_error);
set_login_sensitive(&password_entry, &session_dropdown, true);
}
Err(_) => {
log::error!("Login worker panicked");
show_error(&error_label, &password_entry, strings.socket_error);
set_login_sensitive(&password_entry, &session_dropdown, true);
}
}
}
));
}
/// Result of a login attempt from the background thread.
enum LoginResult {
Success {
username: String,
},
AuthError {
response: serde_json::Value,
username: String,
},
Error {
message: String,
},
Cancelled,
}
/// Run greetd IPC in a background thread.
fn login_worker(
username: &str,
password: &str,
exec_cmd: &str,
sock_path: &str,
greetd_sock: &Arc<Mutex<Option<UnixStream>>>,
login_cancelled: &Arc<std::sync::atomic::AtomicBool>,
strings: &Strings,
) -> Result<LoginResult, String> {
if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) {
log::debug!("Login cancelled before connect");
return Ok(LoginResult::Cancelled);
}
log::debug!("Connecting to greetd socket: {sock_path}");
let mut sock = UnixStream::connect(sock_path).map_err(|e| e.to_string())?;
if let Err(e) = sock.set_read_timeout(Some(std::time::Duration::from_secs(10))) {
log::warn!("Failed to set read timeout: {e}");
}
if let Err(e) = sock.set_write_timeout(Some(std::time::Duration::from_secs(10))) {
log::warn!("Failed to set write timeout: {e}");
}
{
let mut guard = greetd_sock.lock().map_err(|e| e.to_string())?;
*guard = Some(sock.try_clone().map_err(|e| e.to_string())?);
}
// Step 1: Create session — if a stale session exists, cancel it and retry
log::debug!("Creating greetd session for {username}");
let mut response = ipc::create_session(&mut sock, username).map_err(|e| e.to_string())?;
if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) {
return Ok(LoginResult::Cancelled);
}
if response.get("type").and_then(|v| v.as_str()) == Some("error") {
log::debug!("Stale session detected, cancelling and retrying");
let _ = ipc::cancel_session(&mut sock);
response = ipc::create_session(&mut sock, username).map_err(|e| e.to_string())?;
if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) {
return Ok(LoginResult::Cancelled);
}
if response.get("type").and_then(|v| v.as_str()) == Some("error") {
let description = response
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("");
let message = if !description.is_empty() && description.len() <= MAX_GREETD_ERROR_LENGTH {
description.to_string()
} else {
strings.auth_failed.to_string()
};
return Ok(LoginResult::Error { message });
}
}
// Step 2: Send password if auth message received
if response.get("type").and_then(|v| v.as_str()) == Some("auth_message") {
log::debug!("Sending auth response for {username}");
response =
ipc::post_auth_response(&mut sock, Some(password)).map_err(|e| e.to_string())?;
if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) {
return Ok(LoginResult::Cancelled);
}
if response.get("type").and_then(|v| v.as_str()) == Some("error") {
let _ = ipc::cancel_session(&mut sock);
return Ok(LoginResult::AuthError {
response,
username: username.to_string(),
});
}
if response.get("type").and_then(|v| v.as_str()) == Some("auth_message") {
// Multi-stage auth is not supported
let _ = ipc::cancel_session(&mut sock);
return Ok(LoginResult::Error {
message: strings.multi_stage_unsupported.to_string(),
});
}
}
// Step 3: Start session
if response.get("type").and_then(|v| v.as_str()) == Some("success") {
log::debug!("Auth successful, starting session: {exec_cmd}");
let cmd = match split_shell_words(exec_cmd) {
Some(words) if !words.is_empty() => words,
_ => {
let _ = ipc::cancel_session(&mut sock);
return Ok(LoginResult::Error {
message: strings.invalid_session_command.to_string(),
});
}
};
// Validate: reject obviously invalid commands (empty, null bytes, path traversal)
// greetd resolves PATH for relative commands like "niri-session"
let first = &cmd[0];
if first.is_empty() || first.contains('\0') || first.contains("..") {
let _ = ipc::cancel_session(&mut sock);
return Ok(LoginResult::Error {
message: strings.invalid_session_command.to_string(),
});
}
response =
ipc::start_session(&mut sock, &cmd).map_err(|e| e.to_string())?;
if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) {
return Ok(LoginResult::Cancelled);
}
if response.get("type").and_then(|v| v.as_str()) == Some("success") {
log::info!("Login successful for {username}");
return Ok(LoginResult::Success {
username: username.to_string(),
});
} else {
return Ok(LoginResult::Error {
message: response
.get("description")
.and_then(|v| v.as_str())
.unwrap_or(strings.session_start_failed)
.to_string(),
});
}
}
Ok(LoginResult::Error {
message: strings.unexpected_greetd_response.to_string(),
})
}
/// 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);
}
}
}
));
}
// -- Last user/session persistence --
fn load_last_user() -> Option<String> {
load_last_user_from(Path::new(LAST_USER_PATH))
}
fn load_last_user_from(path: &Path) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
let username = content.trim();
if is_valid_username(username) {
log::debug!("Loaded last user: {username}");
Some(username.to_string())
} else {
log::debug!("Invalid last user in {}", path.display());
None
}
}
fn save_last_user(username: &str) {
save_last_user_to(Path::new(LAST_USER_PATH), username);
}
fn save_last_user_to(path: &Path, username: &str) {
log::debug!("Saving last user: {username}");
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
use std::os::unix::fs::OpenOptionsExt;
use std::io::Write;
let _ = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)
.and_then(|mut f| f.write_all(username.as_bytes()));
}
fn load_last_session(username: &str) -> Option<String> {
load_last_session_from(&Path::new(LAST_SESSION_DIR).join(username))
}
fn load_last_session_from(path: &Path) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
let name = content.trim();
if is_valid_session_name(name) {
log::debug!("Loaded last session: {name}");
Some(name.to_string())
} else {
log::debug!("Invalid last session in {}", path.display());
None
}
}
/// Validate a session name — printable ASCII, no path separators or null bytes.
fn is_valid_session_name(name: &str) -> bool {
if name.is_empty() || name.len() > MAX_SESSION_NAME_LENGTH {
return false;
}
// Reject path traversal characters and control chars
name.chars().all(|c| c >= ' ' && c != '/' && c != '\\' && c != '\0')
&& !name.contains("..")
}
fn save_last_session(username: &str, session_name: &str) {
if !is_valid_username(username) {
return;
}
if !is_valid_session_name(session_name) {
return;
}
let dir = Path::new(LAST_SESSION_DIR);
let _ = std::fs::create_dir_all(dir);
save_last_session_to(&dir.join(username), session_name);
}
fn save_last_session_to(path: &Path, session_name: &str) {
log::debug!("Saving last session: {session_name}");
use std::os::unix::fs::OpenOptionsExt;
use std::io::Write;
let _ = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)
.and_then(|mut f| f.write_all(session_name.as_bytes()));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_usernames() {
assert!(is_valid_username("testuser"));
assert!(is_valid_username("test_user"));
assert!(is_valid_username("test-user"));
assert!(is_valid_username("test.user"));
assert!(is_valid_username("_admin"));
}
#[test]
fn invalid_usernames() {
assert!(!is_valid_username(""));
assert!(!is_valid_username(".hidden"));
assert!(!is_valid_username("-dash"));
assert!(!is_valid_username("user/name"));
assert!(!is_valid_username(&"a".repeat(MAX_USERNAME_LENGTH + 1)));
}
#[test]
fn valid_session_names() {
assert!(is_valid_session_name("Niri"));
assert!(is_valid_session_name("Hyprland"));
assert!(is_valid_session_name("sway (wayland)"));
assert!(is_valid_session_name("i3 - X11"));
}
#[test]
fn invalid_session_names() {
assert!(!is_valid_session_name(""));
assert!(!is_valid_session_name("../../../etc/evil"));
assert!(!is_valid_session_name("name/with/slash"));
assert!(!is_valid_session_name("name\0null"));
assert!(!is_valid_session_name("name\\backslash"));
assert!(!is_valid_session_name(&"a".repeat(MAX_SESSION_NAME_LENGTH + 1)));
}
#[test]
fn last_user_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("last-user");
save_last_user_to(&path, "alice");
let loaded = load_last_user_from(&path);
assert_eq!(loaded, Some("alice".to_string()));
}
#[test]
fn last_user_rejects_invalid() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("last-user");
save_last_user_to(&path, "../evil");
let loaded = load_last_user_from(&path);
assert_eq!(loaded, None);
}
#[test]
fn last_user_missing_file() {
let loaded = load_last_user_from(Path::new("/nonexistent/last-user"));
assert_eq!(loaded, None);
}
#[test]
fn last_session_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("alice");
save_last_session_to(&path, "Niri");
let loaded = load_last_session_from(&path);
assert_eq!(loaded, Some("Niri".to_string()));
}
#[test]
fn last_session_rejects_invalid() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("alice");
save_last_session_to(&path, "../../../etc/evil");
let loaded = load_last_session_from(&path);
assert_eq!(loaded, None);
}
#[test]
fn last_session_missing_file() {
let loaded = load_last_session_from(Path::new("/nonexistent/session"));
assert_eq!(loaded, None);
}
#[test]
fn last_user_file_permissions() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("last-user");
save_last_user_to(&path, "alice");
let meta = std::fs::metadata(&path).unwrap();
use std::os::unix::fs::PermissionsExt;
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
}
#[test]
fn last_session_file_permissions() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session");
save_last_session_to(&path, "Niri");
let meta = std::fs::metadata(&path).unwrap();
use std::os::unix::fs::PermissionsExt;
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
}
// -- split_shell_words tests --
#[test]
fn shell_words_simple() {
assert_eq!(
split_shell_words("niri-session"),
Some(vec!["niri-session".to_string()])
);
}
#[test]
fn shell_words_with_args() {
assert_eq!(
split_shell_words("sway --config /etc/sway/config"),
Some(vec![
"sway".to_string(),
"--config".to_string(),
"/etc/sway/config".to_string(),
])
);
}
#[test]
fn shell_words_double_quotes() {
assert_eq!(
split_shell_words(r#"niri --config "/path with spaces/config""#),
Some(vec![
"niri".to_string(),
"--config".to_string(),
"/path with spaces/config".to_string(),
])
);
}
#[test]
fn shell_words_single_quotes() {
assert_eq!(
split_shell_words("bash -c 'echo hello world'"),
Some(vec![
"bash".to_string(),
"-c".to_string(),
"echo hello world".to_string(),
])
);
}
#[test]
fn shell_words_unbalanced_quotes() {
assert!(split_shell_words("niri --config \"unclosed").is_none());
assert!(split_shell_words("bash -c 'unclosed").is_none());
}
#[test]
fn shell_words_empty() {
assert_eq!(split_shell_words(""), Some(vec![]));
assert_eq!(split_shell_words(" "), Some(vec![]));
}
#[test]
fn shell_words_backslash_escape() {
assert_eq!(
split_shell_words(r"path\ with\ spaces"),
Some(vec!["path with spaces".to_string()])
);
}
#[test]
fn shell_words_trailing_backslash() {
assert_eq!(split_shell_words(r"foo\"), None);
assert_eq!(split_shell_words(r#""foo\"#), None);
}
// -- login_worker tests --
// These use a real Unix socket pair via UnixListener to simulate greetd.
use std::os::unix::net::UnixListener;
use crate::ipc;
/// Helper: spawn a fake greetd server that responds to messages.
fn fake_greetd<F>(handler: F) -> (String, std::thread::JoinHandle<()>)
where
F: FnOnce(&mut UnixStream) + Send + 'static,
{
let dir = tempfile::tempdir().unwrap();
let sock_path = dir.path().join("greetd.sock");
let sock_path_str = sock_path.to_str().unwrap().to_string();
let listener = UnixListener::bind(&sock_path).unwrap();
let handle = std::thread::spawn(move || {
let (mut client, _) = listener.accept().unwrap();
handler(&mut client);
// Keep dir alive until handler finishes
drop(dir);
});
(sock_path_str, handle)
}
fn default_greetd_sock() -> Arc<Mutex<Option<UnixStream>>> {
Arc::new(Mutex::new(None))
}
fn default_cancelled() -> Arc<std::sync::atomic::AtomicBool> {
Arc::new(std::sync::atomic::AtomicBool::new(false))
}
#[test]
fn login_worker_auth_error() {
let (sock_path, handle) = fake_greetd(|stream| {
// create_session request
let _msg = ipc::recv_message(stream).unwrap();
// Respond with auth_message
ipc::send_message(stream, &serde_json::json!({
"type": "auth_message",
"auth_message_type": "secret",
"auth_message": "Password: ",
})).unwrap();
// post_auth_response
let _msg = ipc::recv_message(stream).unwrap();
// Respond with error (wrong password)
ipc::send_message(stream, &serde_json::json!({
"type": "error",
"description": "Authentication failure",
})).unwrap();
// cancel_session
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
});
let result = login_worker(
"alice", "wrongpass", "/usr/bin/niri",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
);
let result = result.unwrap();
assert!(matches!(result, LoginResult::AuthError { .. }));
handle.join().unwrap();
}
#[test]
fn login_worker_stale_session_retry() {
let (sock_path, handle) = fake_greetd(|stream| {
// First create_session fails (stale session)
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({
"type": "error",
"description": "session already in progress",
})).unwrap();
// cancel_session
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
// Retry create_session succeeds
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({
"type": "auth_message",
"auth_message_type": "secret",
"auth_message": "Password: ",
})).unwrap();
// post_auth_response
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
// start_session
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
});
let result = login_worker(
"alice", "correct", "/usr/bin/bash",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
);
let result = result.unwrap();
assert!(matches!(result, LoginResult::Success { .. }));
handle.join().unwrap();
}
#[test]
fn login_worker_multi_stage_rejected() {
let (sock_path, handle) = fake_greetd(|stream| {
// create_session
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({
"type": "auth_message",
"auth_message_type": "secret",
"auth_message": "Password: ",
})).unwrap();
// post_auth_response → another auth_message (TOTP)
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({
"type": "auth_message",
"auth_message_type": "visible",
"auth_message": "TOTP: ",
})).unwrap();
// cancel_session
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
});
let result = login_worker(
"alice", "pass", "/usr/bin/niri",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
);
let result = result.unwrap();
assert!(matches!(result, LoginResult::Error { .. }));
if let LoginResult::Error { message } = result {
assert!(message.contains("Multi-stage"));
}
handle.join().unwrap();
}
#[test]
fn login_worker_start_session_failure() {
let (sock_path, handle) = fake_greetd(|stream| {
// create_session
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({
"type": "auth_message",
"auth_message_type": "secret",
"auth_message": "Password: ",
})).unwrap();
// post_auth_response → success
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
// start_session → error
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({
"type": "error",
"description": "session start failed",
})).unwrap();
});
let result = login_worker(
"alice", "pass", "/usr/bin/bash",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
);
let result = result.unwrap();
assert!(matches!(result, LoginResult::Error { .. }));
handle.join().unwrap();
}
#[test]
fn login_worker_cancelled_before_start() {
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(true));
// No server needed — should return Cancelled immediately
let result = login_worker(
"alice", "pass", "/usr/bin/niri",
"/nonexistent/sock", &default_greetd_sock(), &cancelled,
load_strings(Some("en")),
);
let result = result.unwrap();
assert!(matches!(result, LoginResult::Cancelled));
}
#[test]
fn login_worker_invalid_exec_cmd() {
let (sock_path, handle) = fake_greetd(|stream| {
// create_session
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({
"type": "auth_message",
"auth_message_type": "secret",
"auth_message": "Password: ",
})).unwrap();
// post_auth_response → success
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
// cancel_session (from invalid exec_cmd with path traversal)
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
});
// exec_cmd with path traversal
let result = login_worker(
"alice", "pass", "../../../etc/evil",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
);
let result = result.unwrap();
assert!(matches!(result, LoginResult::Error { .. }));
handle.join().unwrap();
}
#[test]
fn login_worker_relative_exec_cmd_allowed() {
let (sock_path, handle) = fake_greetd(|stream| {
// create_session
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({
"type": "auth_message",
"auth_message_type": "secret",
"auth_message": "Password: ",
})).unwrap();
// post_auth_response → success
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
// start_session with relative command (e.g. niri-session)
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
});
// Relative exec_cmd like "niri-session" should be allowed
let result = login_worker(
"alice", "pass", "niri-session",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
);
let result = result.unwrap();
assert!(matches!(result, LoginResult::Success { .. }));
handle.join().unwrap();
}
// -- load_background_texture tests --
#[test]
fn load_background_texture_missing_file_returns_none() {
let result = load_background_texture(Path::new("/nonexistent/wallpaper.jpg"), None);
assert!(result.is_none());
}
#[test]
fn load_background_texture_oversized_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("huge.jpg");
// Create a sparse file that exceeds MAX_WALLPAPER_FILE_SIZE
let f = std::fs::File::create(&path).unwrap();
f.set_len(MAX_WALLPAPER_FILE_SIZE + 1).unwrap();
let result = load_background_texture(&path, None);
assert!(result.is_none());
}
#[test]
fn load_background_texture_non_utf8_path_returns_none() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
// 0xFF is not valid UTF-8
let non_utf8 = OsStr::from_bytes(&[0xff, 0xfe, 0xfd]);
let path = Path::new(non_utf8);
let result = load_background_texture(path, None);
assert!(result.is_none());
}
}