fix(tui): keep the login screen responsive while connecting

The login screen ignored every key while a connect+login attempt ran, so
against an unreachable server it sat on a static "Connecting…" for 20s or
more, with a hint promising that Esc would quit.

While connecting it now shows a spinner: Enter or r restarts the attempt,
Esc stops it and returns to the form, q or Ctrl-C quits. Ctrl-C quits from
every state of the screen instead of typing a "c" into a field.

A failure that got no verdict (unreachable, timed out) keeps the password,
so Enter or r retries it; only a rejection, Ok(false) or
AuthenticationFailed, clears it. Because a retry can now reuse a password
that came from the CLI, env or keychain, entered_via_form compares the
credentials with the ones the flow started with instead of trusting any
submit, so a retried stored password is never written to the keychain.

A restart of an attempt younger than a second is ignored: each attempt
rescans the shares and opens a listener, and a held Enter would otherwise
start one per key repeat. The attempt result is read before drawing, so a
key is never handled against a phase the screen did not show.

A stopped or restarted attempt is abandoned, not cancelled: the server
actor blocks its own mailbox in connect_timeout, so a stop could not reach
it sooner. Its thread runs out its timeouts and drops the client, which
disconnects. Until then it holds the listener port, so a restarted session
can fall back to an ephemeral port, and should a slow server accept the
abandoned login after its replacement got in, the late login displaces the
new session. Fixing either needs a cancellable connect, or a bind after the
login verdict, in the library.
This commit is contained in:
Michel de Graaf
2026-09-13 10:13:38 +02:00
parent be92fd8b3a
commit 18b53aa4ba
+228 -80
View File
@@ -6,21 +6,25 @@
//! ([`run_login_flow`]) drives it against a real terminal and client. //! ([`run_login_flow`]) drives it against a real terminal and client.
use crate::ui::{ use crate::ui::{
GLYPH_CURSOR, accent_style, dimmed_style, error_style, info_style, mask, GLYPH_CURSOR, accent_style, dimmed_style, error_style, get_spinner_char,
pane_block, primary_style, title_style, warning_style, info_style, mask, pane_block, primary_style, title_style, warning_style,
}; };
use color_eyre::Result; use color_eyre::Result;
use ratatui::{ use ratatui::{
DefaultTerminal, Frame, DefaultTerminal, Frame,
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll}, crossterm::event::{
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, poll,
},
layout::{Constraint, Flex, Layout, Rect}, layout::{Constraint, Flex, Layout, Rect},
style::Modifier, style::Modifier,
text::{Line, Span}, text::{Line, Span},
widgets::{Clear, Paragraph}, widgets::{Clear, Paragraph},
}; };
use soulseek_rs::{Client, ClientSettings}; use soulseek_rs::{Client, ClientSettings, SoulseekRs};
use std::sync::mpsc::{Receiver, channel}; use std::sync::mpsc::{Receiver, channel};
use std::time::Duration; use std::time::{Duration, Instant};
const RESTART_FLOOR: Duration = Duration::from_secs(1);
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoginField { pub enum LoginField {
@@ -36,15 +40,17 @@ pub enum LoginPhase {
Connecting, Connecting,
/// The last attempt failed; the message is shown until the next key. /// The last attempt failed; the message is shown until the next key.
Failed(String), Failed(String),
Rejected(String),
} }
/// What the caller should do after feeding a key to the form. /// What the caller should do after feeding a key to the form.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoginAction { pub enum LoginAction {
None, None,
/// Both fields are filled and the user pressed Enter. /// Both fields are filled and the user pressed Enter or r.
Submit, Submit,
/// User pressed Esc — abort the whole program. Stop,
/// User pressed Esc, q or Ctrl-C — abort the whole program.
Cancel, Cancel,
} }
@@ -72,9 +78,33 @@ impl LoginForm {
} }
pub fn handle_key(&mut self, key: KeyEvent) -> LoginAction { pub fn handle_key(&mut self, key: KeyEvent) -> LoginAction {
if key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
return LoginAction::Cancel;
}
match self.phase { match self.phase {
LoginPhase::Connecting => return LoginAction::None, LoginPhase::Connecting => {
return match key.code {
KeyCode::Enter | KeyCode::Char('r') => LoginAction::Submit,
KeyCode::Esc => {
self.phase = LoginPhase::Editing;
LoginAction::Stop
}
KeyCode::Char('q') => LoginAction::Cancel,
_ => LoginAction::None,
};
}
LoginPhase::Failed(_) => { LoginPhase::Failed(_) => {
self.phase = LoginPhase::Editing;
return match key.code {
KeyCode::Enter | KeyCode::Char('r') => self.submit(),
KeyCode::Esc => LoginAction::Cancel,
_ => LoginAction::None,
};
}
LoginPhase::Rejected(_) => {
// Any key acknowledges the error; the password was wrong (or // Any key acknowledges the error; the password was wrong (or
// the name is taken), so make the user retype it. // the name is taken), so make the user retype it.
self.phase = LoginPhase::Editing; self.phase = LoginPhase::Editing;
@@ -97,20 +127,7 @@ impl LoginForm {
}; };
LoginAction::None LoginAction::None
} }
KeyCode::Enter => { KeyCode::Enter => self.submit(),
if !self.username.is_empty() && !self.password.is_empty() {
self.phase = LoginPhase::Connecting;
LoginAction::Submit
} else {
// Move to the first empty field instead of submitting.
self.focused = if self.username.is_empty() {
LoginField::Username
} else {
LoginField::Password
};
LoginAction::None
}
}
KeyCode::Backspace => { KeyCode::Backspace => {
self.focused_field_mut().pop(); self.focused_field_mut().pop();
LoginAction::None LoginAction::None
@@ -123,6 +140,20 @@ impl LoginForm {
} }
} }
fn submit(&mut self) -> LoginAction {
if !self.username.is_empty() && !self.password.is_empty() {
self.phase = LoginPhase::Connecting;
return LoginAction::Submit;
}
// Move to the first empty field instead of submitting.
self.focused = if self.username.is_empty() {
LoginField::Username
} else {
LoginField::Password
};
LoginAction::None
}
const fn focused_field_mut(&mut self) -> &mut String { const fn focused_field_mut(&mut self) -> &mut String {
match self.focused { match self.focused {
LoginField::Username => &mut self.username, LoginField::Username => &mut self.username,
@@ -142,7 +173,7 @@ pub struct LoginOutcome {
} }
/// Drive the login screen until a login succeeds (`Some`) or the user /// Drive the login screen until a login succeeds (`Some`) or the user
/// cancels with Esc (`None`). When both credentials are already known an /// quits (`None`). When both credentials are already known an
/// attempt starts immediately and the form is only shown on failure. /// attempt starts immediately and the form is only shown on failure.
pub fn run_login_flow( pub fn run_login_flow(
terminal: &mut DefaultTerminal, terminal: &mut DefaultTerminal,
@@ -150,27 +181,31 @@ pub fn run_login_flow(
initial_username: Option<String>, initial_username: Option<String>,
initial_password: Option<String>, initial_password: Option<String>,
) -> Result<Option<LoginOutcome>> { ) -> Result<Option<LoginOutcome>> {
let mut form = LoginForm::new(initial_username); let start = |form: &LoginForm| {
let mut entered_via_form = false; spawn_attempt(make_settings(
let mut attempt: Option<Receiver<Result<Client, String>>> = form.username.clone(),
match initial_password { form.password.clone(),
Some(password) if !form.username.is_empty() => { ))
form.password = password; };
form.phase = LoginPhase::Connecting; let mut form = LoginForm::new(initial_username.clone());
Some(spawn_attempt(make_settings( let mut attempt = match &initial_password {
form.username.clone(), Some(password) if !form.username.is_empty() => {
form.password.clone(), form.password.clone_from(password);
))) form.phase = LoginPhase::Connecting;
} Some(start(&form))
_ => None, }
}; _ => None,
};
let mut started = Instant::now();
loop { loop {
terminal.draw(|frame| render(frame, &form))?;
if let Some(rx) = &attempt { if let Some(rx) = &attempt {
match rx.try_recv() { match rx.try_recv() {
Ok(Ok(client)) => { Ok(Ok(client)) => {
let entered_via_form = initial_username.as_deref()
!= Some(form.username.as_str())
|| initial_password.as_deref()
!= Some(form.password.as_str());
return Ok(Some(LoginOutcome { return Ok(Some(LoginOutcome {
client, client,
username: form.username, username: form.username,
@@ -178,8 +213,8 @@ pub fn run_login_flow(
entered_via_form, entered_via_form,
})); }));
} }
Ok(Err(message)) => { Ok(Err(failure)) => {
form.phase = LoginPhase::Failed(message); form.phase = failure;
attempt = None; attempt = None;
} }
Err(std::sync::mpsc::TryRecvError::Empty) => {} Err(std::sync::mpsc::TryRecvError::Empty) => {}
@@ -191,19 +226,24 @@ pub fn run_login_flow(
} }
} }
let spinner =
get_spinner_char(started.elapsed().subsec_millis() as usize / 100);
terminal.draw(|frame| render(frame, &form, spinner))?;
if poll(Duration::from_millis(100))? if poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()? && let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press && key.kind == KeyEventKind::Press
{ {
match form.handle_key(key) { match form.handle_key(key) {
LoginAction::Cancel => return Ok(None), LoginAction::Cancel => return Ok(None),
LoginAction::Submit
if attempt.is_some()
&& started.elapsed() < RESTART_FLOOR => {}
LoginAction::Submit => { LoginAction::Submit => {
entered_via_form = true; attempt = Some(start(&form));
attempt = Some(spawn_attempt(make_settings( started = Instant::now();
form.username.clone(),
form.password.clone(),
)));
} }
LoginAction::Stop => attempt = None,
LoginAction::None => {} LoginAction::None => {}
} }
} }
@@ -211,27 +251,35 @@ pub fn run_login_flow(
} }
/// Connect and log in on a background thread so the UI stays responsive. /// Connect and log in on a background thread so the UI stays responsive.
fn spawn_attempt(settings: ClientSettings) -> Receiver<Result<Client, String>> { fn spawn_attempt(
settings: ClientSettings,
) -> Receiver<Result<Client, LoginPhase>> {
let (tx, rx) = channel(); let (tx, rx) = channel();
std::thread::spawn(move || { std::thread::spawn(move || {
let mut client = Client::with_settings(settings); let mut client = Client::with_settings(settings);
let result = client let result = client
.connect() .connect()
.map_err(|e| format!("Failed to connect: {e}")) .map_err(|e| LoginPhase::Failed(format!("Failed to connect: {e}")))
.and_then(|()| match client.login() { .and_then(|()| verdict(client.login()));
Ok(true) => Ok(()),
Ok(false) => Err(
"Login rejected: wrong password, or the username is taken"
.to_string(),
),
Err(e) => Err(format!("Login failed: {e}")),
});
let _ = tx.send(result.map(|()| client)); let _ = tx.send(result.map(|()| client));
}); });
rx rx
} }
fn render(frame: &mut Frame, form: &LoginForm) { fn verdict(login: soulseek_rs::Result<bool>) -> Result<(), LoginPhase> {
match login {
Ok(true) => Ok(()),
Ok(false) | Err(SoulseekRs::AuthenticationFailed) => {
Err(LoginPhase::Rejected(
"Login rejected: wrong password, or the username is taken"
.to_string(),
))
}
Err(e) => Err(LoginPhase::Failed(format!("Login failed: {e}"))),
}
}
fn render(frame: &mut Frame, form: &LoginForm, spinner: &str) {
let area = centered(frame.area(), 52, 12); let area = centered(frame.area(), 52, 12);
frame.render_widget(Clear, area); frame.render_widget(Clear, area);
@@ -290,21 +338,32 @@ fn render(frame: &mut Frame, form: &LoginForm) {
let status = match &form.phase { let status = match &form.phase {
LoginPhase::Editing => Paragraph::new(""), LoginPhase::Editing => Paragraph::new(""),
LoginPhase::Connecting => Paragraph::new(Line::from(Span::styled( LoginPhase::Connecting => Paragraph::new(Line::from(Span::styled(
"Connecting…", format!("{spinner} Connecting…"),
warning_style(), warning_style(),
))), ))),
LoginPhase::Failed(message) => Paragraph::new(Line::from( LoginPhase::Failed(message) | LoginPhase::Rejected(message) => {
Span::styled(message.clone(), error_style()), Paragraph::new(Line::from(Span::styled(
)) message.clone(),
.wrap(ratatui::widgets::Wrap { trim: true }), error_style(),
)))
.wrap(ratatui::widgets::Wrap { trim: true })
}
}; };
frame.render_widget(status, rows[3]); frame.render_widget(status, rows[3]);
let keys = match form.phase {
LoginPhase::Connecting => {
"Enter/r: restart · Esc: stop · q/Ctrl-C: quit"
}
LoginPhase::Failed(_) => "Enter/r: retry · Esc: quit",
LoginPhase::Editing | LoginPhase::Rejected(_) => {
"Tab: switch · Enter: log in · Esc: quit"
}
};
frame.render_widget( frame.render_widget(
Paragraph::new( Paragraph::new(format!(
"New usernames are registered automatically.\n\ "New usernames are registered automatically.\n{keys}"
Tab: switch · Enter: log in · Esc: quit", ))
)
.style(dimmed_style()), .style(dimmed_style()),
rows[5], rows[5],
); );
@@ -323,7 +382,6 @@ fn centered(area: Rect, width: u16, height: u16) -> Rect {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use ratatui::crossterm::event::KeyModifiers;
fn key(code: KeyCode) -> KeyEvent { fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE) KeyEvent::new(code, KeyModifiers::NONE)
@@ -335,6 +393,13 @@ mod tests {
} }
} }
fn connecting() -> LoginForm {
let mut form = LoginForm::new(Some("alice".into()));
type_str(&mut form, "pw");
assert_eq!(form.handle_key(key(KeyCode::Enter)), LoginAction::Submit);
form
}
#[test] #[test]
fn prefilled_username_focuses_password() { fn prefilled_username_focuses_password() {
let form = LoginForm::new(Some("alice".into())); let form = LoginForm::new(Some("alice".into()));
@@ -402,11 +467,93 @@ mod tests {
} }
#[test] #[test]
fn key_after_failure_returns_to_editing_and_clears_password() { fn ctrl_c_quits_from_every_phase_without_typing() {
let mut form = LoginForm::new(Some("alice".into())); let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
type_str(&mut form, "wrong"); for phase in [
form.handle_key(key(KeyCode::Enter)); LoginPhase::Editing,
form.phase = LoginPhase::Failed("INVALIDPASS".into()); LoginPhase::Connecting,
LoginPhase::Failed("unreachable".into()),
LoginPhase::Rejected("INVALIDPASS".into()),
] {
let mut form = LoginForm::new(None);
form.phase = phase.clone();
assert_eq!(
form.handle_key(ctrl_c),
LoginAction::Cancel,
"{phase:?}"
);
assert_eq!(form.username, "", "{phase:?}");
}
}
#[test]
fn typing_is_ignored_while_connecting() {
let mut form = connecting();
let action = form.handle_key(key(KeyCode::Char('x')));
assert_eq!(action, LoginAction::None);
assert_eq!(form.password, "pw");
assert_eq!(form.phase, LoginPhase::Connecting);
}
#[test]
fn escape_while_connecting_stops_and_keeps_the_credentials() {
let mut form = connecting();
assert_eq!(form.handle_key(key(KeyCode::Esc)), LoginAction::Stop);
assert_eq!(form.phase, LoginPhase::Editing);
assert_eq!(form.password, "pw");
assert_eq!(form.handle_key(key(KeyCode::Enter)), LoginAction::Submit);
}
#[test]
fn enter_or_r_while_connecting_restarts() {
for code in [KeyCode::Enter, KeyCode::Char('r')] {
let mut form = connecting();
assert_eq!(form.handle_key(key(code)), LoginAction::Submit);
assert_eq!(form.phase, LoginPhase::Connecting);
assert_eq!(form.password, "pw");
}
}
#[test]
fn q_while_connecting_quits() {
let mut form = connecting();
assert_eq!(
form.handle_key(key(KeyCode::Char('q'))),
LoginAction::Cancel
);
}
#[test]
fn enter_or_r_after_a_failed_connection_retries_with_the_same_password() {
for code in [KeyCode::Enter, KeyCode::Char('r')] {
let mut form = connecting();
form.phase = LoginPhase::Failed("Login failed: timed out".into());
assert_eq!(form.handle_key(key(code)), LoginAction::Submit);
assert_eq!(form.phase, LoginPhase::Connecting);
assert_eq!(form.password, "pw");
}
}
#[test]
fn other_key_after_a_failed_connection_edits_with_the_password_kept() {
let mut form = connecting();
form.phase = LoginPhase::Failed("Login failed: timed out".into());
assert_eq!(form.handle_key(key(KeyCode::Char('x'))), LoginAction::None);
assert_eq!(form.phase, LoginPhase::Editing);
assert_eq!(form.password, "pw");
}
#[test]
fn escape_after_a_failed_connection_quits() {
let mut form = connecting();
form.phase = LoginPhase::Failed("Login failed: timed out".into());
assert_eq!(form.handle_key(key(KeyCode::Esc)), LoginAction::Cancel);
}
#[test]
fn key_after_rejection_returns_to_editing_and_clears_password() {
let mut form = connecting();
form.phase = LoginPhase::Rejected("INVALIDPASS".into());
let action = form.handle_key(key(KeyCode::Char('x'))); let action = form.handle_key(key(KeyCode::Char('x')));
assert_eq!(action, LoginAction::None); assert_eq!(action, LoginAction::None);
assert_eq!(form.phase, LoginPhase::Editing); assert_eq!(form.phase, LoginPhase::Editing);
@@ -414,13 +561,14 @@ mod tests {
} }
#[test] #[test]
fn keys_ignored_while_connecting() { fn a_wrong_password_is_rejected_and_an_unanswered_login_failed() {
let mut form = LoginForm::new(Some("alice".into())); assert_eq!(verdict(Ok(true)), Ok(()));
type_str(&mut form, "pw"); for rejected in [Ok(false), Err(SoulseekRs::AuthenticationFailed)] {
form.handle_key(key(KeyCode::Enter)); assert!(matches!(verdict(rejected), Err(LoginPhase::Rejected(_))));
assert_eq!(form.phase, LoginPhase::Connecting); }
let action = form.handle_key(key(KeyCode::Char('x'))); for failed in [Err(SoulseekRs::Timeout), Err(SoulseekRs::NotConnected)]
assert_eq!(action, LoginAction::None); {
assert_eq!(form.password, "pw"); assert!(matches!(verdict(failed), Err(LoginPhase::Failed(_))));
}
} }
} }