Files
latchd/crates/latchd-tui/src/lib.rs
Zane Walker faf5b27328 feat: ship Rust login manager and GitLab release pipeline
Replace the legacy Go implementation as the production path, quarantine the reference tree, add deterministic release packaging and version metadata, and run verification through GitLab CI.
2026-09-25 21:33:46 +05:30

902 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Result<AuthTicket, Error>>),
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<SessionDescriptor>,
accounts: Vec<Identity>,
account_index: Option<usize>,
faillock_receiver: Option<Receiver<Result<FaillockStatus, Error>>>,
faillock_status: Option<FaillockStatus>,
selected: usize,
focus: Focus,
username: String,
password: Zeroizing<String>,
status: String,
auth_state: AuthState,
show_help: bool,
power_confirmation: Option<usize>,
should_quit: bool,
ascii: bool,
no_color: bool,
background: Background,
notice: Option<Notice>,
animation_frame: usize,
reduced_motion: bool,
}
impl App {
fn new(
config: Config,
auth: AuthService,
preview: bool,
sessions: Vec<SessionDescriptor>,
accounts: Vec<Identity>,
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<Option<LoginSelection>, 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<Result<AuthTicket, Error>> {
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<ListItem> = 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<Option<LoginSelection>, 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::<String>())
.collect::<Vec<_>>()
.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::<String>())
.collect::<Vec<_>>()
.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));
}
}