diff --git a/README.md b/README.md index cb5d8f3..c2daa6a 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,13 @@ either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?` shows every key for where you are. `s` starts a search, and `S` runs the highlighted one again. +`o` opens settings: who this session is logged in as and how much it shares, +then the download and shared folders. `Enter` on a row acts on it — change the +password (typed twice, so a typo cannot leave the account with one nobody +knows) or log out, which forgets the stored password and comes back to the +login screen. Attached to a daemon the login is the daemon's, so the popup +offers no logout and a password change goes to the daemon. + ### Daemon mode A one-shot command logs in, does its job, and exits. Run a daemon instead and @@ -317,6 +324,8 @@ soulseek-rs serve [--follow] # stay online sharing, stream uploads soulseek-rs daemon [--bind ADDR] # run as a service others share soulseek-rs daemon token|status|stop # control a running one soulseek-rs whoami # confirm credentials and connection +soulseek-rs account password # change the account's password +soulseek-rs account logout # forget the stored password soulseek-rs user # a peer's status and share counts soulseek-rs shares list|add|remove|status|reindex soulseek-rs config path|list|get|set @@ -334,10 +343,10 @@ download folder. `--no-daemon` tests this machine's own login instead. `shares list` reads the config file either way, while `shares status` and `shares reindex` ask the session and report what peers can see. -Nineteen need no credentials: `config path|list|get|set`, +Twenty need no credentials: `config path|list|get|set`, `shares list|add|remove`, `wish add|remove|list`, `portmap`, -`skills install|uninstall|list`, `completions install|uninstall` and -`daemon token|status|stop`. Most never touch the network; `daemon status` and +`skills install|uninstall|list`, `completions install|uninstall`, +`account logout` and `daemon token|status|stop`. Most never touch the network; `daemon status` and `daemon stop` talk to a daemon that is already logged in, so a machine with no account of its own can still ask whether one is running. `shares status` and `shares reindex` are not among them, because reporting what the network sees @@ -368,6 +377,7 @@ daemon status user server clients uptime-secs daemon token token user user status average-speed shared-files whoami user server shared-folders shared-files +account action user shares list ok|missing directory shares status folders files config get|set|list|path key value @@ -594,6 +604,19 @@ Four ways to supply a password, in the order they are consulted: pass show soulseek | soulseek-rs --username alice --password-stdin get "some track" ``` +Changing the password goes through the server; forgetting one is local: + +```bash +pass show soulseek-new | soulseek-rs account password --new-password-stdin +soulseek-rs account logout # drop this machine's stored password +``` + +The server sends no verdict on a password change, so exit 0 means the request +went out. The new password replaces the stored one — the daemon's, when the +run is routed to a daemon, since that is the login that has to work after a +restart. `account logout` leaves the account untouched and only clears what +this machine kept, so the next start asks again. + Logging stays at errors only unless you ask for more: `-v` through `-vvvv` raise it, and `LOG_LEVEL`/`RUST_LOG` are honoured when no `-v` is given. `NO_COLOR` and a non-terminal stderr both disable colour. diff --git a/docs/daemon-protocol.md b/docs/daemon-protocol.md index 362ba18..7f139fd 100644 --- a/docs/daemon-protocol.md +++ b/docs/daemon-protocol.md @@ -148,6 +148,13 @@ file is its own; changing it does not reach a daemon. The exception is `shares.set`, which the CLI's `shares add`/`remove` call so a folder is served without waiting for a restart. +**A password change belongs to the daemon's account.** `account.password` +changes the password of the account the daemon is logged in as, and stores the +new one on the daemon's host so the next start still logs in. The server sends +no verdict on the change, so the `{"ok":true}` means the request went out. The +password crosses the socket in plain text, exactly like the token: keep TCP +behind an SSH tunnel. + **Several clients, no locking.** Last write wins. Two clients cancelling the same transfer is two hands on one keyboard, not an error. diff --git a/soulseek-rs/docs/openrpc.json b/soulseek-rs/docs/openrpc.json index 0ccad0c..1702a7a 100644 --- a/soulseek-rs/docs/openrpc.json +++ b/soulseek-rs/docs/openrpc.json @@ -595,6 +595,18 @@ ], "type": "object" }, + "PasswordParams": { + "description": "A new password for the account the daemon is logged in as. Plain text on\nthe wire, like the token: the transport is the local socket, or TCP behind\nan SSH tunnel (see docs/daemon-protocol.md).", + "properties": { + "password": { + "type": "string" + } + }, + "required": [ + "password" + ], + "type": "object" + }, "QueryParams": { "properties": { "query": { @@ -2317,6 +2329,26 @@ }, "summary": "Everyone this session is watching." }, + { + "name": "account.password", + "paramStructure": "by-name", + "params": [ + { + "name": "password", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Change the password of the account the daemon is logged in as, and store it on the daemon's host so a restart still logs in." + }, { "name": "shares.status", "paramStructure": "by-name", diff --git a/soulseek-rs/src/SKILL.md b/soulseek-rs/src/SKILL.md index b416efd..2e79643 100644 --- a/soulseek-rs/src/SKILL.md +++ b/soulseek-rs/src/SKILL.md @@ -152,6 +152,17 @@ cheapest way to prove the credentials and connection work. `privilege_seconds` is 0 for an ordinary account and `null` only when the server did not answer — those are different facts, so do not treat null as 0. +**`account password --new-password `** (or +`--new-password-stdin`, which reads the first line of stdin and keeps the +password out of `ps`) — one object: `user`, `action` (`password-changed`). +The server never confirms a password change, so exit 0 means the request went +out. The new password is stored in this machine's keychain, or the daemon's +when the run is routed to one. + +**`account logout`** — one object: `user`, `action` (`logged-out`). Forgets +the password stored on this machine; the account itself is untouched, and the +next start asks for it again. + **`room list`** — `room`, `users`. **`room users `** — `room`, `user`, `status`, `average_speed`, `shared_files`, `shared_folders`, `slots_full`, `country`. Everything but diff --git a/soulseek-rs/src/api.rs b/soulseek-rs/src/api.rs index 5c242f5..48d5d1e 100644 --- a/soulseek-rs/src/api.rs +++ b/soulseek-rs/src/api.rs @@ -178,6 +178,13 @@ pub trait SessionApi: Send + Sync { /// store and accepts silently. // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. fn set_download_directory(&self, directory: String) -> Result<()>; + + /// Make `password` this account's password on the server. + /// + /// `Ok(())` means the request reached the server, not that it took it: + /// the protocol's own reply to a password change is not read back. + // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. + fn change_password(&self, password: &str) -> Result<()>; } /// The local session: the library client itself, so `Arc` is already an @@ -416,4 +423,8 @@ impl SessionApi for Client { fn set_download_directory(&self, _directory: String) -> Result<()> { Ok(()) } + + fn change_password(&self, password: &str) -> Result<()> { + Self::change_password(self, password) + } } diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index ad2e2cf..0c8f581 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -222,6 +222,10 @@ pub enum Commands { /// Confirm the credentials and connection work Whoami, + /// This account: its password, and what this machine stores for it + #[command(subcommand)] + Account(AccountCommand), + /// The folders this client shares #[command(subcommand)] Shares(SharesCommand), @@ -270,6 +274,34 @@ pub enum DaemonCommand { Stop, } +#[derive(Subcommand, Debug)] +#[non_exhaustive] +pub enum AccountCommand { + /// Change the account's password on the server + Password(PasswordArgs), + + /// Forget the password stored on this machine, so the next start asks + /// for it again + Logout, +} + +#[derive(Args, Debug)] +pub struct PasswordArgs { + /// The new password (visible in `ps`; prefer --new-password-stdin) + #[arg( + long, + value_name = "PASSWORD", + env = "SOULSEEK_NEW_PASSWORD", + hide_env_values = true + )] + pub new_password: Option, + + /// Read the new password from the first line of stdin (takes precedence + /// over --new-password) + #[arg(long, action = ArgAction::SetTrue)] + pub new_password_stdin: bool, +} + #[derive(Subcommand, Debug)] #[non_exhaustive] pub enum CompletionsCommand { diff --git a/soulseek-rs/src/commands/account.rs b/soulseek-rs/src/commands/account.rs new file mode 100644 index 0000000..56e1521 --- /dev/null +++ b/soulseek-rs/src/commands/account.rs @@ -0,0 +1,59 @@ +//! The account itself: its password on the server, and the credentials this +//! machine keeps for it. + +use super::{Ctx, Session}; +use crate::cli::PasswordArgs; +use crate::output::{AccountRecord, CliError, CliResult, Out}; +use crate::persist::secret::{KeyringStore, SecretStore}; + +/// Change the account's password and store the new one, so the next start +/// still logs in. +pub fn password(ctx: &Ctx, args: &PasswordArgs) -> CliResult { + let new = new_password(args)?; + let session = Session::open(ctx)?; + let user = session.client.username(); + let stored = crate::persist::secret::change_password( + session.client.as_ref(), + &KeyringStore, + &new, + ) + .map_err(|e| { + CliError::connection(format!("cannot change the password: {e}")) + })?; + if let Some(e) = stored { + ctx.out + .warn(&format!("password changed, but not stored here: {e}")); + } + ctx.out.emit(&AccountRecord { + user, + action: "password-changed".to_string(), + }); + Ok(()) +} + +/// Forget the stored password. The account is untouched on the server; what +/// goes is this machine's copy of the way in. +pub fn logout(out: &Out, username: Option<&str>) -> CliResult { + let user = username.ok_or_else(crate::run::missing_username)?; + KeyringStore.delete(user).map_err(|e| { + CliError::usage(format!("cannot clear the stored password: {e}")) + })?; + out.emit(&AccountRecord { + user: user.to_string(), + action: "logged-out".to_string(), + }); + Ok(()) +} + +/// Stdin beats the flag, the way `--password-stdin` beats `--password`. +fn new_password(args: &PasswordArgs) -> CliResult { + if args.new_password_stdin { + return crate::run::password_from_stdin(); + } + match args.new_password.as_deref() { + Some(password) if !password.is_empty() => Ok(password.to_string()), + _ => Err(CliError::usage( + "no new password: pass --new-password or --new-password-stdin", + )), + } +} diff --git a/soulseek-rs/src/commands/mod.rs b/soulseek-rs/src/commands/mod.rs index 27df78a..c5a009e 100644 --- a/soulseek-rs/src/commands/mod.rs +++ b/soulseek-rs/src/commands/mod.rs @@ -5,6 +5,7 @@ //! first; the ones that only read the config file or the local machine are //! dispatched by `main::run` before any credentials are demanded. +pub mod account; pub mod completions; pub mod daemon; pub mod peer; @@ -15,7 +16,9 @@ pub mod transfer; pub mod wish; use crate::api::SessionApi; -use crate::cli::{Commands, MessageCommand, RoomCommand, TransferCommand}; +use crate::cli::{ + AccountCommand, Commands, MessageCommand, RoomCommand, TransferCommand, +}; use crate::output::{CliError, CliResult, Exit, Out, PortmapRecord}; use crate::port_mapping::{self, PortMapper}; use crate::remote::{Endpoint, RemoteSession}; @@ -314,9 +317,13 @@ fn dispatch(ctx: &Ctx, command: Commands) -> CliResult { Commands::User(args) => peer::user(ctx, &args), Commands::Watch(command) => peer::watch(ctx, &command), Commands::Whoami => peer::whoami(ctx), + Commands::Account(AccountCommand::Password(args)) => { + account::password(ctx, &args) + } // main.rs runs these itself: some need no account, the rest need the // config store in scope. The arms only keep the match exhaustive. - Commands::Portmap + Commands::Account(AccountCommand::Logout) + | Commands::Portmap | Commands::Serve(_) | Commands::Daemon(_) | Commands::Wish(_) diff --git a/soulseek-rs/src/daemon/mod.rs b/soulseek-rs/src/daemon/mod.rs index 0575f39..43453d9 100644 --- a/soulseek-rs/src/daemon/mod.rs +++ b/soulseek-rs/src/daemon/mod.rs @@ -197,6 +197,7 @@ pub fn run( let daemon = Arc::new(Daemon { session: Arc::clone(&session.client), + secrets: Arc::new(crate::persist::secret::KeyringStore), hub: Arc::clone(&hub), browses: Arc::clone(&browses), downloads: Arc::clone(&downloads), diff --git a/soulseek-rs/src/daemon/openrpc.rs b/soulseek-rs/src/daemon/openrpc.rs index 59e19ee..5312075 100644 --- a/soulseek-rs/src/daemon/openrpc.rs +++ b/soulseek-rs/src/daemon/openrpc.rs @@ -12,10 +12,10 @@ use super::proto::{ Ack, AuthParams, AuthResult, BrowseEvent, DaemonStatus, DirectoriesParams, DirectoryParams, DownloadStartParams, DownloadStarted, DownloadStatusEvent, Downloads, Event, IntervalSeconds, MemberStats, Members, MessageParams, - Messages, Method, QueryParams, RoomEventDto, RoomRef, SayParams, - SearchResults, Searches, Seconds, SessionLossEvent, SharesStatus, - SlotsParams, TransferRef, UploadInfoDto, Uploads, UserMessageDto, UserRef, - UserResult, Watched, + Messages, Method, PasswordParams, QueryParams, RoomEventDto, RoomRef, + SayParams, SearchResults, Searches, Seconds, SessionLossEvent, + SharesStatus, SlotsParams, TransferRef, UploadInfoDto, Uploads, + UserMessageDto, UserRef, UserResult, Watched, }; use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings}; use serde_json::{Value, json}; @@ -47,6 +47,7 @@ fn params_schema(method: Method, generator: &mut SchemaGenerator) -> Value { | Method::DownloadCancel | Method::UploadCancel => schema_of::(generator), Method::DownloadSetDir => schema_of::(generator), + Method::AccountPassword => schema_of::(generator), Method::UploadSlots => schema_of::(generator), Method::RoomJoin | Method::RoomLeave @@ -111,6 +112,7 @@ fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value { | Method::DownloadRemoveQueued | Method::DownloadCancel | Method::DownloadSetDir + | Method::AccountPassword | Method::UploadCancel | Method::UploadSlots | Method::PrivilegesCheck @@ -170,6 +172,10 @@ fn summary(method: Method) -> &'static str { Method::DownloadSetDir => { "Land future transfers in a different folder on the daemon's host." } + Method::AccountPassword => { + "Change the password of the account the daemon is logged in as, \ + and store it on the daemon's host so a restart still logs in." + } Method::UploadList => "Uploads being served, active first.", Method::UploadCancel => "Stop serving an upload in progress.", Method::UploadSlots => "How many uploads may run at once.", diff --git a/soulseek-rs/src/daemon/proto/method.rs b/soulseek-rs/src/daemon/proto/method.rs index af379e0..48f4976 100644 --- a/soulseek-rs/src/daemon/proto/method.rs +++ b/soulseek-rs/src/daemon/proto/method.rs @@ -82,6 +82,15 @@ pub struct DirectoriesParams { pub directories: Vec, } +/// A new password for the account the daemon is logged in as. Plain text on +/// the wire, like the token: the transport is the local socket, or TCP behind +/// an SSH tunnel (see docs/daemon-protocol.md). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct PasswordParams { + pub password: String, +} + /// Where the daemon should land transfers from now on, on *its* filesystem. /// The same trust as `shares.set`: an authenticated caller is the owner. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] diff --git a/soulseek-rs/src/daemon/proto/mod.rs b/soulseek-rs/src/daemon/proto/mod.rs index e0e243a..9f254f4 100644 --- a/soulseek-rs/src/daemon/proto/mod.rs +++ b/soulseek-rs/src/daemon/proto/mod.rs @@ -111,6 +111,7 @@ wire_vocabulary! { UserWatch => "user.watch", UserUnwatch => "user.unwatch", UserWatched => "user.watched", + AccountPassword => "account.password", SharesStatusOf => "shares.status", SharesSet => "shares.set", SharesReindex => "shares.reindex", diff --git a/soulseek-rs/src/daemon/server/mod.rs b/soulseek-rs/src/daemon/server/mod.rs index 37bd4e2..0396798 100644 --- a/soulseek-rs/src/daemon/server/mod.rs +++ b/soulseek-rs/src/daemon/server/mod.rs @@ -11,13 +11,14 @@ use super::proto::{ DaemonStatus, DirectoriesParams, DirectoryParams, DownloadDto, DownloadStartParams, DownloadStarted, Downloads, IntervalSeconds, MemberStats, Members, MessageParams, Messages, Method, OPENRPC, - PROTOCOL_VERSION, QueryParams, RoomRef, RoomUserStatsDto, RpcError, - SayParams, SearchResultDto, SearchResults, SearchSummary, Searches, - Seconds, SharesStatus, SlotsParams, TransferRef, UploadInfoDto, Uploads, - UserInfoDto, UserRef, UserResult, Watched, + PROTOCOL_VERSION, PasswordParams, QueryParams, RoomRef, RoomUserStatsDto, + RpcError, SayParams, SearchResultDto, SearchResults, SearchSummary, + Searches, Seconds, SharesStatus, SlotsParams, TransferRef, UploadInfoDto, + Uploads, UserInfoDto, UserRef, UserResult, Watched, }; use crate::api::SessionApi; use crate::output::Exit; +use crate::persist::secret::SecretStore; use serde::de::DeserializeOwned; use serde_json::Value; use std::sync::atomic::{AtomicBool, Ordering}; @@ -27,6 +28,9 @@ use std::time::{Duration, Instant}; /// Everything a connection needs to answer a request. pub struct Daemon { pub session: Arc, + /// Where this host keeps the account's password. The daemon owns the + /// login, so a password it changes it also has to store. + pub secrets: Arc, pub hub: Arc, pub browses: Arc, pub downloads: Arc>>, @@ -346,6 +350,27 @@ impl Daemon { users: self.session.watched_users(), }), + Method::AccountPassword => { + let params: PasswordParams = parse(params)?; + // The daemon logs in again from this host's store when it + // restarts, so a password it changed but never stored would + // lock it out of its own account. + let stored = crate::persist::secret::change_password( + self.session.as_ref(), + self.secrets.as_ref(), + ¶ms.password, + ) + .map_err(|e| { + RpcError::application(Exit::Connection, e.to_string()) + })?; + if let Some(e) = stored { + soulseek_rs::warn!( + "Password changed, but not stored on this host: {e}" + ); + } + ok(Ack::OK) + } + Method::SharesStatusOf => ok(self.shares()), Method::SharesSet => { let directories: DirectoriesParams = parse(params)?; @@ -656,6 +681,32 @@ mod tests { assert_eq!(stats["members"], json!([])); } + /// The daemon owns the login, so a password it changes it also stores — + /// its next start logs in from that store, not from the client that + /// asked. + #[test] + fn a_password_change_reaches_the_session_and_this_hosts_store() { + let session = Arc::new(SilentSession::default()); + let daemon = daemon_with("t", Arc::clone(&session)); + + daemon + .call(Method::AccountPassword, json!({ "password": "hunter2" })) + .expect("the password change is served"); + + assert_eq!( + *session.password.lock().expect("not poisoned"), + Some("hunter2".to_string()) + ); + assert_eq!( + daemon + .secrets + .get(&session.username()) + .expect("the store answers") + .as_deref(), + Some("hunter2") + ); + } + #[test] fn a_watch_is_readable_back_and_survives_until_unwatched() { // The three methods are only useful together: what `user.watch` @@ -719,14 +770,23 @@ mod tests { #[derive(Default)] struct SilentSession { watched: Mutex>, + /// What `account.password` asked the session to set, if anything. + password: Mutex>, } /// The one room `SilentSession` claims to have joined. const STUB_ROOM: &str = "jazz"; fn daemon_with_token(token: &str) -> Daemon { + daemon_with(token, Arc::new(SilentSession::default())) + } + + /// A daemon over a session the caller can read back, with an in-memory + /// secret store: no test may touch the machine's keychain. + fn daemon_with(token: &str, session: Arc) -> Daemon { Daemon { - session: Arc::new(SilentSession::default()), + session, + secrets: Arc::new(crate::persist::secret::MemoryStore::default()), hub: Arc::new(Hub::new()), browses: Arc::new(PendingBrowses::default()), downloads: Arc::new(Mutex::new(Vec::new())), @@ -745,6 +805,11 @@ mod tests { fn username(&self) -> String { "tester".to_string() } + fn change_password(&self, password: &str) -> soulseek_rs::Result<()> { + *self.password.lock().expect("not poisoned") = + Some(password.to_string()); + Ok(()) + } fn listen_port(&self) -> Option { None } diff --git a/soulseek-rs/src/models/app_state.rs b/soulseek-rs/src/models/app_state.rs index 5f2c4f6..a97f16e 100644 --- a/soulseek-rs/src/models/app_state.rs +++ b/soulseek-rs/src/models/app_state.rs @@ -222,6 +222,15 @@ impl WrappedLog { } } +/// Why the window closed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TuiExit { + /// The program is done. + Quit, + /// The account was logged out: the caller shows the login screen again. + Logout, +} + /// What the shared command bar is currently capturing input for. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CommandBarMode { @@ -286,7 +295,8 @@ pub struct AppState { /// Where the keys overlay is being read, for a terminal too short to /// show it whole. pub help_view: LogView, - pub should_exit: bool, + /// Set once, when the window is closing, to why. + pub exit: Option, pub command_bar_active: bool, pub command_bar_input: String, pub command_bar_cursor_position: usize, @@ -382,7 +392,7 @@ impl AppState { layout: PaneLayout::default(), show_help: false, help_view: LogView::default(), - should_exit: false, + exit: None, command_bar_active: false, command_bar_input: String::new(), command_bar_cursor_position: 0, diff --git a/soulseek-rs/src/models/mod.rs b/soulseek-rs/src/models/mod.rs index 2994800..69f5710 100644 --- a/soulseek-rs/src/models/mod.rs +++ b/soulseek-rs/src/models/mod.rs @@ -6,7 +6,8 @@ mod settings; pub use app_state::{ AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView, - MessageDirection, PaneLayout, SearchEntry, SearchStatus, WrappedLog, + MessageDirection, PaneLayout, SearchEntry, SearchStatus, TuiExit, + WrappedLog, }; pub use browse::{BrowseState, BrowseStatus, BrowseTabs}; pub use file_display_data::FileDisplayData; @@ -14,4 +15,6 @@ pub use rooms::{ ChatFilter, OpenRoom, RoomLine, RoomsState, RoomsView, contains_filter, matching_users, }; -pub use settings::{SettingsAction, SettingsMode, SettingsState}; +pub use settings::{ + AccountInfo, SettingsAction, SettingsMode, SettingsRow, SettingsState, +}; diff --git a/soulseek-rs/src/models/settings.rs b/soulseek-rs/src/models/settings.rs index 191378e..d892d99 100644 --- a/soulseek-rs/src/models/settings.rs +++ b/soulseek-rs/src/models/settings.rs @@ -1,9 +1,9 @@ -//! State machine for the settings popup (download folder + share paths). +//! State machine for the settings popup (account, download folder, shares). //! Pure — no client or terminal — so every transition is unit-testable. use ratatui::crossterm::event::{KeyCode, KeyEvent}; -/// Which row is highlighted: 0 = download dir, 1.. = share paths. +/// What the popup is doing with the keyboard. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SettingsMode { /// Moving between rows. @@ -12,10 +12,39 @@ pub enum SettingsMode { EditingDownloadDir, /// Typing a new share path to add. AddingShare, + /// Typing a new password; the keystrokes are masked. + NewPassword, + /// Typing it again, with the first entry held here. A password reaches the + /// server unconfirmed, so a typo entered once would change the account to + /// something nobody knows. + RepeatPassword(String), + /// Waiting for "log out?" to be answered. + ConfirmingLogout, +} + +impl SettingsMode { + /// Whether what is being typed must not be shown. + const fn is_secret(&self) -> bool { + matches!(self, Self::NewPassword | Self::RepeatPassword(_)) + } +} + +/// One line the selection can rest on. +/// +/// The rows are derived from the state rather than counted, because the logout +/// row is absent when the session belongs to a daemon and index arithmetic +/// would silently shift with it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsRow { + ChangePassword, + Logout, + DownloadDir, + /// Share path at this index. + Share(usize), } /// What the TUI should do after a key was handled. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum SettingsAction { None, /// Settings changed: persist config and apply the new share set live. @@ -24,12 +53,33 @@ pub enum SettingsAction { Reindex, /// Close the popup. Close, + /// Make this the account's password. + ChangePassword(String), + /// End the session and forget the stored password. + Logout, +} + +/// Who the session is logged in as and what it offers the network. +/// +/// Captured when the popup opens rather than read while drawing: attached to a +/// daemon every one of these is a round trip, and the popup redraws each tick. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountInfo { + pub username: String, + /// Already formatted, e.g. "1234 files in 56 folders". + pub shares: String, + /// The port peers can reach us on, when the listener is up. + pub listen_port: Option, + /// The daemon this session is borrowed from. `Some` means the account is + /// the daemon's, so this window cannot log it out. + pub daemon: Option, } pub struct SettingsState { + pub account: AccountInfo, pub download_dir: String, pub share_dirs: Vec, - /// 0 = download dir row; 1 + i = share path i. + /// Index into [`Self::rows`]. pub selected: usize, pub mode: SettingsMode, /// Edit buffer while typing. @@ -40,8 +90,13 @@ pub struct SettingsState { impl SettingsState { #[must_use] - pub const fn new(download_dir: String, share_dirs: Vec) -> Self { + pub const fn new( + account: AccountInfo, + download_dir: String, + share_dirs: Vec, + ) -> Self { Self { + account, download_dir, share_dirs, selected: 0, @@ -51,16 +106,42 @@ impl SettingsState { } } - const fn rows(&self) -> usize { - 1 + self.share_dirs.len() + /// The daemon owns its own login; a window borrowing it can only detach. + #[must_use] + pub const fn can_log_out(&self) -> bool { + self.account.daemon.is_none() + } + + /// Every row, in the order they are drawn. + #[must_use] + pub fn rows(&self) -> Vec { + let mut rows = vec![SettingsRow::ChangePassword]; + if self.can_log_out() { + rows.push(SettingsRow::Logout); + } + rows.push(SettingsRow::DownloadDir); + rows.extend((0..self.share_dirs.len()).map(SettingsRow::Share)); + rows + } + + fn last_row(&self) -> usize { + self.rows().len() - 1 + } + + /// The row the selection rests on. + #[must_use] + pub fn selected_row(&self) -> SettingsRow { + self.rows()[self.selected.min(self.last_row())] } pub fn handle_key(&mut self, key: KeyEvent) -> SettingsAction { match self.mode { SettingsMode::Navigate => self.handle_navigate(key), - SettingsMode::EditingDownloadDir | SettingsMode::AddingShare => { - self.handle_typing(key) - } + SettingsMode::ConfirmingLogout => self.handle_logout_confirm(key), + SettingsMode::EditingDownloadDir + | SettingsMode::AddingShare + | SettingsMode::NewPassword + | SettingsMode::RepeatPassword(_) => self.handle_typing(key), } } @@ -73,22 +154,28 @@ impl SettingsState { SettingsAction::None } KeyCode::Down | KeyCode::Char('j') => { - self.selected = (self.selected + 1).min(self.rows() - 1); + self.selected = (self.selected + 1).min(self.last_row()); SettingsAction::None } - KeyCode::Enter | KeyCode::Char('e') if self.selected == 0 => { - self.mode = SettingsMode::EditingDownloadDir; - self.input = self.download_dir.clone(); - SettingsAction::None + KeyCode::Enter => self.activate(), + // 'e' has always meant "edit this folder"; it must not reach a row + // that logs the account out. + KeyCode::Char('e') + if self.selected_row() == SettingsRow::DownloadDir => + { + self.activate() } KeyCode::Char('a') => { self.mode = SettingsMode::AddingShare; self.input.clear(); SettingsAction::None } - KeyCode::Char('d') if self.selected > 0 => { - self.share_dirs.remove(self.selected - 1); - self.selected = self.selected.min(self.rows() - 1); + KeyCode::Char('d') => { + let SettingsRow::Share(index) = self.selected_row() else { + return SettingsAction::None; + }; + self.share_dirs.remove(index); + self.selected = self.selected.min(self.last_row()); SettingsAction::Apply } KeyCode::Char('r') => SettingsAction::Reindex, @@ -96,6 +183,31 @@ impl SettingsState { } } + fn activate(&mut self) -> SettingsAction { + match self.selected_row() { + SettingsRow::ChangePassword => { + self.mode = SettingsMode::NewPassword; + // Defensive: a password buffer is never left holding anything. + self.input.clear(); + } + SettingsRow::Logout => self.mode = SettingsMode::ConfirmingLogout, + SettingsRow::DownloadDir => { + self.mode = SettingsMode::EditingDownloadDir; + self.input.clone_from(&self.download_dir); + } + SettingsRow::Share(_) => {} + } + SettingsAction::None + } + + fn handle_logout_confirm(&mut self, key: KeyEvent) -> SettingsAction { + self.mode = SettingsMode::Navigate; + if matches!(key.code, KeyCode::Char('y' | 'Y') | KeyCode::Enter) { + return SettingsAction::Logout; + } + SettingsAction::None + } + fn handle_typing(&mut self, key: KeyEvent) -> SettingsAction { match key.code { KeyCode::Esc => { @@ -103,25 +215,7 @@ impl SettingsState { self.input.clear(); SettingsAction::None } - KeyCode::Enter => { - let value = self.input.trim().to_string(); - let adding = self.mode == SettingsMode::AddingShare; - self.mode = SettingsMode::Navigate; - self.input.clear(); - if value.is_empty() { - return SettingsAction::None; - } - if adding { - if self.share_dirs.contains(&value) { - return SettingsAction::None; - } - self.share_dirs.push(value); - self.selected = self.rows() - 1; - } else { - self.download_dir = value; - } - SettingsAction::Apply - } + KeyCode::Enter => self.submit(), KeyCode::Backspace => { self.input.pop(); SettingsAction::None @@ -133,6 +227,48 @@ impl SettingsState { _ => SettingsAction::None, } } + + fn submit(&mut self) -> SettingsAction { + if self.mode.is_secret() { + return self.submit_password(); + } + let value = self.input.trim().to_string(); + let adding = self.mode == SettingsMode::AddingShare; + self.mode = SettingsMode::Navigate; + self.input.clear(); + if value.is_empty() { + return SettingsAction::None; + } + if adding { + if self.share_dirs.contains(&value) { + return SettingsAction::None; + } + self.share_dirs.push(value); + self.selected = self.last_row(); + } else { + self.download_dir = value; + } + SettingsAction::Apply + } + + /// The first entry is only remembered; the second is what commits. + fn submit_password(&mut self) -> SettingsAction { + let typed = std::mem::take(&mut self.input); + let SettingsMode::RepeatPassword(first) = + std::mem::replace(&mut self.mode, SettingsMode::Navigate) + else { + if !typed.is_empty() { + self.mode = SettingsMode::RepeatPassword(typed); + } + return SettingsAction::None; + }; + if typed != first { + self.status = + Some("The two passwords differ — nothing was changed".into()); + return SettingsAction::None; + } + SettingsAction::ChangePassword(typed) + } } #[cfg(test)] @@ -150,8 +286,30 @@ mod tests { } } + fn account() -> AccountInfo { + AccountInfo { + username: "alice".into(), + shares: "12 files in 3 folders".into(), + listen_port: Some(2234), + daemon: None, + } + } + fn state() -> SettingsState { - SettingsState::new("/dl".into(), vec!["/dl".into(), "/music".into()]) + SettingsState::new( + account(), + "/dl".into(), + vec!["/dl".into(), "/music".into()], + ) + } + + /// Put the selection on a row by name, whatever index it has. + fn select(state: &mut SettingsState, row: SettingsRow) { + state.selected = state + .rows() + .iter() + .position(|candidate| *candidate == row) + .expect("the row is present"); } #[test] @@ -172,6 +330,7 @@ mod tests { assert_eq!(action, SettingsAction::Apply); assert_eq!(s.share_dirs, vec!["/dl", "/music", "/flacs"]); assert_eq!(s.mode, SettingsMode::Navigate); + assert_eq!(s.selected_row(), SettingsRow::Share(2)); } #[test] @@ -186,16 +345,16 @@ mod tests { #[test] fn deleting_the_selected_share_applies() { let mut s = state(); - s.handle_key(key(KeyCode::Down)); // select share 0 - s.handle_key(key(KeyCode::Down)); // select share 1 (/music) + select(&mut s, SettingsRow::Share(1)); let action = s.handle_key(key(KeyCode::Char('d'))); assert_eq!(action, SettingsAction::Apply); assert_eq!(s.share_dirs, vec!["/dl"]); } #[test] - fn delete_on_the_download_dir_row_does_nothing() { + fn delete_on_a_row_that_is_not_a_share_does_nothing() { let mut s = state(); + select(&mut s, SettingsRow::DownloadDir); assert_eq!(s.handle_key(key(KeyCode::Char('d'))), SettingsAction::None); assert_eq!(s.share_dirs.len(), 2); } @@ -203,6 +362,7 @@ mod tests { #[test] fn editing_the_download_dir_applies() { let mut s = state(); + select(&mut s, SettingsRow::DownloadDir); s.handle_key(key(KeyCode::Enter)); assert_eq!(s.mode, SettingsMode::EditingDownloadDir); assert_eq!(s.input, "/dl"); @@ -217,6 +377,7 @@ mod tests { #[test] fn escape_cancels_an_edit_without_applying() { let mut s = state(); + select(&mut s, SettingsRow::DownloadDir); s.handle_key(key(KeyCode::Enter)); type_str(&mut s, "junk"); assert_eq!(s.handle_key(key(KeyCode::Esc)), SettingsAction::None); @@ -238,10 +399,112 @@ mod tests { for _ in 0..10 { s.handle_key(key(KeyCode::Down)); } - assert_eq!(s.selected, 2); + assert_eq!(s.selected_row(), SettingsRow::Share(1)); for _ in 0..10 { s.handle_key(key(KeyCode::Up)); } - assert_eq!(s.selected, 0); + assert_eq!(s.selected_row(), SettingsRow::ChangePassword); + } + + #[test] + fn a_password_typed_twice_the_same_way_is_changed() { + let mut s = state(); + select(&mut s, SettingsRow::ChangePassword); + s.handle_key(key(KeyCode::Enter)); + assert_eq!(s.mode, SettingsMode::NewPassword); + type_str(&mut s, "hunter2"); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None); + assert!(matches!(s.mode, SettingsMode::RepeatPassword(_))); + // The first entry is not left lying in the buffer the popup draws. + assert!(s.input.is_empty()); + type_str(&mut s, "hunter2"); + assert_eq!( + s.handle_key(key(KeyCode::Enter)), + SettingsAction::ChangePassword("hunter2".into()) + ); + assert_eq!(s.mode, SettingsMode::Navigate); + } + + #[test] + fn a_mistyped_repeat_changes_nothing_and_says_so() { + let mut s = state(); + select(&mut s, SettingsRow::ChangePassword); + s.handle_key(key(KeyCode::Enter)); + type_str(&mut s, "hunter2"); + s.handle_key(key(KeyCode::Enter)); + type_str(&mut s, "hunter3"); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None); + assert_eq!(s.mode, SettingsMode::Navigate); + assert!(s.status.is_some()); + } + + #[test] + fn an_empty_password_is_not_asked_for_twice() { + let mut s = state(); + select(&mut s, SettingsRow::ChangePassword); + s.handle_key(key(KeyCode::Enter)); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None); + assert_eq!(s.mode, SettingsMode::Navigate); + } + + #[test] + fn escape_during_the_repeat_forgets_the_first_entry() { + let mut s = state(); + select(&mut s, SettingsRow::ChangePassword); + s.handle_key(key(KeyCode::Enter)); + type_str(&mut s, "hunter2"); + s.handle_key(key(KeyCode::Enter)); + s.handle_key(key(KeyCode::Esc)); + // Starting over asks for both entries again rather than matching the + // abandoned one. + s.handle_key(key(KeyCode::Enter)); + type_str(&mut s, "hunter2"); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None); + assert!(matches!(s.mode, SettingsMode::RepeatPassword(_))); + } + + #[test] + fn logging_out_is_confirmed_first() { + let mut s = state(); + select(&mut s, SettingsRow::Logout); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None); + assert_eq!(s.mode, SettingsMode::ConfirmingLogout); + assert_eq!( + s.handle_key(key(KeyCode::Char('y'))), + SettingsAction::Logout + ); + } + + #[test] + fn anything_but_yes_cancels_the_logout() { + let mut s = state(); + select(&mut s, SettingsRow::Logout); + s.handle_key(key(KeyCode::Enter)); + assert_eq!(s.handle_key(key(KeyCode::Char('n'))), SettingsAction::None); + assert_eq!(s.mode, SettingsMode::Navigate); + } + + #[test] + fn a_daemons_session_offers_no_logout_row() { + let mut s = SettingsState::new( + AccountInfo { + daemon: Some("127.0.0.1:2245".into()), + ..account() + }, + "/dl".into(), + vec!["/music".into()], + ); + assert_eq!( + s.rows(), + vec![ + SettingsRow::ChangePassword, + SettingsRow::DownloadDir, + SettingsRow::Share(0), + ] + ); + // The row below "change password" is the download folder, not a + // logout the window cannot perform. + s.handle_key(key(KeyCode::Down)); + assert_eq!(s.selected_row(), SettingsRow::DownloadDir); } } diff --git a/soulseek-rs/src/output.rs b/soulseek-rs/src/output.rs index abe9d61..21d6aa4 100644 --- a/soulseek-rs/src/output.rs +++ b/soulseek-rs/src/output.rs @@ -345,6 +345,20 @@ impl Record for WhoamiRecord { } } +/// What happened to this account. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AccountRecord { + pub user: String, + /// `password-changed` or `logged-out`. + pub action: String, +} + +impl Record for AccountRecord { + fn text(&self) -> String { + format!("{}\t{}", sanitize(&self.action), sanitize(&self.user)) + } +} + /// What the server knows about another user. /// Each field is `None` when the server has not answered that half yet, so a /// consumer never reads "offline" or "shares nothing" from a missing reply. diff --git a/soulseek-rs/src/persist/secret.rs b/soulseek-rs/src/persist/secret.rs index 034fc90..7d41f1a 100644 --- a/soulseek-rs/src/persist/secret.rs +++ b/soulseek-rs/src/persist/secret.rs @@ -1,11 +1,18 @@ +use crate::api::SessionApi; use color_eyre::Result; /// Where passwords live. The real implementation is the OS keychain /// ([`KeyringStore`]); tests use an in-memory fake. Passwords are never /// written to config.toml. -pub trait SecretStore { +/// +/// `Send + Sync` because the daemon holds one behind an `Arc` and answers +/// requests on a thread per connection. +pub trait SecretStore: Send + Sync { fn get(&self, username: &str) -> Result>; fn set(&self, username: &str, password: &str) -> Result<()>; + /// Forget this account's password. Succeeds when there was nothing + /// stored: the caller wants it gone, and it is. + fn delete(&self, username: &str) -> Result<()>; } /// OS keychain (macOS Keychain / Windows Credential Manager / Linux Secret @@ -30,6 +37,40 @@ impl SecretStore for KeyringStore { .and_then(|entry| entry.set_password(password)) .map_err(|e| color_eyre::eyre::eyre!("keyring: {e}")) } + + fn delete(&self, username: &str) -> Result<()> { + match keyring::Entry::new(SERVICE, username) + .and_then(|entry| entry.delete_credential()) + { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(color_eyre::eyre::eyre!("keyring: {e}")), + } + } +} + +/// Change the account's password and keep the local copy in step, so the next +/// start still logs in. +/// +/// One rule in one place: whoever owns the login owns the stored copy. A +/// window or a command borrowing a daemon's session owns neither, and the +/// daemon writes its own host's store when it serves the request. +/// +/// `Ok` means the request reached the server, not that the server took it — +/// Soulseek answers a password change with nothing at all — and carries the +/// store's complaint when the change went out but the local copy did not. +pub fn change_password( + session: &dyn SessionApi, + secrets: &dyn SecretStore, + password: &str, +) -> soulseek_rs::Result> { + session.change_password(password)?; + if session.daemon_endpoint().is_some() { + return Ok(None); + } + Ok(secrets + .set(&session.username(), password) + .err() + .map(|e| e.to_string())) } /// Resolve the password with precedence: CLI/env > keychain > `password_cmd`. @@ -72,33 +113,48 @@ fn run_password_cmd(cmd: &str) -> Option { (!password.is_empty()).then(|| password.to_string()) } +/// An in-memory store for tests, so no test touches the machine's keychain. +#[cfg(test)] +#[derive(Default)] +pub struct MemoryStore { + secrets: std::sync::Mutex>, + /// Answer every read as if there were no secret service at all. + pub fail: bool, +} + +#[cfg(test)] +impl SecretStore for MemoryStore { + fn get(&self, username: &str) -> Result> { + if self.fail { + return Err(color_eyre::eyre::eyre!("no secret service")); + } + Ok(self + .secrets + .lock() + .expect("not poisoned") + .get(username) + .cloned()) + } + + fn set(&self, username: &str, password: &str) -> Result<()> { + self.secrets + .lock() + .expect("not poisoned") + .insert(username.into(), password.into()); + Ok(()) + } + + fn delete(&self, username: &str) -> Result<()> { + self.secrets.lock().expect("not poisoned").remove(username); + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; - use std::cell::RefCell; - use std::collections::HashMap; - #[derive(Default)] - struct FakeStore { - secrets: RefCell>, - fail: bool, - } - - impl SecretStore for FakeStore { - fn get(&self, username: &str) -> Result> { - if self.fail { - return Err(color_eyre::eyre::eyre!("no secret service")); - } - Ok(self.secrets.borrow().get(username).cloned()) - } - - fn set(&self, username: &str, password: &str) -> Result<()> { - self.secrets - .borrow_mut() - .insert(username.into(), password.into()); - Ok(()) - } - } + type FakeStore = MemoryStore; #[test] fn cli_password_wins_over_everything() { @@ -156,6 +212,16 @@ mod tests { assert_eq!(resolve_password(None, Some("alice"), None, &store), None); } + #[test] + fn a_deleted_password_is_no_longer_resolved() { + let store = FakeStore::default(); + store.set("alice", "from-keyring").unwrap(); + store.delete("alice").unwrap(); + assert_eq!(resolve_password(None, Some("alice"), None, &store), None); + // Deleting again is not an error: the password is gone either way. + store.delete("alice").unwrap(); + } + #[test] fn no_username_means_no_keyring_lookup() { let store = FakeStore { diff --git a/soulseek-rs/src/remote/mod.rs b/soulseek-rs/src/remote/mod.rs index 435d4b0..73d4ee1 100644 --- a/soulseek-rs/src/remote/mod.rs +++ b/soulseek-rs/src/remote/mod.rs @@ -13,9 +13,9 @@ use crate::daemon::proto::{ DaemonStatus, DirectoriesParams, DirectoryParams, DownloadDto, DownloadStartParams, DownloadStarted, Downloads, Event, IntervalSeconds, MemberStats, Members, MessageParams, Messages, Method, PROTOCOL_VERSION, - QueryParams, Request, Response, RoomRef, RpcError, SayParams, - SearchResults, Searches, Seconds, SharesStatus, SlotsParams, TransferRef, - Uploads, UserRef, UserResult, Watched, + PasswordParams, QueryParams, Request, Response, RoomRef, RpcError, + SayParams, SearchResults, Searches, Seconds, SharesStatus, SlotsParams, + TransferRef, Uploads, UserRef, UserResult, Watched, }; use mirror::Mirror; use serde::de::DeserializeOwned; @@ -830,6 +830,15 @@ impl SessionApi for RemoteSession { fn set_download_directory(&self, directory: String) -> Result<()> { self.tell(Method::DownloadSetDir, DirectoryParams { directory }) } + + fn change_password(&self, password: &str) -> Result<()> { + self.tell( + Method::AccountPassword, + PasswordParams { + password: password.to_string(), + }, + ) + } } #[cfg(test)] diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs index 11c2c20..2a0ea7b 100644 --- a/soulseek-rs/src/run.rs +++ b/soulseek-rs/src/run.rs @@ -6,7 +6,9 @@ use crate::cli::{ parse_server_address, }; use crate::commands::Ctx; +use crate::models::TuiExit; use crate::output::{CliError, CliResult, Exit, Out}; +use crate::persist::secret::SecretStore; use crate::ui::launch_main_tui; use soulseek_rs::{ClientSettings, ClientVersion, PeerAddress}; use std::io::{BufRead, IsTerminal}; @@ -57,7 +59,7 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { if let Some(endpoint) = daemon_endpoint(&cli, &resolved) { return run_attached_tui(&resolved, &endpoint, config_path); } - return run_default_tui(&cli, &resolved, config_path, &file_config); + return run_default_tui(&cli, &resolved, config_path); }; // Anything that only reads the network stack or the config file runs @@ -74,6 +76,14 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { Commands::Skills(ref command) => { return crate::commands::skills::run(out, command); } + // Forgetting a password needs no session — least of all the one it is + // about to make unusable. + Commands::Account(crate::cli::AccountCommand::Logout) => { + return crate::commands::account::logout( + out, + resolved.username.as_deref(), + ); + } Commands::Completions(ref command) => { return crate::commands::completions::run(out, command); } @@ -211,12 +221,7 @@ fn context( (String::new(), String::new()) } else { ( - resolved.username.clone().ok_or_else(|| { - CliError::usage( - "no username: pass --username, set SOULSEEK_USERNAME, or \ - put one in config.toml", - ) - })?, + resolved.username.clone().ok_or_else(missing_username)?, password(cli, resolved)?.ok_or_else(|| { CliError::usage( "no password: pass --password/--password-stdin, set \ @@ -325,16 +330,7 @@ fn password( resolved: &crate::persist::config::Resolved, ) -> CliResult> { if cli.password_stdin { - let mut line = String::new(); - io::stdin() - .lock() - .read_line(&mut line) - .map_err(|e| CliError::usage(format!("cannot read stdin: {e}")))?; - let line = line.trim_end_matches(['\r', '\n']); - if line.is_empty() { - return Err(CliError::usage("no password on stdin")); - } - return Ok(Some(line.to_string())); + return password_from_stdin().map(Some); } Ok(crate::persist::secret::resolve_password( cli.password.as_deref(), @@ -344,6 +340,29 @@ fn password( )) } +/// Read a password from the first line of stdin, the way `docker login` does. +pub fn password_from_stdin() -> CliResult { + let mut line = String::new(); + io::stdin() + .lock() + .read_line(&mut line) + .map_err(|e| CliError::usage(format!("cannot read stdin: {e}")))?; + let line = line.trim_end_matches(['\r', '\n']); + if line.is_empty() { + return Err(CliError::usage("no password on stdin")); + } + Ok(line.to_string()) +} + +/// What to say when nothing supplied a username. +#[must_use] +pub fn missing_username() -> CliError { + CliError::usage( + "no username: pass --username, set SOULSEEK_USERNAME, or put one in \ + config.toml", + ) +} + /// Validate the configured shares. A bad entry is a warning, not a failure: /// the client simply shares less. fn shared_directories( @@ -403,12 +422,11 @@ fn enter_terminal() -> ratatui::DefaultTerminal { /// 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. +/// main UI. Logging out from the settings popup starts the round again. fn run_default_tui( cli: &Cli, resolved: &crate::persist::config::Resolved, config_path: Option, - file_config: &crate::persist::config::FileConfig, ) -> CliResult { let out = Out::new(false, false); let (server_host, server_port) = @@ -426,10 +444,7 @@ fn run_default_tui( let shared_directories = shared_directories(resolved, &out); let secret_store = crate::persist::secret::KeyringStore; - let initial_password = password(cli, resolved)?; - - // Enable logger buffering BEFORE connection to prevent log artifacts - soulseek_rs::utils::logger::enable_buffering(); + let mut initial_password = password(cli, resolved)?; let enable_listen = !resolved.disable_listener; let listen_port = resolved.listener_port; @@ -446,57 +461,80 @@ fn run_default_tui( version: ClientVersion::REFERENCE_CLIENT, }; - let mut terminal = enter_terminal(); + // Logging out closes the window and comes straight back to the login + // screen, so one pass of this loop is one session: terminal, credentials, + // main UI. The port mapping and the state store are dropped at the end of + // the pass; the client goes with the last worker thread still holding it, + // so a search or browse in flight can keep the old session open a little + // past the logout. + let mut username = resolved.username.clone(); + loop { + // Enable logger buffering BEFORE connection to prevent log artifacts. + // The window turns it off again when it gives the terminal back. + soulseek_rs::utils::logger::enable_buffering(); + let mut terminal = enter_terminal(); - let outcome = crate::ui::login::run_login_flow( - &mut terminal, - &make_settings, - resolved.username.clone(), - initial_password, - ); + let outcome = crate::ui::login::run_login_flow( + &mut terminal, + &make_settings, + username.clone(), + initial_password.take(), + ); - let outcome = match outcome { - Ok(Some(outcome)) => outcome, - Ok(None) => { - // User cancelled at the login screen. - ratatui::restore(); - soulseek_rs::utils::logger::disable_buffering(); + let outcome = match outcome { + Ok(Some(outcome)) => outcome, + // The user cancelled at the login screen. + Ok(None) => return leave_terminal(Ok(())), + Err(e) => { + return leave_terminal(Err(CliError::new( + Exit::Failure, + e.to_string(), + ))); + } + }; + + persist_credentials(&outcome, config_path.as_deref(), &secret_store); + username = Some(outcome.username.clone()); + + // Best-effort: make ourselves reachable behind a home router so + // firewalled peers can connect back. Mapped only once the listener is + // up, and for the port it really bound. Kept alive for the session. + let _port_mapper = outcome + .client + .listen_port() + .map(crate::port_mapping::PortMapper::spawn); + + let store = crate::persist::paths::state_dir() + .map(crate::persist::state::StateStore::new); + + let exit = launch_main_tui( + terminal, + Arc::new(outcome.client), + resolved.download_dir.clone(), + Duration::from_secs(resolved.search_timeout), + store, + config_path.clone(), + ) + .map_err(|e| CliError::new(Exit::Failure, e.to_string()))?; + + if exit == TuiExit::Quit { return Ok(()); } - Err(e) => { - ratatui::restore(); - soulseek_rs::utils::logger::disable_buffering(); - return Err(CliError::new(Exit::Failure, e.to_string())); + + // Logged out, so the stored password goes with the session: this pass + // is already asking for one, but the next start must ask too rather + // than walk straight back in. + if let Err(e) = secret_store.delete(&outcome.username) { + out.warn(&format!("could not clear the stored password: {e}")); } - }; + } +} - persist_credentials( - &outcome, - config_path.as_deref(), - file_config, - &secret_store, - ); - - // Best-effort: make ourselves reachable behind a home router so firewalled - // peers can connect back. Mapped only once the listener is up, and for the - // port it really bound. Kept alive for the session. - let _port_mapper = outcome - .client - .listen_port() - .map(crate::port_mapping::PortMapper::spawn); - - let store = crate::persist::paths::state_dir() - .map(crate::persist::state::StateStore::new); - - launch_main_tui( - terminal, - Arc::new(outcome.client), - resolved.download_dir.clone(), - Duration::from_secs(resolved.search_timeout), - store, - config_path, - ) - .map_err(|e| CliError::new(Exit::Failure, e.to_string())) +/// Give the terminal back and let logging through again, on the way out. +fn leave_terminal(result: CliResult) -> CliResult { + ratatui::restore(); + soulseek_rs::utils::logger::disable_buffering(); + result } /// Run the TUI against a daemon's session. @@ -542,6 +580,8 @@ fn run_attached_tui( // No StateStore: the daemon owns what survives a restart, so a second // writer here would fight it for the same files. + // The daemon owns the login, so this window has no logout to come back + // from: however it closed, the run is over. launch_main_tui( terminal, Arc::new(session), @@ -550,6 +590,7 @@ fn run_attached_tui( None, config_path, ) + .map(|_| ()) .map_err(|e| CliError::new(Exit::Failure, e.to_string())) } @@ -559,17 +600,10 @@ fn run_attached_tui( fn persist_credentials( outcome: &crate::ui::login::LoginOutcome, config_path: Option<&Path>, - file_config: &crate::persist::config::FileConfig, secret_store: &dyn crate::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 let Some(path) = config_path { + save_username(path, &outcome.username); } if outcome.entered_via_form && let Err(e) = secret_store.set(&outcome.username, &outcome.password) @@ -578,6 +612,25 @@ fn persist_credentials( } } +/// Remember who logged in, for the next start to offer. +/// +/// Read back from disk rather than from the copy this run started with: the +/// settings popup writes the same file, and a second login after a logout +/// would otherwise save a snapshot taken before those edits. +fn save_username(path: &Path, username: &str) { + let mut config = match crate::persist::config::FileConfig::load(path) { + Ok(config) => config, + Err(e) => return eprintln!("⚠️ Could not read config: {e}"), + }; + if config.username.as_deref() == Some(username) { + return; + } + config.username = Some(username.to_string()); + if let Err(e) = config.save(path) { + eprintln!("⚠️ Could not save config: {e}"); + } +} + /// Open files a session asks the OS for at startup. /// /// The client holds a socket per peer and will talk to a thousand of them diff --git a/soulseek-rs/src/ui/login.rs b/soulseek-rs/src/ui/login.rs index f126c79..4bf2334 100644 --- a/soulseek-rs/src/ui/login.rs +++ b/soulseek-rs/src/ui/login.rs @@ -6,7 +6,7 @@ //! ([`run_login_flow`]) drives it against a real terminal and client. use crate::ui::{ - GLYPH_CURSOR, accent_style, dimmed_style, error_style, info_style, + GLYPH_CURSOR, accent_style, dimmed_style, error_style, info_style, mask, pane_block, primary_style, title_style, warning_style, }; use color_eyre::Result; @@ -281,7 +281,7 @@ fn render(frame: &mut Frame, form: &LoginForm) { frame.render_widget( field( "Password:", - "•".repeat(form.password.chars().count()), + mask(&form.password), editing && form.focused == LoginField::Password, ), rows[1], diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs index 84bdc5b..bed4c20 100644 --- a/soulseek-rs/src/ui/main_tui/input.rs +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -72,7 +72,7 @@ impl MainTui { // Global shortcuts match key.code { KeyCode::Char('q') => { - self.state.should_exit = true; + self.state.exit = Some(crate::models::TuiExit::Quit); return; } KeyCode::Char('?') => { diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 5b35068..034dc5e 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -7,7 +7,7 @@ mod search; mod settings; use crate::api::SessionApi; -use crate::models::AppState; +use crate::models::{AppState, TuiExit}; use crate::persist::{ snapshot::{Snapshot, restore_messages, restore_searches}, state::{PersistedMessage, StateStore}, @@ -182,7 +182,7 @@ impl MainTui { self.saved_snapshot = snapshot; } - pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { + pub fn run(mut self, mut terminal: DefaultTerminal) -> Result { use ratatui::crossterm::{event::DisableMouseCapture, execute}; // Run the event loop, then restore the terminal unconditionally: if the @@ -196,7 +196,7 @@ impl MainTui { ratatui::restore(); soulseek_rs::utils::logger::disable_buffering(); - result + result.map(|()| self.state.exit.unwrap_or(TuiExit::Quit)) } /// What the window holds, for a test to read back after driving it. @@ -219,7 +219,7 @@ impl MainTui { } fn run_event_loop(&mut self, terminal: &mut DefaultTerminal) -> Result<()> { - while !self.state.should_exit { + while self.state.exit.is_none() { terminal.draw(|frame| self.render(frame))?; self.poll_session(); @@ -239,7 +239,7 @@ impl MainTui { } _ => {} } - if self.state.should_exit || !poll(Duration::ZERO)? { + if self.state.exit.is_some() || !poll(Duration::ZERO)? { break; } } @@ -257,7 +257,7 @@ pub fn launch_main_tui( search_timeout: Duration, store: Option, config_path: Option, -) -> Result<()> { +) -> Result { let tui = MainTui::new(client, download_dir, search_timeout, store, config_path); tui.run(terminal) @@ -295,6 +295,8 @@ mod tests { download_dir_set: std::sync::Mutex>, /// What `set_shared_directories` was last asked for, if anything. shares_set: std::sync::Mutex>>, + /// What `change_password` was last asked for, if anything. + password_set: std::sync::Mutex>, download_cancelled: std::sync::Mutex>, upload_cancelled: std::sync::Mutex>, download_removed: std::sync::Mutex>, @@ -550,7 +552,12 @@ mod tests { None } fn shared_counts(&self) -> (u32, u32) { - (0, 0) + (3, 12) + } + fn change_password(&self, password: &str) -> soulseek_rs::Result<()> { + *self.password_set.lock().expect("not poisoned") = + Some(password.to_string()); + Ok(()) } fn shared_directories(&self) -> Vec { Vec::new() @@ -1276,6 +1283,100 @@ mod tests { assert_eq!(tui.download_dir, downloads_dir); } + /// The account block answers "who is this session, and what is it + /// offering" without a trip to another screen. + #[test] + fn the_settings_popup_names_the_account_and_its_shares() { + let mut tui = with_session(TalkativeSession::default()); + press(&mut tui, KeyCode::Char('o')); + + let screen = screen_sized(&mut tui, 100, 30); + assert!(screen.contains("tester"), "{screen}"); + assert!(screen.contains("12 files in 3 folders"), "{screen}"); + } + + /// Typed twice the same way, the new password reaches the session — and + /// attached to a daemon it is the daemon that stores it. + #[test] + fn a_password_typed_twice_reaches_the_session() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let mut tui = attach(session.clone()); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Enter); // the change-password row is first + for _ in 0..2 { + for c in "hunter2".chars() { + press(&mut tui, KeyCode::Char(c)); + } + press(&mut tui, KeyCode::Enter); + } + + assert_eq!( + *session.password_set.lock().expect("not poisoned"), + Some("hunter2".to_string()) + ); + let settings = tui.state.settings.as_ref().expect("still open"); + assert_eq!(settings.status.as_deref(), Some("Password changed")); + } + + /// A mistyped repeat is caught here rather than on the server, where the + /// account would be left with a password nobody knows. + #[test] + fn a_password_typed_two_different_ways_is_not_sent() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let mut tui = attach(session.clone()); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Enter); + for typed in ["hunter2", "hunter3"] { + for c in typed.chars() { + press(&mut tui, KeyCode::Char(c)); + } + press(&mut tui, KeyCode::Enter); + } + + assert_eq!(*session.password_set.lock().expect("not poisoned"), None); + } + + /// Logging out ends the window, but as a log-out: the caller brings the + /// login screen back rather than returning to the shell. + #[test] + fn logging_out_asks_first_and_then_closes_the_window() { + let mut tui = with_session(TalkativeSession::default()); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Down); // change password -> log out + press(&mut tui, KeyCode::Enter); + assert!(tui.state.exit.is_none(), "the confirm comes first"); + + press(&mut tui, KeyCode::Char('y')); + assert_eq!(tui.state.exit, Some(TuiExit::Logout)); + } + + /// Attached, the login is the daemon's: there is no logout row to land + /// on, so the row below the password is the download folder. + #[test] + fn an_attached_window_offers_no_logout() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let mut tui = attach(session); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Down); + press(&mut tui, KeyCode::Enter); + + let settings = tui.state.settings.as_ref().expect("still open"); + assert_eq!( + settings.mode, + crate::models::SettingsMode::EditingDownloadDir + ); + assert!(tui.state.exit.is_none()); + } + fn results(n: usize) -> Vec { (0..n) .map(|i| crate::models::FileDisplayData { @@ -1448,7 +1549,7 @@ mod tests { ctrl(&mut tui, 'b'); assert!(!tui.state.command_bar_active, "ctrl-b is not b"); ctrl(&mut tui, 'q'); - assert!(!tui.state.should_exit, "ctrl-q is not q"); + assert!(tui.state.exit.is_none(), "ctrl-q is not q"); } #[test] @@ -1682,7 +1783,7 @@ mod tests { // Other keys are swallowed while it is open. press(&mut tui, KeyCode::Char('q')); - assert!(!tui.state.should_exit, "q closes the list, not the app"); + assert!(tui.state.exit.is_none(), "q closes the list, not the app"); assert!(!tui.state.show_help); press(&mut tui, KeyCode::Char('?')); diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index fdd6aa7..34a312d 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -6,9 +6,9 @@ use crate::ui::panes::{ render_rooms_pane, render_searches_pane, selected_transfer, }; use crate::ui::{ - accent_style, body_style, dimmed_style, info_style, pack_shortcuts, - pane_block, plain_title, primary_style, render_download_stats, - warning_style, + GLYPH_CURSOR, accent_style, body_style, dimmed_style, info_style, mask, + pack_shortcuts, pane_block, plain_title, primary_style, + render_download_stats, warning_style, }; use ratatui::{ Frame, @@ -57,6 +57,14 @@ const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[ ("0 / $", "its start / its end"), ], ), + ( + "Settings popup", + &[ + ("Enter", "change the password, log out, edit the folder"), + ("a / d", "add / remove a shared folder"), + ("r", "re-index the shares"), + ], + ), ]; const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[ @@ -129,6 +137,48 @@ fn filter_keys( keys } +/// What the bar offers in the settings popup: the keys that always apply, +/// plus what Enter does on the row the selection rests on. +fn settings_shortcuts( + settings: &crate::models::SettingsState, +) -> Vec<(&'static str, &'static str)> { + use crate::models::{SettingsMode, SettingsRow}; + match settings.mode { + SettingsMode::ConfirmingLogout => { + vec![("y", "log out"), ("n/Esc", "stay")] + } + SettingsMode::NewPassword => { + vec![ + ("Type", "password"), + ("Enter", "repeat it"), + ("Esc", "cancel"), + ] + } + SettingsMode::RepeatPassword(_) => { + vec![ + ("Type", "password"), + ("Enter", "change it"), + ("Esc", "cancel"), + ] + } + SettingsMode::EditingDownloadDir | SettingsMode::AddingShare => { + vec![("Type", "path"), ("Enter", "save"), ("Esc", "cancel")] + } + SettingsMode::Navigate => vec![ + ("↑↓", "move"), + match settings.selected_row() { + SettingsRow::ChangePassword => ("Enter", "change password"), + SettingsRow::Logout => ("Enter", "log out"), + SettingsRow::DownloadDir => ("Enter", "edit folder"), + SettingsRow::Share(_) => ("d", "remove share"), + }, + ("a", "add share"), + ("r", "re-index"), + ("Esc", "close"), + ], + } +} + impl MainTui { /// Draw the whole window. pub fn render(&mut self, frame: &mut Frame) { @@ -450,44 +500,101 @@ impl MainTui { } fn render_settings_popup(&self, frame: &mut Frame) { - use crate::models::SettingsMode; + use crate::models::{SettingsMode, SettingsRow}; let Some(settings) = self.state.settings.as_ref() else { return; }; let area = centered_rect(70, 60, frame.area()); frame.render_widget(ratatui::widgets::Clear, area); + let selected = settings.selected_row(); let mut lines: Vec = Vec::new(); - let marker = |selected: bool| if selected { "> " } else { " " }; - let entry = |selected: bool, label: &str, value: String| { + let entry = |on: bool, label: &str, value: String| { Line::from(vec![ - Span::styled(marker(selected).to_string(), accent_style()), + Span::styled(if on { "> " } else { " " }, accent_style()), Span::styled(label.to_string(), dimmed_style()), Span::styled(value, primary_style()), ]) }; - - lines.push(if settings.mode == SettingsMode::EditingDownloadDir { + let typing = |label: &str, value: String| { Line::from(vec![ Span::styled("> ", accent_style()), - Span::styled("Download folder: ", dimmed_style()), - Span::styled(settings.input.clone(), primary_style()), - Span::styled("▏", accent_style()), + Span::styled(label.to_string(), dimmed_style()), + Span::styled(value, primary_style()), + Span::styled(GLYPH_CURSOR, accent_style()), ]) + }; + let section = |name: &str| Line::styled(name.to_string(), info_style()); + + // One column of labels, padded here rather than by hand in each + // string. + let field = |label: &str, value: String| { + entry(false, &format!(" {label:<14}"), value) + }; + let account = &settings.account; + lines.push(section("Account")); + lines.push(field("Signed in as", account.username.clone())); + lines.push(field("Sharing", account.shares.clone())); + lines.push(field( + "Reachable on", + account.listen_port.map_or_else( + || "not accepting connections".to_string(), + |port| format!("port {port}"), + ), + )); + if let Some(daemon) = &account.daemon { + lines.push(field("Daemon", daemon.clone())); + } + lines.push(Line::from("")); + + lines.push(match settings.mode { + SettingsMode::NewPassword => { + typing("New password: ", mask(&settings.input)) + } + SettingsMode::RepeatPassword(_) => { + typing("Repeat it: ", mask(&settings.input)) + } + _ => entry( + selected == SettingsRow::ChangePassword, + "Change password…", + String::new(), + ), + }); + if settings.mode == SettingsMode::ConfirmingLogout { + lines.push(entry( + true, + &format!("Log out of {}? ", account.username), + "y / n".to_string(), + )); + } else if settings.can_log_out() { + lines.push(entry( + selected == SettingsRow::Logout, + "Log out…", + String::new(), + )); + } + lines.push(Line::from("")); + + lines.push(section("Folders")); + lines.push(if settings.mode == SettingsMode::EditingDownloadDir { + typing("Download folder: ", settings.input.clone()) } else { entry( - settings.selected == 0, + selected == SettingsRow::DownloadDir, "Download folder: ", settings.download_dir.clone(), ) }); - lines.push(Line::from("")); lines.push(Line::styled( format!("Shared folders ({}):", settings.share_dirs.len()), dimmed_style(), )); for (i, dir) in settings.share_dirs.iter().enumerate() { - lines.push(entry(settings.selected == i + 1, "", dir.clone())); + lines.push(entry( + selected == SettingsRow::Share(i), + "", + dir.clone(), + )); } if settings.share_dirs.is_empty() { lines.push(Line::styled( @@ -496,11 +603,7 @@ impl MainTui { )); } if settings.mode == SettingsMode::AddingShare { - lines.push(Line::from(vec![ - Span::styled(" Add share: ", dimmed_style()), - Span::styled(settings.input.clone(), primary_style()), - Span::styled("▏", accent_style()), - ])); + lines.push(typing(" Add share: ", settings.input.clone())); } if let Some(status) = &settings.status { lines.push(Line::from("")); @@ -599,15 +702,8 @@ impl MainTui { fn shortcuts(&self) -> Vec<(&'static str, &'static str)> { if self.state.show_help { vec![("?/Esc", "close")] - } else if self.state.settings.is_some() { - vec![ - ("↑↓", "move"), - ("Enter/e", "edit download dir"), - ("a", "add share"), - ("d", "remove share"), - ("r", "re-index"), - ("Esc", "close"), - ] + } else if let Some(settings) = self.state.settings.as_ref() { + settings_shortcuts(settings) } else if self.state.show_messages { if self.state.chat_composing { vec![ diff --git a/soulseek-rs/src/ui/main_tui/settings.rs b/soulseek-rs/src/ui/main_tui/settings.rs index 42c60a3..63f568e 100644 --- a/soulseek-rs/src/ui/main_tui/settings.rs +++ b/soulseek-rs/src/ui/main_tui/settings.rs @@ -3,12 +3,21 @@ //! socket when the session is a daemon's). use super::MainTui; -use crate::models::{SettingsAction, SettingsState}; +use crate::models::{AccountInfo, SettingsAction, SettingsState}; +use crate::persist::secret::KeyringStore; use ratatui::crossterm::event::KeyEvent; impl MainTui { pub(super) fn open_settings(&mut self) { + // Read once here, not while drawing: see `AccountInfo`. + let account = AccountInfo { + username: self.client.username(), + shares: self.share_counts(), + listen_port: self.client.listen_port(), + daemon: self.client.daemon_endpoint(), + }; self.state.settings = Some(SettingsState::new( + account, self.download_dir.clone(), self.client.shared_directories(), )); @@ -23,9 +32,28 @@ impl MainTui { SettingsAction::Close => self.state.settings = None, SettingsAction::Apply => self.apply_settings(), SettingsAction::Reindex => self.reindex_shares(), + SettingsAction::ChangePassword(password) => { + self.change_password(&password); + } + SettingsAction::Logout => { + self.state.exit = Some(crate::models::TuiExit::Logout); + } } } + fn change_password(&mut self, password: &str) { + let status = match crate::persist::secret::change_password( + self.client.as_ref(), + &KeyringStore, + password, + ) { + Ok(None) => "Password changed".into(), + Ok(Some(e)) => format!("Password changed, but not stored: {e}"), + Err(e) => format!("Could not change it: {e}"), + }; + self.set_settings_status(status); + } + /// Persist the edited settings and apply them live. /// /// Attached to a daemon, both folders name paths on the *daemon's* @@ -71,9 +99,10 @@ impl MainTui { let mut status = if !refused.is_empty() { format!("Could not apply {}", refused.join("; ")) } else if dropped > 0 { + self.refresh_share_counts(); format!("Applied ({dropped} invalid path(s) ignored)") } else { - format!("Applied · sharing {}", self.share_counts()) + format!("Applied · sharing {}", self.refresh_share_counts()) }; if dir_applied { self.download_dir.clone_from(&download_dir); @@ -101,6 +130,16 @@ impl MainTui { self.set_settings_status(status); } + /// Bring the account block's share count back in step, and hand the same + /// count to the caller: counting is a round trip worth making once. + fn refresh_share_counts(&mut self) -> String { + let counts = self.share_counts(); + if let Some(settings) = self.state.settings.as_mut() { + settings.account.shares.clone_from(&counts); + } + counts + } + fn set_settings_status(&mut self, status: String) { if let Some(settings) = self.state.settings.as_mut() { settings.status = Some(status); @@ -111,13 +150,11 @@ impl MainTui { fn reindex_shares(&mut self) { let dirs = self.client.shared_directories(); let result = self.client.set_shared_directories(dirs); - let counts = self.share_counts(); - if let Some(settings) = self.state.settings.as_mut() { - settings.status = Some(match result { - Ok(()) => format!("Re-indexed · sharing {counts}"), - Err(e) => format!("Re-index failed: {e}"), - }); - } + let counts = self.refresh_share_counts(); + self.set_settings_status(match result { + Ok(()) => format!("Re-indexed · sharing {counts}"), + Err(e) => format!("Re-index failed: {e}"), + }); } fn share_counts(&self) -> String { diff --git a/soulseek-rs/src/ui/styles.rs b/soulseek-rs/src/ui/styles.rs index b807b88..3e953ee 100644 --- a/soulseek-rs/src/ui/styles.rs +++ b/soulseek-rs/src/ui/styles.rs @@ -38,6 +38,12 @@ pub const GLYPH_DONE: &str = "✓"; pub const GLYPH_FAILED: &str = "✗"; pub const GLYPH_TIMED_OUT: &str = "⧗"; pub const GLYPH_CURSOR: &str = "▮"; + +/// What a password looks like on screen: one bullet per character. +#[must_use] +pub fn mask(secret: &str) -> String { + "•".repeat(secret.chars().count()) +} pub const HIGHLIGHT_SYMBOL: &str = "›"; /// Between a key and its action in the legend, spaced. pub const SHORTCUT_ARROW: &str = " → ";