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.
This commit is contained in:
Michel de Graaf
2026-09-10 11:10:58 +02:00
parent 4ea1e9c47c
commit d6d870b39f
26 changed files with 1137 additions and 215 deletions
+26 -3
View File
@@ -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 <NAME> # 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.
+7
View File
@@ -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.
+32
View File
@@ -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",
+11
View File
@@ -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 <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>`** — `room`, `user`, `status`, `average_speed`,
`shared_files`, `shared_folders`, `slots_full`, `country`. Everything but
+11
View File
@@ -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<Client>` 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)
}
}
+32
View File
@@ -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<String>,
/// 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 {
+59
View File
@@ -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<String> {
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",
)),
}
}
+9 -2
View File
@@ -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(_)
+1
View File
@@ -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),
+10 -4
View File
@@ -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::<TransferRef>(generator),
Method::DownloadSetDir => schema_of::<DirectoryParams>(generator),
Method::AccountPassword => schema_of::<PasswordParams>(generator),
Method::UploadSlots => schema_of::<SlotsParams>(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.",
+9
View File
@@ -82,6 +82,15 @@ pub struct DirectoriesParams {
pub directories: Vec<String>,
}
/// 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)]
+1
View File
@@ -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",
+70 -5
View File
@@ -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<dyn SessionApi>,
/// 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<dyn SecretStore>,
pub hub: Arc<Hub>,
pub browses: Arc<PendingBrowses>,
pub downloads: Arc<Mutex<Vec<PendingDownload>>>,
@@ -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(),
&params.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<Vec<String>>,
/// What `account.password` asked the session to set, if anything.
password: Mutex<Option<String>>,
}
/// 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<SilentSession>) -> 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<u16> {
None
}
+12 -2
View File
@@ -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<TuiExit>,
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,
+5 -2
View File
@@ -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,
};
+297 -34
View File
@@ -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<u16>,
/// 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<String>,
}
pub struct SettingsState {
pub account: AccountInfo,
pub download_dir: String,
pub share_dirs: Vec<String>,
/// 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<String>) -> Self {
pub const fn new(
account: AccountInfo,
download_dir: String,
share_dirs: Vec<String>,
) -> 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<SettingsRow> {
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,7 +215,23 @@ impl SettingsState {
self.input.clear();
SettingsAction::None
}
KeyCode::Enter => {
KeyCode::Enter => self.submit(),
KeyCode::Backspace => {
self.input.pop();
SettingsAction::None
}
KeyCode::Char(c) => {
self.input.push(c);
SettingsAction::None
}
_ => 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;
@@ -116,22 +244,30 @@ impl SettingsState {
return SettingsAction::None;
}
self.share_dirs.push(value);
self.selected = self.rows() - 1;
self.selected = self.last_row();
} else {
self.download_dir = value;
}
SettingsAction::Apply
}
KeyCode::Backspace => {
self.input.pop();
SettingsAction::None
/// 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);
}
KeyCode::Char(c) => {
self.input.push(c);
SettingsAction::None
}
_ => SettingsAction::None,
return SettingsAction::None;
};
if typed != first {
self.status =
Some("The two passwords differ — nothing was changed".into());
return SettingsAction::None;
}
SettingsAction::ChangePassword(typed)
}
}
@@ -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);
}
}
+14
View File
@@ -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.
+78 -12
View File
@@ -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<Option<String>>;
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<Option<String>> {
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<String> {
(!password.is_empty()).then(|| password.to_string())
}
/// An in-memory store for tests, so no test touches the machine's keychain.
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::collections::HashMap;
#[derive(Default)]
struct FakeStore {
secrets: RefCell<HashMap<String, String>>,
fail: bool,
pub struct MemoryStore {
secrets: std::sync::Mutex<std::collections::HashMap<String, String>>,
/// Answer every read as if there were no secret service at all.
pub fail: bool,
}
impl SecretStore for FakeStore {
#[cfg(test)]
impl SecretStore for MemoryStore {
fn get(&self, username: &str) -> Result<Option<String>> {
if self.fail {
return Err(color_eyre::eyre::eyre!("no secret service"));
}
Ok(self.secrets.borrow().get(username).cloned())
Ok(self
.secrets
.lock()
.expect("not poisoned")
.get(username)
.cloned())
}
fn set(&self, username: &str, password: &str) -> Result<()> {
self.secrets
.borrow_mut()
.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::*;
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 {
+12 -3
View File
@@ -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)]
+105 -52
View File
@@ -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,6 +330,18 @@ fn password(
resolved: &crate::persist::config::Resolved,
) -> CliResult<Option<String>> {
if cli.password_stdin {
return password_from_stdin().map(Some);
}
Ok(crate::persist::secret::resolve_password(
cli.password.as_deref(),
resolved.username.as_deref(),
resolved.password_cmd.as_deref(),
&crate::persist::secret::KeyringStore,
))
}
/// Read a password from the first line of stdin, the way `docker login` does.
pub fn password_from_stdin() -> CliResult<String> {
let mut line = String::new();
io::stdin()
.lock()
@@ -334,14 +351,16 @@ fn password(
if line.is_empty() {
return Err(CliError::usage("no password on stdin"));
}
return Ok(Some(line.to_string()));
Ok(line.to_string())
}
Ok(crate::persist::secret::resolve_password(
cli.password.as_deref(),
resolved.username.as_deref(),
resolved.password_cmd.as_deref(),
&crate::persist::secret::KeyringStore,
))
/// 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:
@@ -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<PathBuf>,
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,40 +461,44 @@ fn run_default_tui(
version: ClientVersion::REFERENCE_CLIENT,
};
// 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,
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();
return Ok(());
}
// The user cancelled at the login screen.
Ok(None) => return leave_terminal(Ok(())),
Err(e) => {
ratatui::restore();
soulseek_rs::utils::logger::disable_buffering();
return Err(CliError::new(Exit::Failure, e.to_string()));
return leave_terminal(Err(CliError::new(
Exit::Failure,
e.to_string(),
)));
}
};
persist_credentials(
&outcome,
config_path.as_deref(),
file_config,
&secret_store,
);
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.
// 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()
@@ -488,15 +507,34 @@ fn run_default_tui(
let store = crate::persist::paths::state_dir()
.map(crate::persist::state::StateStore::new);
launch_main_tui(
let exit = launch_main_tui(
terminal,
Arc::new(outcome.client),
resolved.download_dir.clone(),
Duration::from_secs(resolved.search_timeout),
store,
config_path,
config_path.clone(),
)
.map_err(|e| CliError::new(Exit::Failure, e.to_string()))
.map_err(|e| CliError::new(Exit::Failure, e.to_string()))?;
if exit == TuiExit::Quit {
return Ok(());
}
// 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}"));
}
}
}
/// 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
+2 -2
View File
@@ -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],
+1 -1
View File
@@ -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('?') => {
+110 -9
View File
@@ -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<TuiExit> {
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<StateStore>,
config_path: Option<std::path::PathBuf>,
) -> Result<()> {
) -> Result<TuiExit> {
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<Option<String>>,
/// What `set_shared_directories` was last asked for, if anything.
shares_set: std::sync::Mutex<Option<Vec<String>>>,
/// What `change_password` was last asked for, if anything.
password_set: std::sync::Mutex<Option<String>>,
download_cancelled: std::sync::Mutex<Vec<(String, String)>>,
upload_cancelled: std::sync::Mutex<Vec<(String, String)>>,
download_removed: std::sync::Mutex<Vec<(String, String)>>,
@@ -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<String> {
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<crate::models::FileDisplayData> {
(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('?'));
+125 -29
View File
@@ -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<Line> = 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![
+43 -6
View File
@@ -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,7 +32,26 @@ 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.
@@ -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,14 +150,12 @@ 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 {
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 {
let (folders, files) = self.client.shared_counts();
+6
View File
@@ -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 = " → ";