feat: complete session preferences and PAM fixture gates

This commit is contained in:
2026-09-26 02:44:29 +05:30
parent 83345a1842
commit ee6186ed0b
34 changed files with 801 additions and 327 deletions

View File

@@ -23,7 +23,6 @@ const MAX_ANIMATION_WIDTH: usize = 160;
const MAX_ANIMATION_HEIGHT: usize = 64;
const FRAME_INTERVAL: Duration = Duration::from_millis(120);
/// Background state owned by the TUI thread.
pub(crate) struct Background {
wallpaper: Option<RgbImage>,
automata: Automata,
@@ -34,7 +33,6 @@ pub(crate) struct Background {
}
impl Background {
/// Loads a bounded wallpaper image and prepares the procedural layer.
pub(crate) fn load(config: &VisualConfig) -> Result<Self, Error> {
let wallpaper = config
.wallpaper_path
@@ -191,8 +189,7 @@ struct Automata {
impl Automata {
fn resize(&mut self, width: usize, height: usize) {
// Keep procedural state bounded even when a terminal reports an
// unusually large window. Rendering remains clipped to this layer.
// Cap the state size. Some terminal emulators report extreme dimensions.
let width = width.min(MAX_ANIMATION_WIDTH);
let height = height.min(MAX_ANIMATION_HEIGHT);
if self.width == width && self.height == height {

View File

@@ -21,6 +21,7 @@ use latchd_core::{
audit::{AuditAction, AuditEvent},
config::Config,
error::{Error, ResultExt},
preferences::{self, LastUsedPreferences},
session::{self, SessionDescriptor},
};
@@ -41,11 +42,8 @@ fn unknown_account_status() -> AccountStatus {
}
}
/// The result of one completed login-screen interaction.
pub struct LoginSelection {
/// Authenticated PAM ticket.
pub ticket: AuthTicket,
/// Selected session descriptor.
pub session: SessionDescriptor,
}
@@ -130,6 +128,8 @@ struct App {
refresh_receiver: Option<Receiver<DiscoverySnapshot>>,
refreshing: bool,
high_contrast: bool,
preferences: LastUsedPreferences,
preference_path: Option<std::path::PathBuf>,
}
impl App {
@@ -181,6 +181,8 @@ impl App {
refresh_receiver: None,
refreshing: false,
high_contrast: std::env::var_os("LATCHD_HIGH_CONTRAST").is_some(),
preferences: LastUsedPreferences::default(),
preference_path: None,
}
}
@@ -258,6 +260,7 @@ impl App {
.get(self.selected)
.cloned()
.ok_or_else(|| Error::Session("no session selected".into()))?;
self.remember_selected_session(&selected);
Ok(Some(LoginSelection {
ticket,
session: selected,
@@ -334,6 +337,25 @@ impl App {
);
}
fn apply_preferred_session(&mut self) {
if let Some(index) = self
.preferences
.preferred_index(&self.username, &self.sessions)
{
self.selected = index;
}
}
fn remember_selected_session(&mut self, session: &SessionDescriptor) {
self.preferences.remember(&self.username, session);
let Some(path) = self.preference_path.clone() else {
return;
};
if let Err(error) = preferences::save(&path, &self.preferences) {
tracing::debug!(%error, "could not persist last-session preference");
}
}
fn poll_faillock(&mut self) {
let Some(receiver) = &self.faillock_receiver else {
return;
@@ -995,6 +1017,7 @@ impl App {
self.auth_blocked_until = None;
self.faillock_status = None;
self.status = format!("Selected account {}", self.username);
self.apply_preferred_session();
self.start_faillock_query();
self.notify(
&format!("Selected account {}", self.username),
@@ -1368,9 +1391,25 @@ pub fn run(
(Vec::new(), Vec::new())
};
let background = Background::load(&config.visual)?;
let preference_path = config
.environment_switcher
.remember_last_session
.then(|| config.environment_switcher.last_session_path.clone());
let preferences =
preference_path
.as_deref()
.map_or_else(LastUsedPreferences::default, |path| {
preferences::load(path).unwrap_or_else(|error| {
tracing::debug!(%error, "could not load last-session preferences");
LastUsedPreferences::default()
})
});
let mut app = App::new(config, auth, preview, sessions, accounts, background);
app.account_statuses = account_statuses;
app.account_index = (!app.accounts.is_empty()).then_some(0);
app.preferences = preferences;
app.preference_path = preference_path;
app.apply_preferred_session();
app.start_faillock_query();
ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string()))
}
@@ -1654,6 +1693,54 @@ mod tests {
assert!(rendered.contains("New password"));
}
#[test]
fn last_session_preference_follows_account_and_persists_success() {
let auth = AuthService::new().expect("PAM worker should start");
let sessions = vec![
SessionDescriptor {
kind: session::SessionKind::Tty,
title: "TTY shell".into(),
argv: Vec::new(),
current_desktop: None,
session_desktop: None,
},
SessionDescriptor {
kind: session::SessionKind::Wayland,
title: "Sway".into(),
argv: vec!["sway".into()],
current_desktop: None,
session_desktop: None,
},
];
let mut app = App::new(
Config::default(),
auth,
true,
sessions,
vec![Identity {
username: "alice".into(),
uid: 1000,
gid: 1000,
home: "/home/alice".into(),
shell: "/bin/sh".into(),
}],
Background::load(&Config::default().visual).expect("default background should load"),
);
app.preferences.remember("alice", &app.sessions[1]);
app.apply_preferred_session();
assert_eq!(app.selected, 1);
let directory =
std::env::temp_dir().join(format!("latchd-tui-preferences-{}", std::process::id()));
app.preference_path = Some(directory.join("last-session.toml"));
let selected = app.sessions[1].clone();
app.remember_selected_session(&selected);
let loaded = preferences::load(&directory.join("last-session.toml"))
.expect("selected session should persist");
assert_eq!(loaded.preferred_index("alice", &app.sessions), Some(1));
let _ = std::fs::remove_dir_all(directory);
}
#[test]
fn filtering_is_smart_case_and_selects_a_matching_session() {
let auth = AuthService::new().expect("PAM worker should start");