refactor: split the TUI and refresh project docs
Move login state, authentication, discovery, input, rendering, and tests into focused TUI modules. Add detailed CLI error reports with recovery commands, align the build and host-check documentation, and keep the legacy Go tree outside normal builds. Checks: make check; make integration-test; go test ./... (legacy/go).
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
//! PAM authentication with one dedicated worker thread.
|
||||
//! Authentication worker and session handoff.
|
||||
//!
|
||||
//! The worker owns all PAM handles. Public tickets contain only an opaque ID.
|
||||
//! Keep handles on one thread; public tickets contain only an opaque ID.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
@@ -108,7 +108,7 @@ unsafe extern "C" {
|
||||
fn pam_strerror(pamh: *const PamHandle, pam_status: c_int) -> *const c_char;
|
||||
}
|
||||
|
||||
/// An opaque authenticated PAM transaction.
|
||||
/// Opaque ticket for an authenticated transaction.
|
||||
pub struct AuthTicket {
|
||||
id: u64,
|
||||
identity: Identity,
|
||||
@@ -153,7 +153,7 @@ enum Command {
|
||||
},
|
||||
}
|
||||
|
||||
/// Serializes PAM operations on a dedicated worker thread.
|
||||
/// Run handle-based operations on one worker thread.
|
||||
#[derive(Clone)]
|
||||
pub struct AuthService {
|
||||
commands: SyncSender<Command>,
|
||||
@@ -192,10 +192,10 @@ impl AuthService {
|
||||
Ok(ticket)
|
||||
}
|
||||
|
||||
/// Changes an expired account password through the configured PAM policy.
|
||||
/// Change an expired account password through the configured policy.
|
||||
///
|
||||
/// PAM enforces password strength, history, and token rules.
|
||||
/// Zeroizing buffers hold both passwords and errors never include them.
|
||||
/// The configured policy checks password strength, history, and token rules
|
||||
/// zeroizing buffers hold both passwords.
|
||||
pub fn change_password(
|
||||
&self,
|
||||
username: String,
|
||||
@@ -218,7 +218,7 @@ impl AuthService {
|
||||
.map_err(|_| Error::Worker("PAM worker disconnected".into()))?
|
||||
}
|
||||
|
||||
/// Runs the authenticated session and closes its PAM transaction.
|
||||
/// Run the authenticated session and close its transaction.
|
||||
pub fn run_session(&self, mut ticket: AuthTicket, spec: SessionSpec) -> Result<(), Error> {
|
||||
let (response, result) = sync_channel(0);
|
||||
let id = ticket.id;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Linux UTMPX accounting with idempotent RAII cleanup.
|
||||
//! Write and close UTMPX records for user sessions.
|
||||
|
||||
use std::{io, mem, os::raw::c_char, ptr};
|
||||
|
||||
@@ -10,7 +10,7 @@ pub struct UtmpxGuard {
|
||||
}
|
||||
|
||||
impl UtmpxGuard {
|
||||
/// Adds a user-process record for a child and returns its cleanup guard.
|
||||
/// Add a user-process record and return the cleanup guard.
|
||||
pub fn start(username: &str, tty: u8, pid: libc::pid_t) -> Result<Self, Error> {
|
||||
let mut record: libc::utmpx = unsafe { mem::zeroed() };
|
||||
record.ut_type = libc::USER_PROCESS;
|
||||
@@ -26,7 +26,6 @@ impl UtmpxGuard {
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes the corresponding dead-process record. Calling it twice is safe.
|
||||
pub fn close(&mut self) -> Result<(), Error> {
|
||||
if self.closed {
|
||||
return Ok(());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! NSS account discovery and advisory faillock status.
|
||||
//! Discover login accounts and display account policy state.
|
||||
|
||||
use std::{
|
||||
ffi::CStr,
|
||||
@@ -23,7 +23,7 @@ pub struct Identity {
|
||||
pub shell: PathBuf,
|
||||
}
|
||||
|
||||
/// Effective account state determined from the login shell and shadow aging data.
|
||||
/// Account state derived from the shell and shadow aging fields.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AccountState {
|
||||
Active,
|
||||
@@ -32,7 +32,7 @@ pub enum AccountState {
|
||||
AccountExpired,
|
||||
AccountInactive,
|
||||
NoLoginShell,
|
||||
/// Shadow data was not available or could not be parsed.
|
||||
/// Shadow data was missing or could not be parsed.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ pub struct ActiveSession {
|
||||
pub host: Option<String>,
|
||||
}
|
||||
|
||||
/// Runtime session-management backend detected on this host.
|
||||
/// Session backend found on the host.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SessionBackend {
|
||||
SystemdLogind,
|
||||
@@ -81,7 +81,7 @@ struct ShadowEntry {
|
||||
account_expiry: Option<i64>,
|
||||
}
|
||||
|
||||
/// A non-authoritative account lockout summary.
|
||||
/// Parsed faillock counts for the selected account.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct FaillockStatus {
|
||||
pub available: bool,
|
||||
@@ -91,7 +91,7 @@ pub struct FaillockStatus {
|
||||
pub locked_out: bool,
|
||||
}
|
||||
|
||||
/// Enumerates interactive accounts using NSS and deterministic ordering.
|
||||
/// Read interactive accounts through NSS and sort them by username.
|
||||
pub fn discover(config: &AccountsConfig) -> Vec<Identity> {
|
||||
let mut accounts = Vec::new();
|
||||
unsafe {
|
||||
@@ -128,9 +128,8 @@ pub fn discover(config: &AccountsConfig) -> Vec<Identity> {
|
||||
accounts
|
||||
}
|
||||
|
||||
/// Discovers identities and attaches account-aging state when the shadow
|
||||
/// database is readable. A missing or inaccessible shadow file yields
|
||||
/// [`AccountState::Unknown`] rather than hiding the account.
|
||||
/// Read identities and attach shadow aging state when the file is available.
|
||||
/// Missing shadow data produces [`AccountState::Unknown`] for that account.
|
||||
pub fn discover_with_status(config: &AccountsConfig) -> Vec<AccountRecord> {
|
||||
discover(config)
|
||||
.into_iter()
|
||||
@@ -147,8 +146,8 @@ pub fn discover_with_status(config: &AccountsConfig) -> Vec<AccountRecord> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Inspects password and account aging fields without returning password
|
||||
/// hashes. The configured shadow path must be root-owned and non-writable.
|
||||
/// Read aging fields without returning password hashes; the shadow path must
|
||||
/// be root-owned and not writable by group or other users.
|
||||
pub fn inspect_account(
|
||||
identity: &Identity,
|
||||
config: &AccountsConfig,
|
||||
@@ -168,7 +167,7 @@ pub fn inspect_account(
|
||||
Ok(account_status_from_shadow(identity, entry, day))
|
||||
}
|
||||
|
||||
/// Returns all active UTMPX user-process records in deterministic order.
|
||||
/// Read active UTMPX user-process records in sorted order.
|
||||
pub fn active_sessions() -> Vec<ActiveSession> {
|
||||
let mut sessions = Vec::new();
|
||||
unsafe {
|
||||
@@ -206,7 +205,7 @@ pub fn active_sessions() -> Vec<ActiveSession> {
|
||||
sessions
|
||||
}
|
||||
|
||||
/// Detects whether systemd-logind has initialized its runtime seat state.
|
||||
/// Check whether systemd-logind has initialized its runtime seat state.
|
||||
pub fn session_backend() -> SessionBackend {
|
||||
if logind_markers_present(
|
||||
Path::new("/run/systemd/system").is_dir(),
|
||||
@@ -218,7 +217,7 @@ pub fn session_backend() -> SessionBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the configured helper and parses its stable numeric output.
|
||||
/// Run the configured helper and parse its numeric output.
|
||||
pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<FaillockStatus, Error> {
|
||||
let helper = &config.faillock_path;
|
||||
if !helper.is_absolute() {
|
||||
@@ -257,8 +256,7 @@ pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result<Faillo
|
||||
Ok(faillock_status_from_output(&output.stdout, max_tries))
|
||||
}
|
||||
|
||||
/// Parses the legacy count format and current rows with usernames or timestamps.
|
||||
/// Malformed output reports zero failures and does not control authentication.
|
||||
/// Parse legacy counts and timestamp rows; bad output produces zero failures.
|
||||
pub fn faillock_status_from_output(output: &[u8], max_tries: Option<u32>) -> FaillockStatus {
|
||||
let failures = parse_failure_count(output);
|
||||
let remaining = max_tries.map(|limit| limit.saturating_sub(failures));
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
//! Redacted security audit events.
|
||||
//!
|
||||
//! Audit records exclude secrets, PAM messages, and command arguments.
|
||||
//! Logs audit events for login operations structurally, without including free-form messages from PAM modules that may contain sensitive information.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// The security operation that produced an audit event.
|
||||
/// Login operation that produced an audit event.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuditAction {
|
||||
Authentication,
|
||||
@@ -24,7 +22,7 @@ impl fmt::Display for AuditAction {
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of a security operation.
|
||||
/// Result recorded for a login operation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuditOutcome {
|
||||
Started,
|
||||
@@ -44,7 +42,7 @@ impl fmt::Display for AuditOutcome {
|
||||
}
|
||||
}
|
||||
|
||||
/// A safe category for a PAM failure, without exposing PAM's free-form text.
|
||||
/// Stable category for a PAM status without its free-form message.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PamReason {
|
||||
AuthenticationRejected,
|
||||
@@ -72,12 +70,12 @@ impl fmt::Display for PamReason {
|
||||
}
|
||||
}
|
||||
|
||||
/// A structured event whose fields are safe to send to an audit logger.
|
||||
/// Event fields intended for the audit logger.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AuditEvent<'a> {
|
||||
pub action: AuditAction,
|
||||
pub outcome: AuditOutcome,
|
||||
/// Account name supplied by the user; never a password or token.
|
||||
/// Account name supplied by the user, never a password or token.
|
||||
pub account: &'a str,
|
||||
pub pam_reason: Option<PamReason>,
|
||||
}
|
||||
@@ -115,7 +113,7 @@ impl<'a> AuditEvent<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps Linux-PAM status codes to stable, non-sensitive categories.
|
||||
/// Map Linux-PAM status codes to stable categories.
|
||||
pub const fn pam_reason(status: i32) -> PamReason {
|
||||
match status {
|
||||
6 | 7 => PamReason::AuthenticationRejected,
|
||||
|
||||
@@ -13,7 +13,7 @@ pub const SYSTEM_CONFIG_PATH: &str = "/etc/latchd/config.toml";
|
||||
const PROJECT_CONFIG_DIR: &str = "latchd";
|
||||
const CONFIG_FILE: &str = "config.toml";
|
||||
|
||||
/// Returns the current user's configuration path.
|
||||
/// Build the current user's configuration path from XDG variables.
|
||||
pub fn user_config_path() -> Option<PathBuf> {
|
||||
let base = env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
@@ -184,7 +184,7 @@ pub struct PowerAction {
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
/// Loads a configuration file over the built-in defaults.
|
||||
/// Load a TOML file over the built-in defaults and validate it.
|
||||
pub fn load(path: Option<&Path>, variables: Option<&Path>) -> Result<Config, Error> {
|
||||
let mut config = Config::default();
|
||||
if let Some(variables_path) = variables {
|
||||
@@ -231,7 +231,7 @@ fn resolve_variables(input: &str, variables: &HashMap<String, String>) -> String
|
||||
})
|
||||
}
|
||||
|
||||
/// Validates values that must be safe before starting the login screen.
|
||||
/// Validate values and paths before the login screen starts.
|
||||
pub fn validate(config: &Config) -> Result<(), Error> {
|
||||
if config.tty == 0 || config.tty > 63 {
|
||||
return Err(Error::Config("tty must be between 1 and 63".into()));
|
||||
@@ -314,13 +314,13 @@ pub fn validate(config: &Config) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rejects a privileged configuration source that can be replaced by another user.
|
||||
/// Require a root-owned regular file without group or other write permission.
|
||||
pub fn validate_source(path: &Path, label: &str) -> Result<(), Error> {
|
||||
let metadata =
|
||||
fs::symlink_metadata(path).io_context(format!("inspect {label} {}", path.display()))?;
|
||||
if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 {
|
||||
return Err(Error::Config(format!(
|
||||
"{label} must be a root-owned, non-group-writable regular file: {}",
|
||||
"{label} must be owned by root, non-group-writable regular file: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
use std::io;
|
||||
use std::{fmt::Write as _, io};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("configuration: {0}")]
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
#[error("{context}: {source}")]
|
||||
#[error("I/O error while {context}: {source}")]
|
||||
Io {
|
||||
context: String,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
#[error("parse {path}: {source}")]
|
||||
#[error("TOML error in {path}: {source}")]
|
||||
Toml {
|
||||
path: String,
|
||||
#[source]
|
||||
source: toml::de::Error,
|
||||
},
|
||||
#[error("PAM {operation} failed with status {status}: {message}")]
|
||||
#[error("PAM error during {operation} (status {status}): {message}")]
|
||||
Pam {
|
||||
operation: &'static str,
|
||||
status: i32,
|
||||
message: String,
|
||||
},
|
||||
#[error("session: {0}")]
|
||||
#[error("Session error: {0}")]
|
||||
Session(String),
|
||||
#[error("terminal: {0}")]
|
||||
#[error("Terminal error: {0}")]
|
||||
Terminal(String),
|
||||
#[error("authentication worker: {0}")]
|
||||
#[error("Authentication worker error: {0}")]
|
||||
Worker(String),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Returns a PAM status when this error came directly from PAM.
|
||||
/// Return the PAM status when the error came from PAM.
|
||||
pub fn pam_status(&self) -> Option<i32> {
|
||||
match self {
|
||||
Self::Pam { status, .. } => Some(*status),
|
||||
@@ -41,10 +41,88 @@ impl Error {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether PAM requires a password change before login can finish.
|
||||
/// Check whether the status requests a password change.
|
||||
pub fn requires_password_change(&self) -> bool {
|
||||
matches!(self.pam_status(), Some(12 | 27))
|
||||
}
|
||||
|
||||
/// Build a user-facing report with context and recovery commands.
|
||||
pub fn user_report(&self) -> String {
|
||||
let mut report = format!("{}\n\nDetails: {}\n\nTry:\n", self, self.details());
|
||||
for fix in self.fixes() {
|
||||
let _ = writeln!(report, " - {fix}");
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
fn details(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Config(_) => {
|
||||
"latchd rejected a configuration value before it opened the login screen."
|
||||
}
|
||||
Self::Io { .. } => {
|
||||
"The operating system rejected a file operation. The path, owner, mode, or free space can be wrong."
|
||||
}
|
||||
Self::Toml { .. } => {
|
||||
"The TOML parser found an invalid key, value, or separator at the location shown above."
|
||||
}
|
||||
Self::Pam { .. } => {
|
||||
"The login service returned a PAM status. The status and module message appear above."
|
||||
}
|
||||
Self::Session(_) => "latchd could not prepare or start the selected desktop session.",
|
||||
Self::Terminal(_) => {
|
||||
"The terminal does not provide the input or output device that latchd needs."
|
||||
}
|
||||
Self::Worker(_) => {
|
||||
"A background latchd task stopped before it completed the requested operation."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fixes(&self) -> Vec<String> {
|
||||
match self {
|
||||
Self::Config(_) => vec![
|
||||
"Edit the value named in the error.".into(),
|
||||
"Run `latchd --preview --no-tui` to check the user configuration.".into(),
|
||||
"Run `sudo latchd --no-tui` to check the system configuration.".into(),
|
||||
],
|
||||
Self::Io { .. } => vec![
|
||||
"Check the path and permissions named in the error.".into(),
|
||||
"Run `df -h` to check free space.".into(),
|
||||
"Run `sudo journalctl -u latchd.service -b` to read the service log.".into(),
|
||||
],
|
||||
Self::Toml { path, .. } => {
|
||||
let path = shell_quote(path);
|
||||
vec![
|
||||
format!("Open {path} at the reported line and column."),
|
||||
format!("Run `latchd --preview --no-tui --config {path}` after the edit."),
|
||||
]
|
||||
}
|
||||
Self::Pam { .. } => vec![
|
||||
"Run `sudo journalctl -u latchd.service -b` to read the login service log.".into(),
|
||||
"Run `sudo faillock --user <account>` to check the account lock state.".into(),
|
||||
"Check the PAM service file with `sudoedit /etc/pam.d/<pam-service>`.".into(),
|
||||
],
|
||||
Self::Session(_) => vec![
|
||||
"Run `latchd --preview --no-tui` to validate the configuration and discovered sessions.".into(),
|
||||
"Run `sudo journalctl -u latchd.service -b` to read the session log.".into(),
|
||||
"Check the selected desktop command with `command -v <program>`.".into(),
|
||||
],
|
||||
Self::Terminal(_) => vec![
|
||||
"Run `tty` to confirm that the process has a controlling terminal.".into(),
|
||||
"Run `latchd --preview` to test the screen without a real login.".into(),
|
||||
"Run `sudo systemctl restart latchd.service` after you attach latchd to a real VT.".into(),
|
||||
],
|
||||
Self::Worker(_) => vec![
|
||||
"Run `sudo systemctl restart latchd.service` to restart the worker.".into(),
|
||||
"Run `sudo journalctl -u latchd.service -b` to find the worker failure.".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||
}
|
||||
|
||||
pub fn io_error(context: impl Into<String>, source: io::Error) -> Error {
|
||||
@@ -63,3 +141,27 @@ impl<T> ResultExt<T> for io::Result<T> {
|
||||
self.map_err(|source| io_error(context, source))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn configuration_report_contains_details_and_commands() {
|
||||
let report = Error::Config("tty must be between 1 and 63".into()).user_report();
|
||||
assert!(report.contains("Details:"));
|
||||
assert!(report.contains("latchd --preview --no-tui"));
|
||||
assert!(report.contains("sudo latchd --no-tui"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_report_quotes_the_source_path() {
|
||||
let error = Error::Toml {
|
||||
path: "/tmp/latchd config.toml".into(),
|
||||
source: toml::from_str::<toml::Value>("=").expect_err("invalid TOML"),
|
||||
};
|
||||
let report = error.user_report();
|
||||
assert!(report.contains("'/tmp/latchd config.toml'"));
|
||||
assert!(report.contains("--config '/tmp/latchd config.toml'"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Core configuration, account, session, and accounting contracts for latchd.
|
||||
//! Shared configuration, account, session, and accounting types.
|
||||
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Bounded, owner-checked persistence for last-used session choices.
|
||||
//! Store the last session choice with private, atomic writes.
|
||||
|
||||
use std::{
|
||||
fs::{self, OpenOptions},
|
||||
@@ -32,7 +32,7 @@ pub struct LastUsedPreferences {
|
||||
}
|
||||
|
||||
impl LastUsedPreferences {
|
||||
/// Records a successful selection, moving the newest entry to the front.
|
||||
/// Record a successful selection and move it to the front.
|
||||
pub fn remember(&mut self, username: &str, session: &SessionDescriptor) {
|
||||
if username.is_empty() || session.title.is_empty() {
|
||||
return;
|
||||
@@ -49,7 +49,7 @@ impl LastUsedPreferences {
|
||||
self.entries.truncate(MAX_ENTRIES);
|
||||
}
|
||||
|
||||
/// Finds the exact previously selected session for an account.
|
||||
/// Find the saved session for an account.
|
||||
pub fn preferred_index(&self, username: &str, sessions: &[SessionDescriptor]) -> Option<usize> {
|
||||
let entry = self
|
||||
.entries
|
||||
@@ -65,7 +65,7 @@ impl LastUsedPreferences {
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads preferences from an owner-checked TOML file.
|
||||
/// Load preferences from an owner-checked TOML file.
|
||||
pub fn load(path: &Path) -> Result<LastUsedPreferences, Error> {
|
||||
if !path.is_absolute() {
|
||||
return Err(Error::Config("last-session path must be absolute".into()));
|
||||
@@ -93,7 +93,7 @@ pub fn load(path: &Path) -> Result<LastUsedPreferences, Error> {
|
||||
Ok(preferences)
|
||||
}
|
||||
|
||||
/// Atomically saves preferences with mode `0600` under a trusted directory.
|
||||
/// Save preferences with mode `0600` under a trusted directory.
|
||||
pub fn save(path: &Path, preferences: &LastUsedPreferences) -> Result<(), Error> {
|
||||
if !path.is_absolute() {
|
||||
return Err(Error::Config("last-session path must be absolute".into()));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Session discovery, environment construction, and child lifecycle.
|
||||
//! Discover sessions, build their environments, and run their children.
|
||||
|
||||
use std::{
|
||||
ffi::{CString, OsStr},
|
||||
@@ -41,7 +41,7 @@ pub enum SessionKind {
|
||||
pub struct SessionDescriptor {
|
||||
pub kind: SessionKind,
|
||||
pub title: String,
|
||||
/// Program and arguments, without shell interpretation.
|
||||
/// Program and arguments kept separate from shell parsing.
|
||||
pub argv: Vec<String>,
|
||||
pub current_desktop: Option<String>,
|
||||
pub session_desktop: Option<String>,
|
||||
@@ -82,7 +82,7 @@ impl SessionSpec {
|
||||
format!("tty{}", self.tty)
|
||||
}
|
||||
|
||||
/// Returns the selected-session metadata that PAM and logind must see.
|
||||
/// Return session metadata for the login stack and logind.
|
||||
pub fn pam_environment(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.environment
|
||||
.iter()
|
||||
@@ -91,7 +91,7 @@ impl SessionSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Discovers configured sessions in stable directory order.
|
||||
/// Discover configured sessions in sorted directory order.
|
||||
pub fn discover(config: &Config) -> Vec<SessionDescriptor> {
|
||||
let mut sessions = Vec::new();
|
||||
scan_desktop_dir(
|
||||
@@ -124,7 +124,7 @@ pub fn discover(config: &Config) -> Vec<SessionDescriptor> {
|
||||
sessions
|
||||
}
|
||||
|
||||
/// Checks configured binaries and discovered session entries without starting them.
|
||||
/// Check configured binaries and session entries without starting them.
|
||||
pub fn preflight(config: &Config) -> Result<(), Error> {
|
||||
let shell = secure_executable(&config.system_shell, "configured system shell")?;
|
||||
if !shell.is_file() {
|
||||
@@ -192,7 +192,7 @@ fn secure_executable(path: &Path, label: &str) -> Result<PathBuf, Error> {
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// Builds an explicit environment and process specification for one identity.
|
||||
/// Build the environment and process specification for one account.
|
||||
pub fn build_spec(
|
||||
identity: &Identity,
|
||||
config: &Config,
|
||||
@@ -328,7 +328,7 @@ pub fn build_spec(
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs a user session and owns all child process cleanup.
|
||||
/// Run a user session and clean up its child process.
|
||||
pub fn run_user_session(identity: &Identity, spec: &SessionSpec) -> Result<(), Error> {
|
||||
let home_metadata = fs::symlink_metadata(&identity.home)
|
||||
.map_err(|error| io_error(format!("inspect home {}", identity.home.display()), error))?;
|
||||
|
||||
264
crates/latchd-tui/src/app.rs
Normal file
264
crates/latchd-tui/src/app.rs
Normal file
@@ -0,0 +1,264 @@
|
||||
//! Login screen state and orchestration.
|
||||
|
||||
use std::{
|
||||
sync::mpsc::Receiver,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crossterm::event::{self, Event, KeyEventKind};
|
||||
use ratatui::DefaultTerminal;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use latchd_auth::{AuthService, AuthTicket};
|
||||
use latchd_core::{
|
||||
accounts::{self, AccountState, AccountStatus, FaillockStatus, Identity},
|
||||
config::Config,
|
||||
error::{Error, ResultExt},
|
||||
preferences::{self, LastUsedPreferences},
|
||||
session::{self, SessionDescriptor},
|
||||
};
|
||||
|
||||
use crate::background::Background;
|
||||
|
||||
mod auth;
|
||||
mod discovery;
|
||||
mod input;
|
||||
mod render;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
const MIN_WIDTH: u16 = 60;
|
||||
const MIN_HEIGHT: u16 = 16;
|
||||
const MAX_INPUT: usize = 256;
|
||||
|
||||
fn unknown_account_status() -> AccountStatus {
|
||||
AccountStatus {
|
||||
state: AccountState::Unknown,
|
||||
password_changed_day: None,
|
||||
password_expiry_day: None,
|
||||
account_expiry_day: None,
|
||||
inactive_after_day: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LoginSelection {
|
||||
pub ticket: AuthTicket,
|
||||
pub session: SessionDescriptor,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
Sessions,
|
||||
Username,
|
||||
Password,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FilterTarget {
|
||||
Sessions,
|
||||
Accounts,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FilterState {
|
||||
target: FilterTarget,
|
||||
query: String,
|
||||
}
|
||||
|
||||
type DiscoverySnapshot = (Vec<SessionDescriptor>, Vec<Identity>, Vec<AccountStatus>);
|
||||
|
||||
#[derive(Debug)]
|
||||
enum AuthState {
|
||||
Idle,
|
||||
Pending(Receiver<Result<AuthTicket, Error>>),
|
||||
PasswordChangePending(Receiver<Result<(), Error>>),
|
||||
Failed,
|
||||
}
|
||||
|
||||
enum AuthCompletion {
|
||||
Login(Result<AuthTicket, Error>),
|
||||
PasswordChange(Result<(), Error>),
|
||||
}
|
||||
|
||||
#[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_statuses: Vec<AccountStatus>,
|
||||
account_index: Option<usize>,
|
||||
faillock_receiver: Option<Receiver<Result<FaillockStatus, Error>>>,
|
||||
faillock_status: Option<FaillockStatus>,
|
||||
selected: usize,
|
||||
focus: Focus,
|
||||
username: String,
|
||||
password: Zeroizing<String>,
|
||||
pending_password: Zeroizing<Vec<u8>>,
|
||||
pending_new_password: Zeroizing<String>,
|
||||
password_change_mode: bool,
|
||||
status: String,
|
||||
auth_state: AuthState,
|
||||
auth_failures: u32,
|
||||
auth_blocked_until: Option<Instant>,
|
||||
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,
|
||||
filter: Option<FilterState>,
|
||||
refresh_receiver: Option<Receiver<DiscoverySnapshot>>,
|
||||
refreshing: bool,
|
||||
high_contrast: bool,
|
||||
preferences: LastUsedPreferences,
|
||||
preference_path: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
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,
|
||||
account_statuses: vec![unknown_account_status(); accounts.len()],
|
||||
accounts,
|
||||
account_index,
|
||||
faillock_receiver: None,
|
||||
faillock_status: None,
|
||||
selected: 0,
|
||||
focus: Focus::Username,
|
||||
username,
|
||||
password: Zeroizing::new(String::new()),
|
||||
pending_password: Zeroizing::new(Vec::new()),
|
||||
pending_new_password: Zeroizing::new(String::new()),
|
||||
password_change_mode: false,
|
||||
status: "Enter your username".into(),
|
||||
auth_state: AuthState::Idle,
|
||||
auth_failures: 0,
|
||||
auth_blocked_until: None,
|
||||
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,
|
||||
filter: None,
|
||||
refresh_receiver: None,
|
||||
refreshing: false,
|
||||
high_contrast: std::env::var_os("LATCHD_HIGH_CONTRAST").is_some(),
|
||||
preferences: LastUsedPreferences::default(),
|
||||
preference_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<Option<LoginSelection>, Error> {
|
||||
loop {
|
||||
self.poll_faillock();
|
||||
self.poll_refresh();
|
||||
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(completion) = self.poll_auth() {
|
||||
if let Some(selection) = self.handle_auth_completion(completion)? {
|
||||
return Ok(Some(selection));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the login screen and restore 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, account_statuses) = if config.accounts.enumerate {
|
||||
let records = accounts::discover_with_status(&config.accounts);
|
||||
records
|
||||
.into_iter()
|
||||
.map(|record| (record.identity, record.status))
|
||||
.unzip()
|
||||
} else {
|
||||
(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()))
|
||||
}
|
||||
229
crates/latchd-tui/src/app/auth.rs
Normal file
229
crates/latchd-tui/src/app/auth.rs
Normal file
@@ -0,0 +1,229 @@
|
||||
use std::{
|
||||
sync::mpsc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use latchd_core::{
|
||||
audit::{AuditAction, AuditEvent},
|
||||
error::Error,
|
||||
};
|
||||
|
||||
use super::{App, AuthCompletion, AuthState, Focus, LoginSelection, NoticeLevel};
|
||||
|
||||
impl App {
|
||||
pub(super) fn poll_auth(&mut self) -> Option<AuthCompletion> {
|
||||
match &self.auth_state {
|
||||
AuthState::Pending(receiver) => match receiver.try_recv() {
|
||||
Ok(result) => Some(AuthCompletion::Login(result)),
|
||||
Err(mpsc::TryRecvError::Empty) => None,
|
||||
Err(mpsc::TryRecvError::Disconnected) => Some(AuthCompletion::Login(Err(
|
||||
Error::Worker("authentication task disconnected".into()),
|
||||
))),
|
||||
},
|
||||
AuthState::PasswordChangePending(receiver) => match receiver.try_recv() {
|
||||
Ok(result) => Some(AuthCompletion::PasswordChange(result)),
|
||||
Err(mpsc::TryRecvError::Empty) => None,
|
||||
Err(mpsc::TryRecvError::Disconnected) => Some(AuthCompletion::PasswordChange(Err(
|
||||
Error::Worker("password-change task disconnected".into()),
|
||||
))),
|
||||
},
|
||||
AuthState::Idle | AuthState::Failed => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_auth_completion(
|
||||
&mut self,
|
||||
completion: AuthCompletion,
|
||||
) -> Result<Option<LoginSelection>, Error> {
|
||||
match completion {
|
||||
AuthCompletion::Login(Ok(ticket)) => {
|
||||
self.audit(AuditEvent::succeeded(
|
||||
AuditAction::Authentication,
|
||||
&self.username,
|
||||
));
|
||||
self.auth_failures = 0;
|
||||
self.auth_blocked_until = None;
|
||||
self.pending_password.clear();
|
||||
let selected = self
|
||||
.sessions
|
||||
.get(self.selected)
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::Session("no session selected".into()))?;
|
||||
self.remember_selected_session(&selected);
|
||||
Ok(Some(LoginSelection {
|
||||
ticket,
|
||||
session: selected,
|
||||
}))
|
||||
}
|
||||
AuthCompletion::Login(Err(error)) => {
|
||||
let audit = AuditEvent::failed(AuditAction::Authentication, &self.username, &error);
|
||||
self.audit(audit);
|
||||
if error.requires_password_change() {
|
||||
self.auth_state = AuthState::Idle;
|
||||
self.password_change_mode = true;
|
||||
self.password.clear();
|
||||
self.auth_failures = 0;
|
||||
self.auth_blocked_until = None;
|
||||
self.focus = Focus::Password;
|
||||
self.status = "PAM requires a new password; enter it and press Enter".into();
|
||||
self.notify("Password change required by PAM", NoticeLevel::Warning);
|
||||
} else {
|
||||
self.auth_state = AuthState::Failed;
|
||||
self.pending_password.clear();
|
||||
self.password.clear();
|
||||
self.auth_failures = self.auth_failures.saturating_add(1);
|
||||
self.auth_blocked_until =
|
||||
Some(Instant::now() + auth_backoff(self.auth_failures));
|
||||
self.status = "Authentication failed".into();
|
||||
self.notify("Authentication failed", NoticeLevel::Warning);
|
||||
self.start_faillock_query();
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
AuthCompletion::PasswordChange(Ok(())) => {
|
||||
self.audit(AuditEvent::succeeded(
|
||||
AuditAction::PasswordChange,
|
||||
&self.username,
|
||||
));
|
||||
self.password = std::mem::replace(
|
||||
&mut self.pending_new_password,
|
||||
Zeroizing::new(String::new()),
|
||||
);
|
||||
self.pending_password.clear();
|
||||
self.password_change_mode = false;
|
||||
self.auth_state = AuthState::Idle;
|
||||
self.status = "Password changed; authenticating…".into();
|
||||
self.notify("Password changed; signing in", NoticeLevel::Info);
|
||||
self.begin_authentication();
|
||||
Ok(None)
|
||||
}
|
||||
AuthCompletion::PasswordChange(Err(error)) => {
|
||||
self.audit(AuditEvent::failed(
|
||||
AuditAction::PasswordChange,
|
||||
&self.username,
|
||||
&error,
|
||||
));
|
||||
self.auth_state = AuthState::Failed;
|
||||
self.password_change_mode = false;
|
||||
self.pending_password.clear();
|
||||
self.pending_new_password.clear();
|
||||
self.password.clear();
|
||||
self.status = "PAM rejected the password change".into();
|
||||
self.notify("Password change failed", NoticeLevel::Warning);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn audit(&self, event: AuditEvent<'_>) {
|
||||
tracing::info!(
|
||||
target: "latchd.audit",
|
||||
action = %event.action,
|
||||
outcome = %event.outcome,
|
||||
account = %event.account,
|
||||
pam_reason = ?event.pam_reason,
|
||||
"security event"
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn begin_authentication(&mut self) {
|
||||
if self.preview {
|
||||
self.status = "Preview mode — authentication and session startup are disabled".into();
|
||||
return;
|
||||
}
|
||||
if self.password_change_mode {
|
||||
self.begin_password_change();
|
||||
return;
|
||||
}
|
||||
if self.username.is_empty() || self.password.is_empty() {
|
||||
self.status = "Username and password are required".into();
|
||||
return;
|
||||
}
|
||||
if let Some(until) = self.auth_blocked_until {
|
||||
if let Some(remaining) = until.checked_duration_since(Instant::now()) {
|
||||
self.status = format!(
|
||||
"Authentication paused after failed attempts; try again in {}s",
|
||||
remaining.as_secs().saturating_add(1)
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.auth_blocked_until = None;
|
||||
}
|
||||
let username = self.username.clone();
|
||||
let password = self.password.as_bytes().to_vec();
|
||||
self.pending_password = Zeroizing::new(password.clone());
|
||||
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.audit(AuditEvent::started(
|
||||
AuditAction::Authentication,
|
||||
&self.username,
|
||||
));
|
||||
self.auth_state = AuthState::Pending(receiver);
|
||||
self.status = "Authenticating…".into();
|
||||
self.password.clear();
|
||||
}
|
||||
Err(error) => {
|
||||
self.pending_password.clear();
|
||||
self.status = format!("Could not start authentication: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn begin_password_change(&mut self) {
|
||||
if self.pending_password.is_empty() {
|
||||
self.password_change_mode = false;
|
||||
self.status = "Password-change context expired; authenticate again".into();
|
||||
return;
|
||||
}
|
||||
if self.password.is_empty() {
|
||||
self.status = "A new password is required".into();
|
||||
return;
|
||||
}
|
||||
let username = self.username.clone();
|
||||
let current_password = self.pending_password.to_vec();
|
||||
let new_password = self.password.as_bytes().to_vec();
|
||||
self.pending_new_password = Zeroizing::new(self.password.as_str().to_owned());
|
||||
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-password-change".into())
|
||||
.spawn(move || {
|
||||
let result =
|
||||
auth.change_password(username, current_password, new_password, service);
|
||||
let _ = sender.send(result);
|
||||
});
|
||||
match task {
|
||||
Ok(_) => {
|
||||
self.audit(AuditEvent::started(
|
||||
AuditAction::PasswordChange,
|
||||
&self.username,
|
||||
));
|
||||
self.auth_state = AuthState::PasswordChangePending(receiver);
|
||||
self.status = "Changing password through PAM…".into();
|
||||
self.password.clear();
|
||||
}
|
||||
Err(error) => {
|
||||
self.pending_new_password.clear();
|
||||
self.status = format!("Could not start password change: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn auth_backoff(failures: u32) -> Duration {
|
||||
let shift = failures.saturating_sub(1).min(4);
|
||||
Duration::from_secs(1u64 << shift)
|
||||
}
|
||||
172
crates/latchd-tui/src/app/discovery.rs
Normal file
172
crates/latchd-tui/src/app/discovery.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
|
||||
use latchd_core::{accounts, preferences, session, session::SessionDescriptor};
|
||||
|
||||
use super::{App, NoticeLevel, unknown_account_status};
|
||||
|
||||
impl App {
|
||||
pub(super) fn apply_preferred_session(&mut self) {
|
||||
if let Some(index) = self
|
||||
.preferences
|
||||
.preferred_index(&self.username, &self.sessions)
|
||||
{
|
||||
self.selected = index;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn poll_refresh(&mut self) {
|
||||
let Some(receiver) = &self.refresh_receiver else {
|
||||
return;
|
||||
};
|
||||
match receiver.try_recv() {
|
||||
Ok((sessions, accounts, account_statuses)) => {
|
||||
self.sessions = sessions;
|
||||
self.accounts = accounts;
|
||||
self.account_statuses = account_statuses;
|
||||
self.refresh_receiver = None;
|
||||
self.refreshing = false;
|
||||
self.reconcile_selection();
|
||||
self.status = format!(
|
||||
"Discovery refreshed: {} session(s), {} account(s)",
|
||||
self.sessions.len(),
|
||||
self.accounts.len()
|
||||
);
|
||||
self.notify("Account and session discovery refreshed", NoticeLevel::Info);
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => {}
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
self.refresh_receiver = None;
|
||||
self.refreshing = false;
|
||||
self.status = "Discovery refresh failed".into();
|
||||
self.notify(
|
||||
"Could not refresh account and session discovery",
|
||||
NoticeLevel::Warning,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn start_refresh(&mut self) {
|
||||
if self.refreshing {
|
||||
self.status = "Discovery refresh is already running".into();
|
||||
return;
|
||||
}
|
||||
let config = self.config.clone();
|
||||
let (sender, receiver) = mpsc::sync_channel(1);
|
||||
let task = thread::Builder::new()
|
||||
.name("latchd-discovery".into())
|
||||
.spawn(move || {
|
||||
let sessions = session::discover(&config);
|
||||
let (accounts, account_statuses) = if config.accounts.enumerate {
|
||||
let records = accounts::discover_with_status(&config.accounts);
|
||||
records
|
||||
.into_iter()
|
||||
.map(|record| (record.identity, record.status))
|
||||
.unzip()
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
let _ = sender.send((sessions, accounts, account_statuses));
|
||||
});
|
||||
match task {
|
||||
Ok(_) => {
|
||||
self.refresh_receiver = Some(receiver);
|
||||
self.refreshing = true;
|
||||
self.status = "Refreshing accounts and sessions…".into();
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "could not start discovery refresh");
|
||||
self.status = format!("Could not start discovery refresh: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reconcile_selection(&mut self) {
|
||||
if self.sessions.is_empty() {
|
||||
self.selected = 0;
|
||||
} else if self.selected >= self.sessions.len() {
|
||||
self.selected = self.sessions.len() - 1;
|
||||
}
|
||||
if self.accounts.is_empty() {
|
||||
self.account_index = None;
|
||||
self.account_statuses.clear();
|
||||
} else if self.account_statuses.len() != self.accounts.len() {
|
||||
self.account_statuses
|
||||
.resize(self.accounts.len(), unknown_account_status());
|
||||
} else if self
|
||||
.account_index
|
||||
.is_some_and(|index| index >= self.accounts.len())
|
||||
{
|
||||
self.account_index = Some(0);
|
||||
}
|
||||
if let Some(index) = self.account_index {
|
||||
if let Some(account) = self.accounts.get(index) {
|
||||
self.username = account.username.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
449
crates/latchd-tui/src/app/input.rs
Normal file
449
crates/latchd-tui/src/app/input.rs
Normal file
@@ -0,0 +1,449 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use super::{App, AuthState, FilterState, FilterTarget, Focus, MAX_INPUT, Notice, NoticeLevel};
|
||||
|
||||
impl App {
|
||||
pub(super) 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn begin_filter(&mut self) {
|
||||
let target = match self.focus {
|
||||
Focus::Sessions => FilterTarget::Sessions,
|
||||
Focus::Username => FilterTarget::Accounts,
|
||||
Focus::Password => return,
|
||||
};
|
||||
self.filter = Some(FilterState {
|
||||
target,
|
||||
query: String::new(),
|
||||
});
|
||||
self.status = match target {
|
||||
FilterTarget::Sessions => {
|
||||
"Filter sessions: type text, Enter selects, Esc clears".into()
|
||||
}
|
||||
FilterTarget::Accounts => {
|
||||
"Filter accounts: type text, Enter selects, Esc clears".into()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(super) fn filter_key(&mut self, key: KeyEvent) {
|
||||
let Some(target) = self.filter.as_ref().map(|filter| filter.target) else {
|
||||
return;
|
||||
};
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.filter = None;
|
||||
self.status = "Filter cleared".into();
|
||||
}
|
||||
KeyCode::Enter => self.accept_filter(),
|
||||
KeyCode::Backspace => {
|
||||
if let Some(filter) = &mut self.filter {
|
||||
filter.query.pop();
|
||||
}
|
||||
self.update_filter_status();
|
||||
}
|
||||
KeyCode::Up | KeyCode::Left => match target {
|
||||
FilterTarget::Sessions => self.previous_session(),
|
||||
FilterTarget::Accounts => self.previous_account(),
|
||||
},
|
||||
KeyCode::Down | KeyCode::Right => match target {
|
||||
FilterTarget::Sessions => self.next_session(),
|
||||
FilterTarget::Accounts => self.next_account(),
|
||||
},
|
||||
KeyCode::Char(character)
|
||||
if !key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||||
&& self
|
||||
.filter
|
||||
.as_ref()
|
||||
.is_some_and(|filter| filter.query.chars().count() < MAX_INPUT) =>
|
||||
{
|
||||
if let Some(filter) = &mut self.filter {
|
||||
filter.query.push(character);
|
||||
}
|
||||
self.update_filter_status();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_filter_status(&mut self) {
|
||||
let Some(filter) = &self.filter else {
|
||||
return;
|
||||
};
|
||||
let count = match filter.target {
|
||||
FilterTarget::Sessions => self.matching_session_indices().len(),
|
||||
FilterTarget::Accounts => self.matching_account_indices().len(),
|
||||
};
|
||||
self.status = format!(
|
||||
"Filter {}: {} match(es) — Enter selects, Esc clears",
|
||||
match filter.target {
|
||||
FilterTarget::Sessions => "sessions",
|
||||
FilterTarget::Accounts => "accounts",
|
||||
},
|
||||
count
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn accept_filter(&mut self) {
|
||||
let Some(filter) = self.filter.take() else {
|
||||
return;
|
||||
};
|
||||
match filter.target {
|
||||
FilterTarget::Sessions => {
|
||||
let index = self
|
||||
.sessions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, session)| matches_filter(&session.title, &filter.query))
|
||||
.map(|(index, _)| index);
|
||||
if let Some(index) = index {
|
||||
self.selected = index;
|
||||
self.status = format!("Selected session {}", self.sessions[index].title);
|
||||
} else {
|
||||
self.status = "No matching sessions".into();
|
||||
}
|
||||
}
|
||||
FilterTarget::Accounts => {
|
||||
let index = self
|
||||
.accounts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, account)| matches_filter(&account.username, &filter.query))
|
||||
.map(|(index, _)| index);
|
||||
if let Some(index) = index {
|
||||
self.select_account_index(index);
|
||||
} else {
|
||||
self.status = "No matching accounts".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn matching_session_indices(&self) -> Vec<usize> {
|
||||
let query = self
|
||||
.filter
|
||||
.as_ref()
|
||||
.filter(|filter| filter.target == FilterTarget::Sessions)
|
||||
.map_or("", |filter| filter.query.as_str());
|
||||
self.sessions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, session)| matches_filter(&session.title, query))
|
||||
.map(|(index, _)| index)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn matching_account_indices(&self) -> Vec<usize> {
|
||||
let query = self
|
||||
.filter
|
||||
.as_ref()
|
||||
.filter(|filter| filter.target == FilterTarget::Accounts)
|
||||
.map_or("", |filter| filter.query.as_str());
|
||||
self.accounts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, account)| matches_filter(&account.username, query))
|
||||
.map(|(index, _)| index)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) 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(_) | AuthState::PasswordChangePending(_)
|
||||
) {
|
||||
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.filter.is_some() {
|
||||
self.filter_key(key);
|
||||
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;
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
self.start_refresh();
|
||||
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::Char('/') => self.begin_filter(),
|
||||
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)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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();
|
||||
}
|
||||
|
||||
pub(super) 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();
|
||||
}
|
||||
|
||||
pub(super) 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();
|
||||
}
|
||||
|
||||
pub(super) 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 if self.password_change_mode => {
|
||||
"Enter a new password and press Enter".into()
|
||||
}
|
||||
Focus::Password => "Enter your password and press Enter".into(),
|
||||
};
|
||||
}
|
||||
|
||||
pub(super) fn next_session(&mut self) {
|
||||
let indices = self.matching_session_indices();
|
||||
if let Some(position) = indices.iter().position(|&index| index == self.selected) {
|
||||
self.selected = indices[(position + 1) % indices.len()];
|
||||
} else if let Some(&index) = indices.first() {
|
||||
self.selected = index;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn previous_session(&mut self) {
|
||||
let indices = self.matching_session_indices();
|
||||
if let Some(position) = indices.iter().position(|&index| index == self.selected) {
|
||||
self.selected = indices
|
||||
.get(position.checked_sub(1).unwrap_or(indices.len() - 1))
|
||||
.copied()
|
||||
.unwrap_or(self.selected);
|
||||
} else if let Some(&index) = indices.first() {
|
||||
self.selected = index;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn next_account(&mut self) {
|
||||
self.select_account(1);
|
||||
}
|
||||
|
||||
pub(super) fn previous_account(&mut self) {
|
||||
self.select_account(-1);
|
||||
}
|
||||
|
||||
pub(super) fn select_account(&mut self, delta: isize) {
|
||||
let indices = self.matching_account_indices();
|
||||
if indices.is_empty() {
|
||||
self.notify(
|
||||
"No eligible accounts discovered; enter a username manually",
|
||||
NoticeLevel::Info,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let current = self
|
||||
.account_index
|
||||
.and_then(|index| indices.iter().position(|&candidate| candidate == index))
|
||||
.unwrap_or(0) as isize;
|
||||
let index = indices[(current + delta).rem_euclid(indices.len() as isize) as usize];
|
||||
self.select_account_index(index);
|
||||
}
|
||||
|
||||
pub(super) fn select_account_index(&mut self, index: usize) {
|
||||
let Some(username) = self
|
||||
.accounts
|
||||
.get(index)
|
||||
.map(|account| account.username.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.account_index = Some(index);
|
||||
self.username = username;
|
||||
self.password.clear();
|
||||
self.pending_password.clear();
|
||||
self.pending_new_password.clear();
|
||||
self.password_change_mode = false;
|
||||
self.auth_state = AuthState::Idle;
|
||||
self.auth_failures = 0;
|
||||
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),
|
||||
NoticeLevel::Info,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_filter(value: &str, query: &str) -> bool {
|
||||
if query.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if query.chars().any(char::is_uppercase) {
|
||||
value.contains(query)
|
||||
} else {
|
||||
value.to_lowercase().contains(query)
|
||||
}
|
||||
}
|
||||
398
crates/latchd-tui/src/app/render.rs
Normal file
398
crates/latchd-tui/src/app/render.rs
Normal file
@@ -0,0 +1,398 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use ratatui::{
|
||||
prelude::{Constraint, Direction, Layout, Line, Rect, Span, Style},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use latchd_core::accounts::AccountState;
|
||||
|
||||
use super::{App, AuthState, FilterTarget, Focus, MIN_HEIGHT, MIN_WIDTH, NoticeLevel};
|
||||
|
||||
impl App {
|
||||
pub(super) 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_indices = self.matching_session_indices();
|
||||
let session_items: Vec<ListItem> = session_indices
|
||||
.iter()
|
||||
.filter_map(|&index| self.sessions.get(index).map(|session| (index, session)))
|
||||
.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 session_items = if session_items.is_empty() {
|
||||
vec![ListItem::new(" No matching sessions").style(self.style_muted())]
|
||||
} else {
|
||||
session_items
|
||||
};
|
||||
let session_title = if self
|
||||
.filter
|
||||
.as_ref()
|
||||
.is_some_and(|filter| filter.target == FilterTarget::Sessions)
|
||||
{
|
||||
format!("Session {}/{}", session_indices.len(), self.sessions.len())
|
||||
} else {
|
||||
"Session".into()
|
||||
};
|
||||
let sessions = List::new(session_items)
|
||||
.block(self.block(&session_title, 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(
|
||||
if self.password_change_mode {
|
||||
"New password"
|
||||
} else {
|
||||
"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(self.footer_hint()), 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);
|
||||
} else if self.filter.is_some() {
|
||||
self.draw_filter(frame, area);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn footer_hint(&self) -> String {
|
||||
let mut hint = match self.focus {
|
||||
Focus::Sessions => "Tab focus ←/→ choose / filter Ctrl-R refresh ? help",
|
||||
Focus::Username => "Tab focus / filter Ctrl-N/P switch Ctrl-R refresh ? help",
|
||||
Focus::Password if self.password_change_mode => {
|
||||
"Tab focus Enter change password Ctrl-M motion ? help"
|
||||
}
|
||||
Focus::Password => "Tab focus Enter login Ctrl-M motion ? help",
|
||||
}
|
||||
.to_owned();
|
||||
if self.no_color {
|
||||
hint.push_str(" [monochrome]");
|
||||
} else if self.high_contrast {
|
||||
hint.push_str(" [high contrast]");
|
||||
}
|
||||
if self.reduced_motion {
|
||||
hint.push_str(" [reduced motion]");
|
||||
}
|
||||
hint
|
||||
}
|
||||
|
||||
pub(super) fn draw_filter(&self, frame: &mut ratatui::Frame, area: Rect) {
|
||||
let Some(filter) = &self.filter else {
|
||||
return;
|
||||
};
|
||||
let popup = centered_rect(72, 52, area);
|
||||
frame.render_widget(Clear, popup);
|
||||
let (label, values): (&str, Vec<String>) = match filter.target {
|
||||
FilterTarget::Sessions => (
|
||||
"Sessions",
|
||||
self.matching_session_indices()
|
||||
.iter()
|
||||
.filter_map(|&index| self.sessions.get(index))
|
||||
.map(|session| session.title.clone())
|
||||
.collect(),
|
||||
),
|
||||
FilterTarget::Accounts => (
|
||||
"Accounts",
|
||||
self.matching_account_indices()
|
||||
.iter()
|
||||
.filter_map(|&index| self.accounts.get(index))
|
||||
.map(|account| account.username.clone())
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
let mut lines = vec![
|
||||
Line::from(format!("{label} /{}", filter.query)),
|
||||
Line::raw(""),
|
||||
];
|
||||
if values.is_empty() {
|
||||
lines.push(Line::from("No matches"));
|
||||
} else {
|
||||
for value in values.iter().take(8) {
|
||||
lines.push(Line::from(format!(
|
||||
" {}",
|
||||
truncate_cells(value, popup.width.saturating_sub(5) as usize)
|
||||
)));
|
||||
}
|
||||
}
|
||||
lines.push(Line::raw(""));
|
||||
lines.push(Line::from(
|
||||
"Type to filter • ↑/↓ choose • Enter accept • Esc clear",
|
||||
));
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.block(self.block("Filter", true))
|
||||
.wrap(Wrap { trim: true }),
|
||||
popup,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) 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\n/ filter sessions or accounts\nCtrl-N / Ctrl-P switch account\nCtrl-R refresh discovery\nCtrl-B toggle background\nCtrl-M toggle reduced motion\nEnter authenticate / change password\nEsc clear password / close popup\nCtrl-C quit cleanly\n? close help",
|
||||
)
|
||||
.block(self.block("Help", false))
|
||||
.wrap(Wrap { trim: true }),
|
||||
popup,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) 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
|
||||
}
|
||||
|
||||
pub(super) fn account_title(&self) -> String {
|
||||
self.account_index
|
||||
.map(|index| format!("Username {}/{}", index + 1, self.accounts.len()))
|
||||
.unwrap_or_else(|| "Username".into())
|
||||
}
|
||||
|
||||
pub(super) fn status_line(&self) -> String {
|
||||
let mut status = self.status.clone();
|
||||
if matches!(
|
||||
self.auth_state,
|
||||
AuthState::Pending(_) | AuthState::PasswordChangePending(_)
|
||||
) {
|
||||
let spinner = if self.config.visual.animations && !self.reduced_motion {
|
||||
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
.get(self.animation_frame % 10)
|
||||
.copied()
|
||||
.unwrap_or("⠋")
|
||||
} else if self.ascii {
|
||||
"|"
|
||||
} else {
|
||||
"…"
|
||||
};
|
||||
status = if matches!(self.auth_state, AuthState::PasswordChangePending(_)) {
|
||||
format!("{spinner} Changing password through PAM…")
|
||||
} else {
|
||||
format!("{spinner} Authenticating…")
|
||||
};
|
||||
}
|
||||
if let Some(until) = self.auth_blocked_until {
|
||||
if let Some(remaining) = until.checked_duration_since(Instant::now()) {
|
||||
status.push_str(&format!(
|
||||
" • retry cooldown {}s",
|
||||
remaining.as_secs().saturating_add(1)
|
||||
));
|
||||
}
|
||||
}
|
||||
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(index) = self.account_index {
|
||||
if let Some(account) = self.account_statuses.get(index) {
|
||||
let policy = match account.state {
|
||||
AccountState::Active | AccountState::Unknown => None,
|
||||
AccountState::Locked => Some("account password is locked"),
|
||||
AccountState::PasswordExpired => Some("password must be changed"),
|
||||
AccountState::AccountExpired => Some("account has expired"),
|
||||
AccountState::AccountInactive => Some("account is inactive"),
|
||||
AccountState::NoLoginShell => Some("account has no login shell"),
|
||||
};
|
||||
if let Some(policy) = policy {
|
||||
status.push_str(&format!(" • {policy}; PAM remains authoritative"));
|
||||
}
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
if self.refreshing {
|
||||
status.push_str(" • refreshing discovery");
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
pub(super) fn style_accent(&self) -> Style {
|
||||
if self.no_color {
|
||||
Style::default().add_modifier(ratatui::prelude::Modifier::BOLD)
|
||||
} else if self.high_contrast {
|
||||
Style::default()
|
||||
.fg(ratatui::prelude::Color::White)
|
||||
.bg(ratatui::prelude::Color::Blue)
|
||||
.add_modifier(ratatui::prelude::Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(ratatui::prelude::Color::Cyan)
|
||||
.add_modifier(ratatui::prelude::Modifier::BOLD)
|
||||
}
|
||||
}
|
||||
pub(super) fn style_selected(&self) -> Style {
|
||||
if self.no_color {
|
||||
Style::default().add_modifier(ratatui::prelude::Modifier::REVERSED)
|
||||
} else if self.high_contrast {
|
||||
Style::default()
|
||||
.fg(ratatui::prelude::Color::Black)
|
||||
.bg(ratatui::prelude::Color::White)
|
||||
.add_modifier(ratatui::prelude::Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(ratatui::prelude::Color::Yellow)
|
||||
.add_modifier(ratatui::prelude::Modifier::BOLD)
|
||||
}
|
||||
}
|
||||
pub(super) fn style_muted(&self) -> Style {
|
||||
Style::default().add_modifier(ratatui::prelude::Modifier::DIM)
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
pub(super) 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
|
||||
}
|
||||
345
crates/latchd-tui/src/app/tests.rs
Normal file
345
crates/latchd-tui/src/app/tests.rs
Normal file
@@ -0,0 +1,345 @@
|
||||
use std::sync::mpsc;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::{auth::auth_backoff, render::truncate_cells, *};
|
||||
|
||||
#[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_without_layout_errors_at_supported_breakpoints() {
|
||||
for (width, height) in [(80, 24), (79, 23), (60, 24)] {
|
||||
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(width, height);
|
||||
let mut terminal = Terminal::new(backend).expect("test terminal should initialize");
|
||||
terminal
|
||||
.draw(|frame| app.draw(frame))
|
||||
.expect("frame should render");
|
||||
let rendered = terminal.backend().buffer().content();
|
||||
assert!(rendered.iter().any(|cell| cell.symbol() == "l"));
|
||||
assert!(
|
||||
!rendered
|
||||
.iter()
|
||||
.any(|cell| cell.symbol() == "terminal too small")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_policy_state_is_visible_without_overriding_pam() {
|
||||
let auth = AuthService::new().expect("PAM worker should start");
|
||||
let account = Identity {
|
||||
username: "alice".into(),
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
home: "/home/alice".into(),
|
||||
shell: "/bin/sh".into(),
|
||||
};
|
||||
let mut app = App::new(
|
||||
Config::default(),
|
||||
auth,
|
||||
true,
|
||||
Vec::new(),
|
||||
vec![account],
|
||||
Background::load(&Config::default().visual).expect("default background should load"),
|
||||
);
|
||||
app.account_statuses[0].state = AccountState::Locked;
|
||||
let status = app.status_line();
|
||||
assert!(status.contains("password is locked"));
|
||||
assert!(status.contains("PAM remains authoritative"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_backoff_is_exponential_and_bounded() {
|
||||
assert_eq!(auth_backoff(1), Duration::from_secs(1));
|
||||
assert_eq!(auth_backoff(2), Duration::from_secs(2));
|
||||
assert_eq!(auth_backoff(5), Duration::from_secs(16));
|
||||
assert_eq!(auth_backoff(100), Duration::from_secs(16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pam_password_change_requirement_enters_explicit_new_password_mode() {
|
||||
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"),
|
||||
);
|
||||
app.pending_password = Zeroizing::new(b"old-secret".to_vec());
|
||||
app.handle_auth_completion(AuthCompletion::Login(Err(Error::Pam {
|
||||
operation: "pam_acct_mgmt",
|
||||
status: 12,
|
||||
message: "module detail must stay out of the UI".into(),
|
||||
})))
|
||||
.expect("policy transition should not fail");
|
||||
assert!(app.password_change_mode);
|
||||
assert_eq!(app.focus, Focus::Password);
|
||||
assert!(app.status.contains("new password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_change_mode_has_a_distinct_prompt() {
|
||||
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"),
|
||||
);
|
||||
app.password_change_mode = true;
|
||||
app.focus = Focus::Password;
|
||||
app.update_hint();
|
||||
assert!(app.status.contains("new password"));
|
||||
let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("terminal");
|
||||
terminal.draw(|frame| app.draw(frame)).expect("frame");
|
||||
let rendered = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content()
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect::<String>();
|
||||
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");
|
||||
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,
|
||||
},
|
||||
SessionDescriptor {
|
||||
kind: session::SessionKind::Wayland,
|
||||
title: "Sway session".into(),
|
||||
argv: vec!["sway".into()],
|
||||
current_desktop: None,
|
||||
session_desktop: None,
|
||||
},
|
||||
],
|
||||
Vec::new(),
|
||||
Background::load(&Config::default().visual).expect("default background should load"),
|
||||
);
|
||||
app.focus = Focus::Sessions;
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
|
||||
assert_eq!(app.matching_session_indices(), vec![1]);
|
||||
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_eq!(app.selected, 1);
|
||||
assert!(app.filter.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_reconciles_selection_without_blocking_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"),
|
||||
);
|
||||
app.refresh_receiver = Some({
|
||||
let (sender, receiver) = mpsc::sync_channel(1);
|
||||
sender
|
||||
.send((
|
||||
vec![SessionDescriptor {
|
||||
kind: session::SessionKind::Tty,
|
||||
title: "TTY shell".into(),
|
||||
argv: Vec::new(),
|
||||
current_desktop: None,
|
||||
session_desktop: None,
|
||||
}],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
))
|
||||
.expect("refresh result should queue");
|
||||
receiver
|
||||
});
|
||||
app.refreshing = true;
|
||||
app.poll_refresh();
|
||||
assert!(!app.refreshing);
|
||||
assert_eq!(app.sessions.len(), 1);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Low-cost terminal wallpaper and procedural ambient animation.
|
||||
//! Draw terminal wallpaper and a small ambient animation.
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
@@ -189,7 +189,7 @@ struct Automata {
|
||||
|
||||
impl Automata {
|
||||
fn resize(&mut self, width: usize, height: usize) {
|
||||
// Cap the state size. Some terminal emulators report extreme dimensions.
|
||||
// Keep the state bounded when a terminal reports an extreme size.
|
||||
let width = width.min(MAX_ANIMATION_WIDTH);
|
||||
let height = height.min(MAX_ANIMATION_HEIGHT);
|
||||
if self.width == width && self.height == height {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user