//! Ratatui login screen and deterministic state machine. use std::{ sync::mpsc::{self, Receiver}, thread, time::{Duration, Instant}, }; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ DefaultTerminal, prelude::{Constraint, Direction, Layout, Line, Rect, Span, Style}, widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, }; use unicode_width::UnicodeWidthStr; use zeroize::Zeroizing; use latchd_auth::{AuthService, AuthTicket}; use latchd_core::{ accounts::{self, FaillockStatus, Identity}, config::Config, error::{Error, ResultExt}, session::{self, SessionDescriptor}, }; mod background; use background::Background; const MIN_WIDTH: u16 = 60; const MIN_HEIGHT: u16 = 16; const MAX_INPUT: usize = 256; /// The result of one completed login-screen interaction. pub struct LoginSelection { /// Authenticated PAM ticket. pub ticket: AuthTicket, /// Selected session descriptor. pub session: SessionDescriptor, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Focus { Sessions, Username, Password, } #[derive(Debug)] enum AuthState { Idle, Pending(Receiver>), Failed, } #[derive(Debug, Clone, Copy)] enum NoticeLevel { Info, Warning, } #[derive(Debug)] struct Notice { message: String, level: NoticeLevel, expires: Instant, } struct App { config: Config, auth: AuthService, preview: bool, sessions: Vec, accounts: Vec, account_index: Option, faillock_receiver: Option>>, faillock_status: Option, selected: usize, focus: Focus, username: String, password: Zeroizing, status: String, auth_state: AuthState, show_help: bool, power_confirmation: Option, should_quit: bool, ascii: bool, no_color: bool, background: Background, notice: Option, animation_frame: usize, reduced_motion: bool, } impl App { fn new( config: Config, auth: AuthService, preview: bool, sessions: Vec, accounts: Vec, background: Background, ) -> Self { let username = accounts .first() .map(|account| account.username.clone()) .unwrap_or_default(); let account_index = (!accounts.is_empty()).then_some(0); let reduced_motion = config.visual.reduced_motion; Self { config, auth, preview, sessions, accounts, account_index, faillock_receiver: None, faillock_status: None, selected: 0, focus: Focus::Username, username, password: Zeroizing::new(String::new()), status: "Enter your username".into(), auth_state: AuthState::Idle, show_help: false, power_confirmation: None, should_quit: false, ascii: std::env::var_os("LATCHD_ASCII").is_some(), no_color: std::env::var_os("NO_COLOR").is_some(), background, notice: None, animation_frame: 0, reduced_motion, } } fn run(&mut self, terminal: &mut DefaultTerminal) -> Result, Error> { loop { self.poll_faillock(); let now = Instant::now(); if self.background.tick_if_due(now) { self.animation_frame = self.animation_frame.wrapping_add(1); } if self .notice .as_ref() .is_some_and(|notice| notice.expires <= now) { self.notice = None; } terminal .draw(|frame| self.draw(frame)) .map_err(|error| Error::Terminal(error.to_string()))?; if let Some(result) = self.poll_auth() { match result { Ok(ticket) => { let selected = self .sessions .get(self.selected) .cloned() .ok_or_else(|| Error::Session("no session selected".into()))?; return Ok(Some(LoginSelection { ticket, session: selected, })); } Err(error) => { tracing::warn!(%error, "authentication attempt failed"); self.auth_state = AuthState::Failed; self.password.clear(); self.status = "Authentication failed".into(); self.notify("Authentication failed", NoticeLevel::Warning); self.start_faillock_query(); } } } if self.should_quit { return Ok(None); } if event::poll(Duration::from_millis(100)).io_context("poll terminal event")? { if let Event::Key(key) = event::read().io_context("read terminal event")? { if key.kind == KeyEventKind::Press { self.handle_key(key); } } } } } fn poll_auth(&mut self) -> Option> { let AuthState::Pending(receiver) = &self.auth_state else { return None; }; match receiver.try_recv() { Ok(result) => Some(result), Err(mpsc::TryRecvError::Empty) => None, Err(mpsc::TryRecvError::Disconnected) => Some(Err(Error::Worker( "authentication task disconnected".into(), ))), } } fn poll_faillock(&mut self) { let Some(receiver) = &self.faillock_receiver else { return; }; match receiver.try_recv() { Ok(Ok(status)) => { self.faillock_status = Some(status); self.faillock_receiver = None; if status.available { if status.locked_out { self.notify( "Account has reached the advisory faillock threshold", NoticeLevel::Warning, ); } else if status.failures > 0 { self.notify( &format!("{} failed attempt(s) recorded", status.failures), NoticeLevel::Info, ); } } } Ok(Err(error)) => { tracing::debug!(%error, "faillock query failed"); self.faillock_receiver = None; } Err(mpsc::TryRecvError::Empty) => {} Err(mpsc::TryRecvError::Disconnected) => self.faillock_receiver = None, } } fn start_faillock_query(&mut self) { let username = self.username.clone(); if username.is_empty() { self.faillock_receiver = None; self.faillock_status = None; return; } let config = self.config.accounts.clone(); let (sender, receiver) = mpsc::sync_channel(1); self.faillock_receiver = Some(receiver); let task = thread::Builder::new() .name("latchd-faillock".into()) .spawn(move || { let result = accounts::faillock_status(&username, &config); let _ = sender.send(result); }); if let Err(error) = task { tracing::debug!(%error, "could not start faillock query"); self.faillock_receiver = None; } } fn notify(&mut self, message: &str, level: NoticeLevel) { if self.config.visual.notifications { self.notice = Some(Notice { message: message.to_owned(), level, expires: Instant::now() + Duration::from_secs(4), }); } } fn handle_key(&mut self, key: KeyEvent) { if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { self.should_quit = true; return; } if matches!(self.auth_state, AuthState::Pending(_)) { if key.code == KeyCode::Esc { self.status = "Authentication is still in progress".into(); } return; } if self.show_help { if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) { self.show_help = false; } return; } if self.power_confirmation.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) { match key.code { KeyCode::Char('n') => { self.next_account(); return; } KeyCode::Char('p') => { self.previous_account(); return; } KeyCode::Char('b') => { let enabled = self.background.toggle_enabled(); self.notify( if enabled { "Ambient background enabled" } else { "Ambient background disabled" }, NoticeLevel::Info, ); return; } KeyCode::Char('m') => { let reduced = self.background.toggle_motion(); self.reduced_motion = reduced; self.notify( if reduced { "Reduced motion enabled" } else { "Reduced motion disabled" }, NoticeLevel::Info, ); return; } _ => {} } } if let Some(index) = self.power_confirmation { if key.code == KeyCode::Enter { self.run_power_action(index); } else if key.code == KeyCode::Esc { self.power_confirmation = None; self.status = "Power action cancelled".into(); } return; } match key.code { KeyCode::Char('?') => self.show_help = true, KeyCode::Esc => { self.password.clear(); self.status = "Login cancelled".into(); } KeyCode::Tab | KeyCode::Down => self.next_focus(), KeyCode::BackTab | KeyCode::Up => self.previous_focus(), KeyCode::Left if self.focus == Focus::Sessions => self.previous_session(), KeyCode::Right if self.focus == Focus::Sessions => self.next_session(), KeyCode::Enter if self.focus == Focus::Password => self.begin_authentication(), KeyCode::F(number) => self.confirm_power_action(number), KeyCode::Backspace => self.backspace(), KeyCode::Char(character) if !key .modifiers .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { self.insert(character) } _ => {} } } fn confirm_power_action(&mut self, number: u8) { if self.preview { self.status = "Preview mode — power actions are disabled".into(); return; } let key = format!("F{number}"); if let Some((index, action)) = self .config .power_controls .entries .iter() .enumerate() .find(|(_, action)| action.key.eq_ignore_ascii_case(&key)) { self.power_confirmation = Some(index); self.status = format!("Confirm {} — press Enter, Esc cancels", action.hint); } } fn run_power_action(&mut self, index: usize) { if self.preview { self.power_confirmation = None; self.status = "Preview mode — power actions are disabled".into(); return; } let Some(action) = self.config.power_controls.entries.get(index).cloned() else { self.power_confirmation = None; self.status = "Power action is no longer available".into(); return; }; if !action.program.is_absolute() { self.power_confirmation = None; self.status = "Power action must use an absolute program path".into(); return; } match std::process::Command::new(&action.program) .args(&action.args) .spawn() { Ok(_) => { self.power_confirmation = None; self.status = format!("Started {}", action.hint); } Err(error) => { self.power_confirmation = None; self.status = format!("Could not start {}: {error}", action.hint); } } } fn begin_authentication(&mut self) { if self.preview { self.status = "Preview mode — authentication and session startup are disabled".into(); return; } if self.username.is_empty() || self.password.is_empty() { self.status = "Username and password are required".into(); return; } let username = self.username.clone(); let password = self.password.as_bytes().to_vec(); let service = self.config.pam_service.clone(); let auth = self.auth.clone(); let (sender, receiver) = mpsc::sync_channel(0); let task = thread::Builder::new() .name("latchd-auth-attempt".into()) .spawn(move || { let result = auth.authenticate(username, password, service); let _ = sender.send(result); }); match task { Ok(_) => { self.auth_state = AuthState::Pending(receiver); self.status = "Authenticating…".into(); self.password.clear(); } Err(error) => self.status = format!("Could not start authentication: {error}"), } } fn insert(&mut self, character: char) { if self.focus == Focus::Username { self.account_index = None; } let target = match self.focus { Focus::Username => &mut self.username, Focus::Password => &mut self.password, Focus::Sessions => return, }; if target.chars().count() < MAX_INPUT { target.push(character); } } fn backspace(&mut self) { if self.focus == Focus::Username { self.account_index = None; } let target = match self.focus { Focus::Username => &mut self.username, Focus::Password => &mut self.password, Focus::Sessions => return, }; target.pop(); } fn next_focus(&mut self) { self.focus = match self.focus { Focus::Sessions => Focus::Username, Focus::Username => Focus::Password, Focus::Password => Focus::Sessions, }; self.update_hint(); } fn previous_focus(&mut self) { self.focus = match self.focus { Focus::Sessions => Focus::Password, Focus::Username => Focus::Sessions, Focus::Password => Focus::Username, }; self.update_hint(); } fn update_hint(&mut self) { self.status = match self.focus { Focus::Sessions => "Choose a session with ←/→".into(), Focus::Username => "Enter your username".into(), Focus::Password => "Enter your password and press Enter".into(), }; } fn next_session(&mut self) { if !self.sessions.is_empty() { self.selected = (self.selected + 1) % self.sessions.len(); } } fn previous_session(&mut self) { if !self.sessions.is_empty() { self.selected = self .selected .checked_sub(1) .unwrap_or(self.sessions.len() - 1); } } fn next_account(&mut self) { self.select_account(1); } fn previous_account(&mut self) { self.select_account(-1); } fn select_account(&mut self, delta: isize) { if self.accounts.is_empty() { self.notify( "No eligible accounts discovered; enter a username manually", NoticeLevel::Info, ); return; } let current = self.account_index.unwrap_or(0) as isize; let length = self.accounts.len() as isize; let index = (current + delta).rem_euclid(length) as usize; self.account_index = Some(index); self.username = self.accounts[index].username.clone(); self.password.clear(); self.auth_state = AuthState::Idle; self.faillock_status = None; self.status = format!("Selected account {}", self.username); self.start_faillock_query(); self.notify( &format!("Selected account {}", self.username), NoticeLevel::Info, ); } fn draw(&mut self, frame: &mut ratatui::Frame) { let area = frame.area(); self.background.draw(frame, area, self.ascii, self.no_color); if area.width < MIN_WIDTH || area.height < MIN_HEIGHT { frame.render_widget(Clear, area); frame.render_widget( Paragraph::new(format!( "terminal too small — need {MIN_WIDTH}×{MIN_HEIGHT}" )) .alignment(ratatui::prelude::Alignment::Center), area, ); return; } let vertical = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(2), Constraint::Min(8), Constraint::Length(2), Constraint::Length(1), ]) .split(area); let title = Paragraph::new(Line::from(vec![ Span::styled("latchd", self.style_accent()), Span::raw(" secure terminal login"), ])) .alignment(ratatui::prelude::Alignment::Center); frame.render_widget(title, vertical[0]); let body = if area.width < 80 { Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(5), Constraint::Length(8)]) .split(vertical[1]) } else { Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(42), Constraint::Percentage(58)]) .split(vertical[1]) }; let session_area = body[0]; let form_area = if body.len() == 2 { body[1] } else { vertical[1] }; let session_items: Vec = self .sessions .iter() .enumerate() .map(|(index, session)| { let marker = if index == self.selected { if self.ascii { ">" } else { "▶" } } else { " " }; ListItem::new(format!( "{marker} {}", truncate_cells( &session.title, self.config.environment_switcher.max_display_length ) )) .style(if index == self.selected { self.style_selected() } else { self.style_muted() }) }) .collect(); let sessions = List::new(session_items).block(self.block("Session", self.focus == Focus::Sessions)); frame.render_widget(sessions, session_area); let fields = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Length(3), Constraint::Min(1), ]) .split(form_area); let account_title = self.account_title(); let username = Paragraph::new(self.username.as_str()) .block(self.block(&account_title, self.focus == Focus::Username)); let password_marker = if self.ascii { "*" } else { "•" }; let password = Paragraph::new(password_marker.repeat(self.password.chars().count())) .block(self.block("Password", self.focus == Focus::Password)); frame.render_widget(username, fields[0]); frame.render_widget(password, fields[1]); let status = self.status_line(); frame.render_widget(Paragraph::new(status).wrap(Wrap { trim: true }), fields[2]); frame.render_widget( Paragraph::new( "Tab focus ←/→ session Ctrl-N/P account Ctrl-B effects Ctrl-M motion ? help", ), vertical[2], ); frame.render_widget( Paragraph::new("PAM authentication • session output stays off the login screen"), vertical[3], ); if self.show_help { self.draw_help(frame, area); } } fn draw_help(&self, frame: &mut ratatui::Frame, area: Rect) { let popup = centered_rect(70, 50, area); frame.render_widget(Clear, popup); frame.render_widget(Paragraph::new("Keyboard help\n\nTab / Shift-Tab move focus\nArrow keys choose session\nCtrl-N / Ctrl-P switch account\nCtrl-B toggle background\nCtrl-M toggle reduced motion\nEnter authenticate\nEsc clear password\nCtrl-C quit cleanly\n? close help") .block(self.block("Help", false)).wrap(Wrap { trim: true }), popup); } fn block(&self, title: &str, focused: bool) -> Block<'static> { let mut block = Block::default() .borders(Borders::ALL) .title(title.to_owned()) .border_style(if focused { self.style_accent() } else { self.style_muted() }); if !self.no_color { block = block.style(Style::default().bg(ratatui::prelude::Color::Black)); } block } fn account_title(&self) -> String { self.account_index .map(|index| format!("Username {}/{}", index + 1, self.accounts.len())) .unwrap_or_else(|| "Username".into()) } fn status_line(&self) -> String { let mut status = self.status.clone(); if matches!(self.auth_state, AuthState::Pending(_)) { let spinner = if self.config.visual.animations && !self.reduced_motion { ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] .get(self.animation_frame % 10) .copied() .unwrap_or("⠋") } else if self.ascii { "|" } else { "…" }; status = format!("{spinner} Authenticating…"); } if let Some(lockout) = self.faillock_status { if lockout.locked_out { status .push_str(" • advisory faillock threshold reached; PAM remains authoritative"); } else if lockout.failures > 0 { let remaining = lockout .remaining .map_or_else(|| "unknown".into(), |value| value.to_string()); status.push_str(&format!( " • {} failed attempt(s), {remaining} remaining", lockout.failures )); } } if let Some(notice) = &self.notice { let marker = match notice.level { NoticeLevel::Info => { if self.ascii { "[i]" } else { "ℹ" } } NoticeLevel::Warning => { if self.ascii { "[!]" } else { "⚠" } } }; status.push_str(&format!(" {marker} {}", notice.message)); } status } fn style_accent(&self) -> Style { if self.no_color { Style::default().add_modifier(ratatui::prelude::Modifier::BOLD) } else { Style::default() .fg(ratatui::prelude::Color::Cyan) .add_modifier(ratatui::prelude::Modifier::BOLD) } } fn style_selected(&self) -> Style { if self.no_color { Style::default().add_modifier(ratatui::prelude::Modifier::REVERSED) } else { Style::default() .fg(ratatui::prelude::Color::Yellow) .add_modifier(ratatui::prelude::Modifier::BOLD) } } fn style_muted(&self) -> Style { Style::default().add_modifier(ratatui::prelude::Modifier::DIM) } } /// Runs the login UI and restores the terminal on every return path. pub fn run( config: Config, auth: AuthService, preview: bool, ) -> Result, Error> { let sessions = session::discover(&config); let accounts = if config.accounts.enumerate { accounts::discover(&config.accounts) } else { Vec::new() }; let background = Background::load(&config.visual)?; let mut app = App::new(config, auth, preview, sessions, accounts, background); app.account_index = (!app.accounts.is_empty()).then_some(0); app.start_faillock_query(); ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string())) } fn centered_rect(width_percent: u16, height_percent: u16, area: Rect) -> Rect { let vertical = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Percentage((100 - height_percent) / 2), Constraint::Percentage(height_percent), Constraint::Percentage((100 - height_percent) / 2), ]) .split(area); Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage((100 - width_percent) / 2), Constraint::Percentage(width_percent), Constraint::Percentage((100 - width_percent) / 2), ]) .split(vertical[1])[1] } fn truncate_cells(value: &str, width: usize) -> String { if UnicodeWidthStr::width(value) <= width { return value.into(); } let mut output = String::new(); let mut used = 0; for character in value.chars() { let character_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(1); if used + character_width + 1 > width { break; } output.push(character); used += character_width; } output.push('…'); output } #[cfg(test)] mod tests { use super::*; use ratatui::{Terminal, backend::TestBackend}; #[test] fn truncation_respects_terminal_cells() { assert_eq!(truncate_cells("界界界", 5), "界界…"); } #[test] fn renders_the_login_screen_at_the_floor() { let auth = AuthService::new().expect("PAM worker should start"); let mut app = App::new( Config::default(), auth, true, vec![SessionDescriptor { kind: session::SessionKind::Tty, title: "TTY shell".into(), argv: Vec::new(), current_desktop: None, session_desktop: None, }], Vec::new(), Background::load(&Config::default().visual).expect("default background should load"), ); let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).expect("test terminal should initialize"); terminal .draw(|frame| app.draw(frame)) .expect("frame should render"); let buffer = terminal.backend().buffer(); let rendered = buffer .content() .chunks(buffer.area.width as usize) .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) .collect::>() .join("\n"); assert!(rendered.contains("latchd")); assert!(rendered.contains("Username")); assert!(rendered.contains("Password")); } #[test] fn renders_a_truthful_too_small_state() { let auth = AuthService::new().expect("PAM worker should start"); let mut app = App::new( Config::default(), auth, true, Vec::new(), Vec::new(), Background::load(&Config::default().visual).expect("default background should load"), ); let backend = TestBackend::new(59, 15); let mut terminal = Terminal::new(backend).expect("test terminal should initialize"); terminal .draw(|frame| app.draw(frame)) .expect("frame should render"); let buffer = terminal.backend().buffer(); let rendered = buffer .content() .chunks(buffer.area.width as usize) .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) .collect::>() .join("\n"); assert!(rendered.contains("terminal too small")); } #[test] fn switching_accounts_clears_password_and_auth_state() { let auth = AuthService::new().expect("PAM worker should start"); let accounts = vec![ Identity { username: "alice".into(), uid: 1000, gid: 1000, home: "/home/alice".into(), shell: "/bin/sh".into(), }, Identity { username: "bob".into(), uid: 1001, gid: 1001, home: "/home/bob".into(), shell: "/bin/sh".into(), }, ]; let mut app = App::new( Config::default(), auth, true, Vec::new(), accounts, Background::load(&Config::default().visual).expect("default background should load"), ); app.password = Zeroizing::new("secret".into()); app.next_account(); assert_eq!(app.username, "bob"); assert!(app.password.is_empty()); assert!(matches!(app.auth_state, AuthState::Idle)); } }