diff --git a/soulseek-rs/src/main.rs b/soulseek-rs/src/main.rs index 81e40bb..aecc779 100644 --- a/soulseek-rs/src/main.rs +++ b/soulseek-rs/src/main.rs @@ -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, + 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, + 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; diff --git a/soulseek-rs/src/ui/login.rs b/soulseek-rs/src/ui/login.rs new file mode 100644 index 0000000..c1f295a --- /dev/null +++ b/soulseek-rs/src/ui/login.rs @@ -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) -> 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, + initial_password: Option, +) -> Result> { + let mut form = LoginForm::new(initial_username); + let mut entered_via_form = false; + let mut attempt: Option>> = 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> { + 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"); + } +} diff --git a/soulseek-rs/src/ui/mod.rs b/soulseek-rs/src/ui/mod.rs index e00a956..351ebff 100644 --- a/soulseek-rs/src/ui/mod.rs +++ b/soulseek-rs/src/ui/mod.rs @@ -1,4 +1,5 @@ mod download_selector; +pub mod login; mod downloads; mod main_tui; mod panes;