Files
soulseek-rs-modded/soulseek-rs/src/ui/login.rs
T
Michel de Graaf d6d870b39f feat(tui): account settings — stats, password change, and logout
The settings popup opens with an Account block: who the session is logged in
as, how much it shares, the port peers reach it on, and the daemon it borrows
when the window is attached to one. Two rows follow, both confirmed before
they act. A password is typed twice, because the server sends no verdict on a
change and a typo entered once would leave the account with a password nobody
knows. Logging out closes the window and comes back to the login screen with
the stored password deleted, so the next start asks rather than walking
straight back in. A window attached to a daemon offers no logout: the login is
the daemon's.

"Change it on the server, then store it where the login lives" is one function
over the session and the secret store, so the window, the command and the
daemon share the rule rather than each spelling out its own idea of which
process owns the password.

Headless the same two acts are `account password` and `account logout`, and
the daemon answers `account.password` so a routed run is not a dead end —
`whoami` already reported who is logged in and what they share.
2026-09-10 11:10:58 +02:00

427 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 crate::ui::{
GLYPH_CURSOR, accent_style, dimmed_style, error_style, info_style, mask,
pane_block, primary_style, title_style, warning_style,
};
use color_eyre::Result;
use ratatui::{
DefaultTerminal, Frame,
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll},
layout::{Constraint, Flex, Layout, Rect},
style::Modifier,
text::{Line, Span},
widgets::{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,
}
}
const 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>>> =
match initial_password {
Some(password) if !form.username.is_empty() => {
form.password = password;
form.phase = LoginPhase::Connecting;
Some(spawn_attempt(make_settings(
form.username.clone(),
form.password.clone(),
)))
}
_ => None,
};
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 = pane_block(true).title(Line::from(vec![
Span::styled(" soulseek-rs", primary_style()),
Span::styled(" login ", title_style(true)),
]));
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 {
info_style().add_modifier(Modifier::BOLD)
} else {
dimmed_style()
};
Paragraph::new(Line::from(vec![
Span::styled(format!("{marker}{label:<10}"), style),
Span::styled(value, primary_style()),
Span::styled(
if focused { GLYPH_CURSOR } else { "" },
accent_style(),
),
]))
};
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:",
mask(&form.password),
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…",
warning_style(),
))),
LoginPhase::Failed(message) => Paragraph::new(Line::from(
Span::styled(message.clone(), error_style()),
))
.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(dimmed_style()),
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");
}
}