feat: in-TUI login/registration screen

The default TUI path now brings the terminal up first and runs a login
screen whenever no working credentials exist: a pure-state-machine form
(unit tested) driven by an async connect/login attempt on a background
thread. Soulseek registers unknown usernames on login, so the same form
covers registration. On success the username is saved to config.toml and
a form-entered password to the OS keychain; stored credentials skip the
form entirely. Failed logins show the server error and retry instead of
aborting.
This commit is contained in:
michel
2026-07-20 20:14:17 +02:00
parent aaeb62efa4
commit d913c5cc71
3 changed files with 533 additions and 30 deletions
+108 -30
View File
@@ -45,6 +45,13 @@ fn main() -> Result<()> {
return Ok(());
}
// The interactive TUI handles missing credentials itself with a
// login/registration screen; only the one-shot subcommands hard-require
// them up front.
if cli.command.is_none() {
return run_default_tui(&cli, &resolved, config_path, &file_config);
}
let username = resolved.username.clone().ok_or_else(|| {
color_eyre::eyre::eyre!(
"Username required: use --username, set SOULSEEK_USERNAME, or add it to config.toml"
@@ -122,14 +129,9 @@ fn main() -> Result<()> {
message,
listen_secs,
}) => chat_room(&settings, &room, message.as_deref(), listen_secs),
// Handled before the credential check above.
Some(Commands::Portmap) => unreachable!(),
None => run_default_tui(
settings,
resolved.download_dir.clone(),
resolved.max_concurrent_downloads,
Duration::from_secs(resolved.search_timeout),
),
// Portmap is handled before the credential check; None returns early
// into run_default_tui above.
Some(Commands::Portmap) | None => unreachable!(),
}
}
@@ -152,12 +154,15 @@ fn init_logging(cli: &Cli) {
}
}
/// Connect, log in, and run the interactive TUI (the default no-subcommand path).
/// Run the interactive TUI (the default no-subcommand path): bring the
/// terminal up first, run the login/registration screen (skipped past when
/// stored credentials work), persist whatever logged in, then enter the
/// main UI.
fn run_default_tui(
settings: ClientSettings,
download_dir: String,
max_concurrent_downloads: usize,
search_timeout: Duration,
cli: &Cli,
resolved: &persist::config::Resolved,
config_path: Option<std::path::PathBuf>,
file_config: &persist::config::FileConfig,
) -> Result<()> {
use ratatui::crossterm::{
event::EnableMouseCapture,
@@ -165,40 +170,113 @@ fn run_default_tui(
terminal::{Clear, ClearType},
};
let (server_host, server_port) = parse_server_address(&resolved.server)?;
let shared_directory = match directories::resolve_shared_directory(
resolved.shared_dir.as_deref(),
) {
Ok(dir) => dir.map(|path| path.display().to_string()),
Err(e) => {
eprintln!("⚠️ Ignoring shared directory: {e}");
None
}
};
let secret_store = persist::secret::KeyringStore;
let initial_password = persist::secret::resolve_password(
cli.password.as_deref(),
resolved.username.as_deref(),
resolved.password_cmd.as_deref(),
&secret_store,
);
// Enable logger buffering BEFORE connection to prevent log artifacts
soulseek_rs::utils::logger::enable_buffering();
// Best-effort: make ourselves reachable behind a home router so
// firewalled peers can connect back. Kept alive for the session.
let _port_mapper = settings
.enable_listen
.then(|| port_mapping::PortMapper::spawn(settings.listen_port));
let _port_mapper = (!resolved.disable_listener)
.then(|| port_mapping::PortMapper::spawn(resolved.listener_port));
let mut client = Client::with_settings(settings);
client
.connect()
.map_err(|e| color_eyre::eyre::eyre!("Failed to connect: {}", e))?;
client
.login()
.map_err(|e| color_eyre::eyre::eyre!("Failed to login: {}", e))?;
let client = Arc::new(client);
let enable_listen = !resolved.disable_listener;
let listen_port = resolved.listener_port;
let make_settings = move |username: String, password: String| {
ClientSettings {
username,
password,
server_address: PeerAddress::new(
server_host.clone(),
server_port,
),
enable_listen,
listen_port,
shared_directory: shared_directory.clone(),
}
};
// Clear screen and enable mouse capture before initializing TUI
let _ =
execute!(std::io::stdout(), Clear(ClearType::All), EnableMouseCapture);
let mut terminal = ratatui::init();
let terminal = ratatui::init();
let outcome = ui::login::run_login_flow(
&mut terminal,
&make_settings,
resolved.username.clone(),
initial_password,
);
let outcome = match outcome {
Ok(Some(outcome)) => outcome,
Ok(None) => {
// User cancelled at the login screen.
ratatui::restore();
soulseek_rs::utils::logger::disable_buffering();
return Ok(());
}
Err(e) => {
ratatui::restore();
soulseek_rs::utils::logger::disable_buffering();
return Err(e);
}
};
persist_credentials(&outcome, config_path, file_config, &secret_store);
launch_main_tui(
terminal,
client,
download_dir,
max_concurrent_downloads,
search_timeout,
Arc::new(outcome.client),
resolved.download_dir.clone(),
resolved.max_concurrent_downloads,
Duration::from_secs(resolved.search_timeout),
)
}
/// After a successful login, remember the username in config.toml and — when
/// it was typed into the form — the password in the OS keychain. Both are
/// best-effort: failing to persist must not break a working session.
fn persist_credentials(
outcome: &ui::login::LoginOutcome,
config_path: Option<std::path::PathBuf>,
file_config: &persist::config::FileConfig,
secret_store: &dyn persist::secret::SecretStore,
) {
if let Some(path) = config_path
&& file_config.username.as_deref() != Some(&outcome.username)
{
let mut updated = file_config.clone();
updated.username = Some(outcome.username.clone());
if let Err(e) = updated.save(&path) {
eprintln!("⚠️ Could not save config: {e}");
}
}
if outcome.entered_via_form
&& let Err(e) = secret_store.set(&outcome.username, &outcome.password)
{
eprintln!("⚠️ Could not store password in keychain: {e}");
}
}
fn browse_user(settings: &ClientSettings, target: &str) -> Result<()> {
use std::time::Instant;
+424
View File
@@ -0,0 +1,424 @@
//! First-run login/registration screen.
//!
//! Soulseek auto-registers unknown usernames on login, so a single form
//! covers both flows. The form itself ([`LoginForm`]) is a pure state
//! machine so it can be tested without a terminal; the IO loop
//! ([`run_login_flow`]) drives it against a real terminal and client.
use color_eyre::Result;
use ratatui::{
DefaultTerminal, Frame,
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll},
layout::{Constraint, Flex, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use soulseek_rs::{Client, ClientSettings};
use std::sync::mpsc::{Receiver, channel};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoginField {
Username,
Password,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoginPhase {
/// User is typing into the form.
Editing,
/// A connect+login attempt is in flight.
Connecting,
/// The last attempt failed; the message is shown until the next key.
Failed(String),
}
/// What the caller should do after feeding a key to the form.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoginAction {
None,
/// Both fields are filled and the user pressed Enter.
Submit,
/// User pressed Esc — abort the whole program.
Cancel,
}
pub struct LoginForm {
pub username: String,
pub password: String,
pub focused: LoginField,
pub phase: LoginPhase,
}
impl LoginForm {
pub fn new(username: Option<String>) -> Self {
let username = username.unwrap_or_default();
let focused = if username.is_empty() {
LoginField::Username
} else {
LoginField::Password
};
Self {
username,
password: String::new(),
focused,
phase: LoginPhase::Editing,
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> LoginAction {
match self.phase {
LoginPhase::Connecting => return LoginAction::None,
LoginPhase::Failed(_) => {
// Any key acknowledges the error; the password was wrong (or
// the name is taken), so make the user retype it.
self.phase = LoginPhase::Editing;
self.password.clear();
self.focused = LoginField::Password;
if key.code == KeyCode::Esc {
return LoginAction::Cancel;
}
return LoginAction::None;
}
LoginPhase::Editing => {}
}
match key.code {
KeyCode::Esc => LoginAction::Cancel,
KeyCode::Tab | KeyCode::BackTab | KeyCode::Up | KeyCode::Down => {
self.focused = match self.focused {
LoginField::Username => LoginField::Password,
LoginField::Password => LoginField::Username,
};
LoginAction::None
}
KeyCode::Enter => {
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 => {
self.focused_field_mut().pop();
LoginAction::None
}
KeyCode::Char(c) => {
self.focused_field_mut().push(c);
LoginAction::None
}
_ => LoginAction::None,
}
}
fn focused_field_mut(&mut self) -> &mut String {
match self.focused {
LoginField::Username => &mut self.username,
LoginField::Password => &mut self.password,
}
}
}
/// A successful login: the connected client plus the credentials that worked.
pub struct LoginOutcome {
pub client: Client,
pub username: String,
pub password: String,
/// True when the password was typed into the form (as opposed to coming
/// from CLI/env/keychain) — only then do we offer to store it.
pub entered_via_form: bool,
}
/// Drive the login screen until a login succeeds (`Some`) or the user
/// cancels with Esc (`None`). When both credentials are already known an
/// attempt starts immediately and the form is only shown on failure.
pub fn run_login_flow(
terminal: &mut DefaultTerminal,
make_settings: &dyn Fn(String, String) -> ClientSettings,
initial_username: Option<String>,
initial_password: Option<String>,
) -> Result<Option<LoginOutcome>> {
let mut form = LoginForm::new(initial_username);
let mut entered_via_form = false;
let mut attempt: Option<Receiver<Result<Client, String>>> = None;
if let Some(password) = initial_password
&& !form.username.is_empty()
{
form.password = password;
form.phase = LoginPhase::Connecting;
attempt = Some(spawn_attempt(make_settings(
form.username.clone(),
form.password.clone(),
)));
}
loop {
terminal.draw(|frame| render(frame, &form))?;
if let Some(rx) = &attempt {
match rx.try_recv() {
Ok(Ok(client)) => {
return Ok(Some(LoginOutcome {
client,
username: form.username,
password: form.password,
entered_via_form,
}));
}
Ok(Err(message)) => {
form.phase = LoginPhase::Failed(message);
attempt = None;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
form.phase =
LoginPhase::Failed("Connection thread died".into());
attempt = None;
}
}
}
if poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
match form.handle_key(key) {
LoginAction::Cancel => return Ok(None),
LoginAction::Submit => {
entered_via_form = true;
attempt = Some(spawn_attempt(make_settings(
form.username.clone(),
form.password.clone(),
)));
}
LoginAction::None => {}
}
}
}
}
/// Connect and log in on a background thread so the UI stays responsive.
fn spawn_attempt(
settings: ClientSettings,
) -> Receiver<Result<Client, String>> {
let (tx, rx) = channel();
std::thread::spawn(move || {
let mut client = Client::with_settings(settings);
let result = client
.connect()
.map_err(|e| format!("Failed to connect: {e}"))
.and_then(|()| match 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));
});
rx
}
fn render(frame: &mut Frame, form: &LoginForm) {
let area = centered(frame.area(), 52, 12);
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.title(" Soulseek Login ")
.title_style(Style::default().add_modifier(Modifier::BOLD));
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(1), // username
Constraint::Length(1), // password
Constraint::Length(1), // spacer
Constraint::Length(2), // status / error
Constraint::Length(1), // spacer
Constraint::Length(2), // hints
])
.split(inner);
let field = |label: &str, value: String, focused: bool| {
let marker = if focused { "> " } else { " " };
let style = if focused {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
Paragraph::new(Line::from(vec![
Span::styled(format!("{marker}{label:<10}"), style),
Span::raw(value),
Span::raw(if focused { "▏" } else { "" }),
]))
};
let editing = form.phase == LoginPhase::Editing;
frame.render_widget(
field(
"Username:",
form.username.clone(),
editing && form.focused == LoginField::Username,
),
rows[0],
);
frame.render_widget(
field(
"Password:",
"•".repeat(form.password.chars().count()),
editing && form.focused == LoginField::Password,
),
rows[1],
);
let status = match &form.phase {
LoginPhase::Editing => Paragraph::new(""),
LoginPhase::Connecting => Paragraph::new(Line::from(Span::styled(
"Connecting…",
Style::default().fg(Color::Yellow),
))),
LoginPhase::Failed(message) => {
Paragraph::new(Line::from(Span::styled(
message.clone(),
Style::default().fg(Color::Red),
)))
.wrap(ratatui::widgets::Wrap { trim: true })
}
};
frame.render_widget(status, rows[3]);
frame.render_widget(
Paragraph::new(
"New usernames are registered automatically.\n\
Tab: switch · Enter: log in · Esc: quit",
)
.style(Style::default().fg(Color::DarkGray)),
rows[5],
);
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let [area] = Layout::horizontal([Constraint::Length(width)])
.flex(Flex::Center)
.areas(area);
let [area] = Layout::vertical([Constraint::Length(height)])
.flex(Flex::Center)
.areas(area);
area
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::KeyModifiers;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn type_str(form: &mut LoginForm, text: &str) {
for c in text.chars() {
form.handle_key(key(KeyCode::Char(c)));
}
}
#[test]
fn prefilled_username_focuses_password() {
let form = LoginForm::new(Some("alice".into()));
assert_eq!(form.username, "alice");
assert_eq!(form.focused, LoginField::Password);
}
#[test]
fn empty_form_focuses_username() {
let form = LoginForm::new(None);
assert_eq!(form.focused, LoginField::Username);
}
#[test]
fn typing_fills_the_focused_field_and_tab_switches() {
let mut form = LoginForm::new(None);
type_str(&mut form, "bob");
assert_eq!(form.username, "bob");
form.handle_key(key(KeyCode::Tab));
assert_eq!(form.focused, LoginField::Password);
type_str(&mut form, "hunter2");
assert_eq!(form.password, "hunter2");
form.handle_key(key(KeyCode::Tab));
assert_eq!(form.focused, LoginField::Username);
}
#[test]
fn backspace_deletes_from_focused_field() {
let mut form = LoginForm::new(None);
type_str(&mut form, "bob");
form.handle_key(key(KeyCode::Backspace));
assert_eq!(form.username, "bo");
}
#[test]
fn enter_on_username_moves_to_password() {
let mut form = LoginForm::new(None);
type_str(&mut form, "bob");
let action = form.handle_key(key(KeyCode::Enter));
assert_eq!(action, LoginAction::None);
assert_eq!(form.focused, LoginField::Password);
}
#[test]
fn enter_submits_when_both_fields_filled() {
let mut form = LoginForm::new(Some("alice".into()));
type_str(&mut form, "hunter2");
let action = form.handle_key(key(KeyCode::Enter));
assert_eq!(action, LoginAction::Submit);
assert_eq!(form.phase, LoginPhase::Connecting);
}
#[test]
fn enter_with_empty_password_does_not_submit() {
let mut form = LoginForm::new(Some("alice".into()));
let action = form.handle_key(key(KeyCode::Enter));
assert_eq!(action, LoginAction::None);
assert_eq!(form.phase, LoginPhase::Editing);
}
#[test]
fn escape_cancels() {
let mut form = LoginForm::new(None);
assert_eq!(form.handle_key(key(KeyCode::Esc)), LoginAction::Cancel);
}
#[test]
fn key_after_failure_returns_to_editing_and_clears_password() {
let mut form = LoginForm::new(Some("alice".into()));
type_str(&mut form, "wrong");
form.handle_key(key(KeyCode::Enter));
form.phase = LoginPhase::Failed("INVALIDPASS".into());
let action = form.handle_key(key(KeyCode::Char('x')));
assert_eq!(action, LoginAction::None);
assert_eq!(form.phase, LoginPhase::Editing);
assert_eq!(form.password, "");
}
#[test]
fn keys_ignored_while_connecting() {
let mut form = LoginForm::new(Some("alice".into()));
type_str(&mut form, "pw");
form.handle_key(key(KeyCode::Enter));
assert_eq!(form.phase, LoginPhase::Connecting);
let action = form.handle_key(key(KeyCode::Char('x')));
assert_eq!(action, LoginAction::None);
assert_eq!(form.password, "pw");
}
}
+1
View File
@@ -1,4 +1,5 @@
mod download_selector;
pub mod login;
mod downloads;
mod main_tui;
mod panes;