diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29f4526..4fb67f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,3 +107,13 @@ jobs: env: SOULSEEK_E2E_REQUIRED: "1" run: cargo test -p soulseek-rs --test cli_e2e -- --nocapture + + # The same suite again, with every session served by a daemon instead of + # the command's own login. Identical records and exit codes both ways is + # what makes daemon mode a routing decision rather than a second + # implementation to keep in step. + - name: Run CLI e2e tests against a daemon + env: + SOULSEEK_E2E_REQUIRED: "1" + SOULSEEK_E2E_MODE: daemon + run: cargo test -p soulseek-rs --test cli_e2e -- --nocapture --test-threads=1 diff --git a/soulseek-rs/src/api.rs b/soulseek-rs/src/api.rs index 585375a..4493d94 100644 --- a/soulseek-rs/src/api.rs +++ b/soulseek-rs/src/api.rs @@ -12,6 +12,7 @@ //! change is [`SessionApi::username`], which returns an owned `String`: a //! remote session has no borrowed client to hand a `&str` out of. +use crate::daemon::proto::ChatMessageDto; use soulseek_rs::types::{Download, DownloadMetadata}; use soulseek_rs::{ Client, DownloadStatus, Result, RoomEvent, SearchResult, SessionLoss, @@ -101,6 +102,17 @@ pub trait SessionApi: Send + Sync { fn request_user_info(&self, username: &str) -> Result<()>; fn user_info(&self, username: &str) -> Option; + /// The conversation this session already knows about. + /// + /// Empty locally, where the TUI's own snapshot on disk is the record. A + /// daemon has been collecting private messages the whole time it has been + /// running, including while nothing was attached, so an attaching client + /// asks for the backlog rather than starting a conversation half-way + /// through. + fn message_history(&self) -> Vec { + Vec::new() + } + fn shared_counts(&self) -> (u32, u32); fn shared_directories(&self) -> Vec; fn set_shared_directories(&self, directories: Vec) -> Result<()>; diff --git a/soulseek-rs/src/commands/peer.rs b/soulseek-rs/src/commands/peer.rs index 38e7df6..9c06960 100644 --- a/soulseek-rs/src/commands/peer.rs +++ b/soulseek-rs/src/commands/peer.rs @@ -106,18 +106,27 @@ pub fn serve( args: &ServeArgs, mut sweeper: Option, ) -> CliResult { - if ctx.settings.shared_directories.is_empty() { - return Err(CliError::usage( - "nothing to share: pass --shared-dir, or add one with `shares add`", - )); - } - if !ctx.settings.enable_listen { - return Err(CliError::usage( - "serving needs the listener: drop --no-listener", - )); + // Cheap check first, so a misconfigured local run is rejected before it + // bothers the server with a login. + if ctx.daemon.is_none() { + if ctx.settings.shared_directories.is_empty() { + return Err(nothing_to_share()); + } + if !ctx.settings.enable_listen { + return Err(needs_a_listener()); + } } let session = Session::open(ctx)?; + // What the session actually offers is what decides. Routed to a daemon + // that is what the daemon shares, which is the only thing peers can see — + // this run's own configuration has no bearing on it. + if session.client.shared_directories().is_empty() { + return Err(nothing_to_share()); + } + if session.client.listen_port().is_none() { + return Err(needs_a_listener()); + } if let Some(slots) = args.upload_slots { session.client.set_upload_slots(usize::from(slots)); ctx.out.status(&format!("{slots} upload slots")); @@ -169,6 +178,16 @@ pub fn serve( Ok(()) } +fn nothing_to_share() -> CliError { + CliError::usage( + "nothing to share: pass --shared-dir, or add one with `shares add`", + ) +} + +fn needs_a_listener() -> CliError { + CliError::usage("serving needs the listener: drop --no-listener") +} + /// Flatten a live upload into the record `serve` reports. fn upload_record(upload: &UploadInfo) -> UploadRecord { let (status, reason) = match &upload.status { diff --git a/soulseek-rs/src/daemon/hub.rs b/soulseek-rs/src/daemon/hub.rs index 1acee85..492c444 100644 --- a/soulseek-rs/src/daemon/hub.rs +++ b/soulseek-rs/src/daemon/hub.rs @@ -20,7 +20,7 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel}; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; /// How often the drainer samples the session. The TUI already redraws on this /// cadence, so events reach a remote UI as promptly as a local one. @@ -157,22 +157,39 @@ struct DrainState { reported_loss: bool, } -/// Peers we have asked for a listing and are still waiting on. `take_browse_result` -/// is per-user and destructive, so the daemon has to know who to ask about. +/// How long to keep asking whether a peer has answered a browse request. +/// +/// A peer that is offline or uninterested never replies, and the daemon would +/// otherwise poll for it until restart — so a script that browsed a few +/// thousand absent peers would leave the drainer doing thousands of pointless +/// lookups every tick. Comfortably longer than any client's own browse +/// timeout, so this expiry never beats a real answer. +const BROWSE_PATIENCE: Duration = Duration::from_mins(2); + +/// Peers we have asked for a listing and are still waiting on. +/// `take_browse_result` is per-user and destructive, so the daemon has to know +/// who to ask about. #[derive(Default)] -pub struct PendingBrowses(Mutex>); +pub struct PendingBrowses(Mutex>); impl PendingBrowses { pub fn expect(&self, username: &str) { - if let Ok(mut pending) = self.0.lock() - && !pending.iter().any(|name| name == username) - { - pending.push(username.to_string()); - } + let Ok(mut pending) = self.0.lock() else { + return; + }; + // Asking again restarts the clock: it is a fresh request. + pending.retain(|(name, _)| name != username); + pending.push((username.to_string(), Instant::now())); } + /// Who to ask about this tick, forgetting the ones that have run out of + /// patience. fn snapshot(&self) -> Vec { - self.0.lock().map_or_else(|_| Vec::new(), |pending| pending.clone()) + let Ok(mut pending) = self.0.lock() else { + return Vec::new(); + }; + pending.retain(|(_, asked)| asked.elapsed() < BROWSE_PATIENCE); + pending.iter().map(|(name, _)| name.clone()).collect() } fn settled(&self, username: &str) { @@ -182,7 +199,7 @@ impl PendingBrowses { /// Stop waiting on a peer whose request never reached the wire. pub fn forget(&self, username: &str) { if let Ok(mut pending) = self.0.lock() { - pending.retain(|name| name != username); + pending.retain(|(name, _)| name != username); } } } @@ -446,4 +463,16 @@ mod tests { pending.settled("bob"); assert!(pending.snapshot().is_empty()); } + + #[test] + fn a_browse_that_is_never_answered_stops_being_polled() { + // A peer that is offline never replies; without an expiry the drainer + // would look for its answer on every tick until the daemon restarts. + let asked = Instant::now() + .checked_sub(BROWSE_PATIENCE + Duration::from_secs(1)) + .expect("the clock is past the epoch"); + let pending = + PendingBrowses(Mutex::new(vec![("ghost".to_string(), asked)])); + assert!(pending.snapshot().is_empty()); + } } diff --git a/soulseek-rs/src/daemon/server.rs b/soulseek-rs/src/daemon/server.rs index 05178da..5f561f3 100644 --- a/soulseek-rs/src/daemon/server.rs +++ b/soulseek-rs/src/daemon/server.rs @@ -523,20 +523,21 @@ fn handle(stream: Stream, daemon: Arc) -> std::io::Result<()> { }) }; + // Requests are answered on this thread, in order. A method slow enough to + // matter (re-indexing a large share) delays only the connection that asked + // for it, which is the right person to wait — and every other client has a + // connection of its own. Spawning per request would instead churn a thread + // for each of the handful of polls a TUI makes every frame. for line in lines { let line = line?; if line.trim().is_empty() { continue; } - let daemon = Arc::clone(&daemon); - let out = out.clone(); - // One thread per request: a slow method must not hold up the ones - // behind it, and the ids let a client match replies out of order. - std::thread::spawn(move || { - if let Some(response) = answer(&line, &daemon) { - let _ = out.send(response); - } - }); + if let Some(response) = answer(&line, &daemon) + && out.send(response).is_err() + { + break; + } } daemon.hub.unsubscribe(subscription); diff --git a/soulseek-rs/src/directories.rs b/soulseek-rs/src/directories.rs index 28375bc..be3edb1 100644 --- a/soulseek-rs/src/directories.rs +++ b/soulseek-rs/src/directories.rs @@ -37,6 +37,7 @@ pub fn resolve_shared_directory( /// Validate several configured share paths; invalid ones are skipped with a /// warning instead of aborting (the client just shares less). +#[cfg_attr(not(test), allow(dead_code))] #[must_use] pub fn resolve_shared_directories(dirs: &[String]) -> Vec { let mut resolved = Vec::new(); diff --git a/soulseek-rs/src/lib.rs b/soulseek-rs/src/lib.rs index 11ce9c1..bda54b4 100644 --- a/soulseek-rs/src/lib.rs +++ b/soulseek-rs/src/lib.rs @@ -1,5 +1,6 @@ -//! Library surface of the TUI binary, exposed for integration tests. -//! The binary itself compiles these modules directly (see `main.rs`). +//! The program itself. `main.rs` is a thin entry point over this crate, so +//! every module is compiled once and integration tests drive the same code the +//! binary runs. pub mod api; pub mod cli; @@ -11,3 +12,5 @@ pub mod output; pub mod persist; pub(crate) mod port_mapping; pub mod remote; +pub mod run; +pub(crate) mod ui; diff --git a/soulseek-rs/src/main.rs b/soulseek-rs/src/main.rs index 88297b1..1aec4fe 100644 --- a/soulseek-rs/src/main.rs +++ b/soulseek-rs/src/main.rs @@ -1,40 +1,19 @@ -mod api; -mod cli; -mod commands; -mod daemon; -mod directories; -mod models; -mod output; -mod persist; -mod port_mapping; -mod remote; -mod ui; +//! Entry point. Everything it does lives in the library crate, so the same +//! code is what integration tests drive. -use clap::Parser; -use cli::{ - Cli, Commands, ConfigCommand, DaemonCommand, SharesCommand, WishCommand, - parse_server_address, -}; -use commands::Ctx; -use output::{CliError, CliResult, Exit, Out}; -use soulseek_rs::{ClientSettings, ClientVersion, PeerAddress}; -use std::io::{BufRead, IsTerminal}; -use std::path::PathBuf; +use soulseek_rs_tui::output::{Exit, Out}; +use soulseek_rs_tui::run; use std::process::ExitCode; -use std::sync::Arc; -use std::time::Duration; -use std::{env, io}; -use ui::launch_main_tui; fn main() -> ExitCode { dotenv::dotenv().ok(); let _ = color_eyre::install(); - let cli = Cli::parse(); - init_logging(&cli); + let cli = ::parse(); + run::init_logging(&cli); let out = Out::new(cli.json, cli.quiet); - match run(cli, &out) { + match run::run(cli, &out) { Ok(()) => Exit::Ok.into(), Err(error) => { out.error(&error.message); @@ -42,491 +21,3 @@ fn main() -> ExitCode { } } } - -fn run(mut cli: Cli, out: &Out) -> CliResult { - let config_path = config_path(&cli); - let file_config = match &config_path { - Some(path) => persist::config::FileConfig::load(path) - .map_err(|e| CliError::usage(e.to_string()))?, - None => persist::config::FileConfig::default(), - }; - let resolved = persist::config::resolve(&cli, &file_config); - - let Some(command) = cli.command.take() else { - // A running daemon already holds a session; the TUI attaches to it - // rather than logging in a second time, which the server would answer - // by cutting the daemon off. - if let Some(endpoint) = daemon_endpoint(&cli) { - return run_attached_tui(&cli, &resolved, &endpoint); - } - return run_default_tui(&cli, &resolved, config_path, &file_config); - }; - - // Anything that only reads the network stack or the config file runs - // before credentials are demanded: asking a script for a login it does not - // need would be a poor trade for a `config get`. - let store = commands::settings::Store { - path: config_path, - config: file_config, - }; - match command { - Commands::Portmap => { - return commands::portmap(out, resolved.listener_port); - } - Commands::Skills(ref command) => { - return commands::skills::run(out, command); - } - Commands::Completions(ref command) => { - return commands::completions::run(out, command); - } - Commands::Config(ConfigCommand::Path) => { - return commands::settings::config_path(out, &store); - } - Commands::Config(ConfigCommand::List) => { - commands::settings::config_list(out, &store); - return Ok(()); - } - Commands::Config(ConfigCommand::Get { ref key }) => { - return commands::settings::config_get(out, &store, key); - } - Commands::Config(ConfigCommand::Set { ref key, ref value }) => { - return commands::settings::config_set(out, &store, key, value); - } - Commands::Shares(SharesCommand::List) => { - return commands::settings::shares_list( - out, - &store, - &resolved.shared_dirs, - ); - } - Commands::Shares(SharesCommand::Add { ref directory }) => { - return commands::settings::shares_add(out, &store, directory); - } - Commands::Shares(SharesCommand::Remove { ref directory }) => { - return commands::settings::shares_remove(out, &store, directory); - } - Commands::Wish(WishCommand::Add { ref query }) => { - return commands::wish::add(out, &store, query); - } - Commands::Wish(WishCommand::Remove { ref query }) => { - return commands::wish::remove(out, &store, query); - } - Commands::Wish(WishCommand::List) => { - return commands::wish::list(out, &store); - } - // Controlling a daemon needs no account of our own: the daemon is - // already logged in, and demanding credentials a script never had - // would turn "is it running?" into a configuration error. - Commands::Daemon(cli::DaemonArgs { - command: Some(ref command), - .. - }) => { - let endpoint = daemon_endpoint(&cli); - let token = cli.daemon_token.clone(); - return match command { - DaemonCommand::Token => commands::daemon::token(out), - DaemonCommand::Status => { - commands::daemon::status(out, endpoint.as_ref(), token) - } - DaemonCommand::Stop => { - commands::daemon::stop(out, endpoint.as_ref(), token) - } - }; - } - _ => {} - } - - let ctx = context(&cli, &resolved, out)?; - match command { - Commands::Shares(SharesCommand::Status) => { - commands::settings::shares_status(&ctx) - } - Commands::Shares(SharesCommand::Reindex) => { - commands::settings::shares_reindex(&ctx) - } - Commands::Wish(WishCommand::Run(ref args)) => { - commands::wish::run(&ctx, &store, args) - } - Commands::Daemon(ref args) => { - daemon::run(&ctx, args.bind.as_deref(), args.upload_slots) - } - Commands::Serve(ref args) => { - let sweeper = args - .wishlist - .then(|| commands::wish::Sweeper::new(store.config.wishes())) - .flatten(); - if args.wishlist && sweeper.is_none() { - out.warn( - "--wishlist has nothing to look for; add one with \ - `wish add QUERY`", - ); - } - commands::peer::serve(&ctx, args, sweeper) - } - other => commands::run(&ctx, other), - } -} - -/// Which config file to read: an explicit `--config`, the platform default, -/// or none at all under `--no-config`. -fn config_path(cli: &Cli) -> Option { - if cli.no_config { - return None; - } - cli.config.clone().or_else(persist::paths::config_file) -} - -/// Assemble everything a one-shot command needs, demanding the credentials -/// the interactive login screen would otherwise ask for. -fn context( - cli: &Cli, - resolved: &persist::config::Resolved, - out: &Out, -) -> CliResult { - let daemon = daemon_endpoint(cli); - // A run routed to a daemon needs no account of its own: the daemon is - // already logged in, and asking for credentials it will not use would - // stop a script that never had any. - let (username, password) = if daemon.is_some() { - (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", - ) - })?, - password(cli, resolved)?.ok_or_else(|| { - CliError::usage( - "no password: pass --password/--password-stdin, set \ - SOULSEEK_PASSWORD, store one in the OS keychain, or set \ - password_cmd", - ) - })?, - ) - }; - let (host, port) = - parse_server_address(&resolved.server).map_err(CliError::usage)?; - - // A daemon has its own server and its own filesystem. Asking it once, up - // front, is what keeps every command that reports a path or a server - // honest — `whoami` naming the right server, `download` naming the file - // where the bytes actually landed. - let described = daemon.as_ref().and_then(|endpoint| { - describe_daemon(endpoint, cli.daemon_token.clone()) - }); - let server_address = described.as_ref().map_or_else( - || PeerAddress::new(host, port), - |status| { - parse_server_address(&status.server).map_or_else( - |_| PeerAddress::new(status.server.clone(), 0), - |(host, port)| PeerAddress::new(host, port), - ) - }, - ); - let download_dir = described.as_ref().map_or_else( - || resolved.download_dir.clone(), - |status| status.download_dir.clone(), - ); - - Ok(Ctx { - out: out.clone(), - settings: ClientSettings { - username, - password, - server_address, - enable_listen: !resolved.disable_listener, - listen_port: resolved.listener_port, - shared_directories: shared_directories(resolved, out), - version: ClientVersion::REFERENCE_CLIENT, - }, - download_dir, - max_concurrent_downloads: resolved.max_concurrent_downloads, - search_timeout: Duration::from_secs(resolved.search_timeout), - daemon, - daemon_token: cli.daemon_token.clone(), - session: std::sync::OnceLock::new(), - }) -} - -/// Ask a daemon what it is, so this run describes the session it will actually -/// use. A daemon that cannot be reached is left to `Session::open` to report, -/// which produces a better message than anything this early probe could. -fn describe_daemon( - endpoint: &remote::Endpoint, - token: Option, -) -> Option { - let token = token.or_else(daemon::stored_token); - remote::RemoteSession::connect(endpoint, token) - .ok()? - .ask_for(daemon::proto::Method::DaemonStatus) - .ok() -} - -/// Which daemon this run should use, if any. -/// -/// An address given explicitly always wins. Otherwise a daemon listening on -/// the local socket is used automatically — the convention Docker and -/// ssh-agent set, and what lets an agent run a command with no flags at all. -/// With nothing listening, the run opens its own session exactly as it always -/// has. -fn daemon_endpoint(cli: &Cli) -> Option { - if cli.no_daemon { - return None; - } - if let Some(address) = &cli.daemon { - return Some(remote::Endpoint::parse(address)); - } - daemon::local_daemon().map(remote::Endpoint::Socket) -} - -/// Resolve the password: stdin beats the flag/env, which beats the keychain, -/// which beats `password_cmd`. -fn password( - cli: &Cli, - resolved: &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())); - } - Ok(persist::secret::resolve_password( - cli.password.as_deref(), - resolved.username.as_deref(), - resolved.password_cmd.as_deref(), - &persist::secret::KeyringStore, - )) -} - -/// Validate the configured shares. A bad entry is a warning, not a failure: -/// the client simply shares less. -fn shared_directories( - resolved: &persist::config::Resolved, - out: &Out, -) -> Vec { - let mut valid = Vec::new(); - for dir in &resolved.shared_dirs { - match directories::resolve_shared_directory(Some(dir)) { - Ok(Some(path)) => valid.push(path.display().to_string()), - Ok(None) => {} - Err(e) => out.warn(&format!("ignoring shared directory: {e}")), - } - } - valid -} - -/// `-v` raises the level; without it, an explicit `LOG_LEVEL`/`RUST_LOG` from -/// the environment is honoured, and otherwise logging stays at errors only so -/// stderr carries this program's own output and nothing else. -fn init_logging(cli: &Cli) { - let level = match cli.verbose { - 0 => (env::var_os("LOG_LEVEL").is_none() - && env::var_os("RUST_LOG").is_none()) - .then_some("ERROR"), - 1 => Some("WARN"), - 2 => Some("INFO"), - 3 => Some("DEBUG"), - _ => Some("TRACE"), - }; - // SAFETY: called before any threads are spawned. - unsafe { - if let Some(level) = level { - env::set_var("LOG_LEVEL", level); - } - if let Some(log_file) = &cli.log_file { - env::set_var("LOG_FILE", log_file.as_os_str()); - } - } -} - -/// Run the interactive TUI (the default no-subcommand path): bring the -/// terminal up first, run the login/registration screen (skipped past when -/// stored credentials work), persist whatever logged in, then enter the -/// main UI. -fn run_default_tui( - cli: &Cli, - resolved: &persist::config::Resolved, - config_path: Option, - file_config: &persist::config::FileConfig, -) -> CliResult { - use ratatui::crossterm::{ - event::EnableMouseCapture, - execute, - terminal::{Clear, ClearType}, - }; - - // Without a terminal the TUI would panic on its first draw. A script that - // reached this point wanted a subcommand, so say that instead of crashing. - if !std::io::stdout().is_terminal() { - return Err(CliError::usage( - "the interactive interface needs a terminal; run a subcommand for \ - scripted use (see --help)", - )); - } - - let out = Out::new(false, false); - let (server_host, server_port) = - parse_server_address(&resolved.server).map_err(CliError::usage)?; - - // Make sure the download folder exists up front: first-run defaults point - // download and shared at the same fresh Downloads/Soulseek folder, and - // shared-directory validation requires an existing path. - if let Err(e) = std::fs::create_dir_all(&resolved.download_dir) { - out.warn(&format!( - "could not create download directory {}: {e}", - resolved.download_dir - )); - } - - let shared_directories = shared_directories(resolved, &out); - let secret_store = 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 enable_listen = !resolved.disable_listener; - let listen_port = resolved.listener_port; - let make_settings = - move |username: String, password: String| ClientSettings { - username, - password, - server_address: PeerAddress::new(server_host.clone(), server_port), - enable_listen, - listen_port, - shared_directories: shared_directories.clone(), - version: ClientVersion::REFERENCE_CLIENT, - }; - - // Clear screen and enable mouse capture before initializing TUI - let _ = - execute!(std::io::stdout(), Clear(ClearType::All), EnableMouseCapture); - let mut terminal = ratatui::init(); - - let outcome = ui::login::run_login_flow( - &mut terminal, - &make_settings, - resolved.username.clone(), - initial_password, - ); - - let outcome = match outcome { - Ok(Some(outcome)) => outcome, - Ok(None) => { - // User cancelled at the login screen. - ratatui::restore(); - soulseek_rs::utils::logger::disable_buffering(); - return Ok(()); - } - Err(e) => { - ratatui::restore(); - soulseek_rs::utils::logger::disable_buffering(); - return Err(CliError::new(Exit::Failure, e.to_string())); - } - }; - - persist_credentials(&outcome, config_path, 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(port_mapping::PortMapper::spawn); - - let store = - persist::paths::state_dir().map(persist::state::StateStore::new); - - launch_main_tui( - terminal, - Arc::new(outcome.client), - resolved.download_dir.clone(), - resolved.max_concurrent_downloads, - Duration::from_secs(resolved.search_timeout), - store, - ) - .map_err(|e| CliError::new(Exit::Failure, e.to_string())) -} - -/// Run the TUI against a daemon's session. -/// -/// No login screen: the daemon is already logged in, so there is nothing to -/// ask for and nothing to store. Everything the UI shows comes back over the -/// socket, and what it persists stays daemon-side. -fn run_attached_tui( - cli: &Cli, - resolved: &persist::config::Resolved, - endpoint: &remote::Endpoint, -) -> CliResult { - use ratatui::crossterm::{ - event::EnableMouseCapture, - execute, - terminal::{Clear, ClearType}, - }; - - if !std::io::stdout().is_terminal() { - return Err(CliError::usage( - "the interactive interface needs a terminal; run a subcommand for \ - scripted use (see --help)", - )); - } - - let token = cli - .daemon_token - .clone() - .or_else(daemon::stored_token); - let session = remote::RemoteSession::connect(endpoint, token) - .map_err(|e| CliError::connection(format!("cannot use the daemon at {endpoint}: {e}")))?; - - soulseek_rs::utils::logger::enable_buffering(); - let _ = - execute!(std::io::stdout(), Clear(ClearType::All), EnableMouseCapture); - let terminal = ratatui::init(); - - // No StateStore: the daemon owns what survives a restart, so a second - // writer here would fight it for the same files. - launch_main_tui( - terminal, - Arc::new(session), - resolved.download_dir.clone(), - resolved.max_concurrent_downloads, - Duration::from_secs(resolved.search_timeout), - None, - ) - .map_err(|e| CliError::new(Exit::Failure, e.to_string())) -} - -/// After a successful login, remember the username in config.toml and — when -/// it was typed into the form — the password in the OS keychain. Both are -/// best-effort: failing to persist must not break a working session. -fn persist_credentials( - outcome: &ui::login::LoginOutcome, - config_path: Option, - file_config: &persist::config::FileConfig, - secret_store: &dyn persist::secret::SecretStore, -) { - if let Some(path) = config_path - && file_config.username.as_deref() != Some(&outcome.username) - { - let mut updated = file_config.clone(); - updated.username = Some(outcome.username.clone()); - if let Err(e) = updated.save(&path) { - eprintln!("⚠️ Could not save config: {e}"); - } - } - if outcome.entered_via_form - && let Err(e) = secret_store.set(&outcome.username, &outcome.password) - { - eprintln!("⚠️ Could not store password in keychain: {e}"); - } -} diff --git a/soulseek-rs/src/remote/mod.rs b/soulseek-rs/src/remote/mod.rs index ff2bec8..60ad427 100644 --- a/soulseek-rs/src/remote/mod.rs +++ b/soulseek-rs/src/remote/mod.rs @@ -162,14 +162,14 @@ impl RemoteSession { let pending = Arc::new(Pending::default()); let mirror = Arc::new(Mirror::default()); - let closed = Arc::new(AtomicBool::new(false)); + let hung_up = Arc::new(AtomicBool::new(false)); { let pending = Arc::clone(&pending); let mirror = Arc::clone(&mirror); - let closed = Arc::clone(&closed); + let hung_up = Arc::clone(&hung_up); std::thread::spawn(move || { read_loop(read, &pending, &mirror); - closed.store(true, Ordering::Relaxed); + hung_up.store(true, Ordering::Relaxed); pending.fail_all(); }); } @@ -181,7 +181,7 @@ impl RemoteSession { mirror, next_id: AtomicU64::new(1), username: String::new(), - closed, + closed: hung_up, }; let token = if endpoint.self_authenticating() { @@ -341,17 +341,11 @@ impl RemoteSession { /// One parameterless request, for the control commands that only need to /// ask a daemon something once. + /// pub fn ask_for(&self, method: Method) -> Result { self.request(method, ()) } - /// The daemon's chat history, which a remote client needs to rebuild a - /// conversation it was not attached for. Not part of [`SessionApi`] - /// because a local session keeps this in the TUI's own snapshot. - pub fn message_history(&self) -> Result> { - self.request::<_, Messages>(Method::MessageHistory, ()) - .map(|history| history.messages) - } } /// Turn the daemon's view of a transfer back into the library's, with the @@ -739,6 +733,12 @@ impl SessionApi for RemoteSession { .map(Into::into) } + fn message_history(&self) -> Vec { + self.request::<_, Messages>(Method::MessageHistory, ()) + .map(|history| history.messages) + .unwrap_or_default() + } + fn shared_counts(&self) -> (u32, u32) { let shares = self.shares(); (shares.folders, shares.files) diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs new file mode 100644 index 0000000..82e8f5b --- /dev/null +++ b/soulseek-rs/src/run.rs @@ -0,0 +1,506 @@ +//! Wiring the command line to a session: which config, which credentials, and +//! whether this run opens a session of its own or borrows a daemon's. + +use crate::cli::{ + Cli, Commands, ConfigCommand, DaemonCommand, SharesCommand, WishCommand, + parse_server_address, +}; +use crate::commands::Ctx; +use crate::output::{CliError, CliResult, Exit, Out}; +use soulseek_rs::{ClientSettings, ClientVersion, PeerAddress}; +use std::io::{BufRead, IsTerminal}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use std::{env, io}; +use crate::ui::launch_main_tui; + +pub fn run(mut cli: Cli, out: &Out) -> CliResult { + let config_path = config_path(&cli); + let file_config = match &config_path { + Some(path) => crate::persist::config::FileConfig::load(path) + .map_err(|e| CliError::usage(e.to_string()))?, + None => crate::persist::config::FileConfig::default(), + }; + let resolved = crate::persist::config::resolve(&cli, &file_config); + + let Some(command) = cli.command.take() else { + // A running daemon already holds a session; the TUI attaches to it + // rather than logging in a second time, which the server would answer + // by cutting the daemon off. + if let Some(endpoint) = daemon_endpoint(&cli) { + return run_attached_tui(&cli, &resolved, &endpoint); + } + return run_default_tui(&cli, &resolved, config_path, &file_config); + }; + + // Anything that only reads the network stack or the config file runs + // before credentials are demanded: asking a script for a login it does not + // need would be a poor trade for a `config get`. + let store = crate::commands::settings::Store { + path: config_path, + config: file_config, + }; + match command { + Commands::Portmap => { + return crate::commands::portmap(out, resolved.listener_port); + } + Commands::Skills(ref command) => { + return crate::commands::skills::run(out, command); + } + Commands::Completions(ref command) => { + return crate::commands::completions::run(out, command); + } + Commands::Config(ConfigCommand::Path) => { + return crate::commands::settings::config_path(out, &store); + } + Commands::Config(ConfigCommand::List) => { + crate::commands::settings::config_list(out, &store); + return Ok(()); + } + Commands::Config(ConfigCommand::Get { ref key }) => { + return crate::commands::settings::config_get(out, &store, key); + } + Commands::Config(ConfigCommand::Set { ref key, ref value }) => { + return crate::commands::settings::config_set(out, &store, key, value); + } + Commands::Shares(SharesCommand::List) => { + return crate::commands::settings::shares_list( + out, + &store, + &resolved.shared_dirs, + ); + } + Commands::Shares(SharesCommand::Add { ref directory }) => { + return crate::commands::settings::shares_add(out, &store, directory); + } + Commands::Shares(SharesCommand::Remove { ref directory }) => { + return crate::commands::settings::shares_remove(out, &store, directory); + } + Commands::Wish(WishCommand::Add { ref query }) => { + return crate::commands::wish::add(out, &store, query); + } + Commands::Wish(WishCommand::Remove { ref query }) => { + return crate::commands::wish::remove(out, &store, query); + } + Commands::Wish(WishCommand::List) => { + return crate::commands::wish::list(out, &store); + } + // Controlling a daemon needs no account of our own: the daemon is + // already logged in, and demanding credentials a script never had + // would turn "is it running?" into a configuration error. + Commands::Daemon(crate::cli::DaemonArgs { + command: Some(ref command), + .. + }) => { + let endpoint = daemon_endpoint(&cli); + let token = cli.daemon_token.clone(); + return match command { + DaemonCommand::Token => crate::commands::daemon::token(out), + DaemonCommand::Status => { + crate::commands::daemon::status(out, endpoint.as_ref(), token) + } + DaemonCommand::Stop => { + crate::commands::daemon::stop(out, endpoint.as_ref(), token) + } + }; + } + _ => {} + } + + let ctx = context(&cli, &resolved, out)?; + match command { + Commands::Shares(SharesCommand::Status) => { + crate::commands::settings::shares_status(&ctx) + } + Commands::Shares(SharesCommand::Reindex) => { + crate::commands::settings::shares_reindex(&ctx) + } + Commands::Wish(WishCommand::Run(ref args)) => { + crate::commands::wish::run(&ctx, &store, args) + } + Commands::Daemon(ref args) => { + crate::daemon::run(&ctx, args.bind.as_deref(), args.upload_slots) + } + Commands::Serve(ref args) => { + let sweeper = args + .wishlist + .then(|| crate::commands::wish::Sweeper::new(store.config.wishes())) + .flatten(); + if args.wishlist && sweeper.is_none() { + out.warn( + "--wishlist has nothing to look for; add one with \ + `wish add QUERY`", + ); + } + crate::commands::peer::serve(&ctx, args, sweeper) + } + other => crate::commands::run(&ctx, other), + } +} + +/// Which config file to read: an explicit `--config`, the platform default, +/// or none at all under `--no-config`. +fn config_path(cli: &Cli) -> Option { + if cli.no_config { + return None; + } + cli.config.clone().or_else(crate::persist::paths::config_file) +} + +/// Assemble everything a one-shot command needs, demanding the credentials +/// the interactive login screen would otherwise ask for. +fn context( + cli: &Cli, + resolved: &crate::persist::config::Resolved, + out: &Out, +) -> CliResult { + let daemon = daemon_endpoint(cli); + // A run routed to a daemon needs no account of its own: the daemon is + // already logged in, and asking for credentials it will not use would + // stop a script that never had any. + let (username, password) = if daemon.is_some() { + (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", + ) + })?, + password(cli, resolved)?.ok_or_else(|| { + CliError::usage( + "no password: pass --password/--password-stdin, set \ + SOULSEEK_PASSWORD, store one in the OS keychain, or set \ + password_cmd", + ) + })?, + ) + }; + let (host, port) = + parse_server_address(&resolved.server).map_err(CliError::usage)?; + + // A daemon has its own server and its own filesystem. Asking it once, up + // front, is what keeps every command that reports a path or a server + // honest — `whoami` naming the right server, `download` naming the file + // where the bytes actually landed. + let described = daemon.as_ref().and_then(|endpoint| { + describe_daemon(endpoint, cli.daemon_token.clone()) + }); + let server_address = described.as_ref().map_or_else( + || PeerAddress::new(host, port), + |status| { + parse_server_address(&status.server).map_or_else( + |_| PeerAddress::new(status.server.clone(), 0), + |(host, port)| PeerAddress::new(host, port), + ) + }, + ); + let download_dir = described.as_ref().map_or_else( + || resolved.download_dir.clone(), + |status| status.download_dir.clone(), + ); + + Ok(Ctx { + out: out.clone(), + settings: ClientSettings { + username, + password, + server_address, + enable_listen: !resolved.disable_listener, + listen_port: resolved.listener_port, + shared_directories: shared_directories(resolved, out), + version: ClientVersion::REFERENCE_CLIENT, + }, + download_dir, + max_concurrent_downloads: resolved.max_concurrent_downloads, + search_timeout: Duration::from_secs(resolved.search_timeout), + daemon, + daemon_token: cli.daemon_token.clone(), + session: std::sync::OnceLock::new(), + }) +} + +/// Ask a daemon what it is, so this run describes the session it will actually +/// use. A daemon that cannot be reached is left to `Session::open` to report, +/// which produces a better message than anything this early probe could. +fn describe_daemon( + endpoint: &crate::remote::Endpoint, + token: Option, +) -> Option { + let token = token.or_else(crate::daemon::stored_token); + crate::remote::RemoteSession::connect(endpoint, token) + .ok()? + .ask_for(crate::daemon::proto::Method::DaemonStatus) + .ok() +} + +/// Which daemon this run should use, if any. +/// +/// An address given explicitly always wins. Otherwise a daemon listening on +/// the local socket is used automatically — the convention Docker and +/// ssh-agent set, and what lets an agent run a command with no flags at all. +/// With nothing listening, the run opens its own session exactly as it always +/// has. +fn daemon_endpoint(cli: &Cli) -> Option { + if cli.no_daemon { + return None; + } + if let Some(address) = &cli.daemon { + return Some(crate::remote::Endpoint::parse(address)); + } + crate::daemon::local_daemon().map(crate::remote::Endpoint::Socket) +} + +/// Resolve the password: stdin beats the flag/env, which beats the keychain, +/// which beats `password_cmd`. +fn password( + cli: &Cli, + 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())); + } + Ok(crate::persist::secret::resolve_password( + cli.password.as_deref(), + resolved.username.as_deref(), + resolved.password_cmd.as_deref(), + &crate::persist::secret::KeyringStore, + )) +} + +/// Validate the configured shares. A bad entry is a warning, not a failure: +/// the client simply shares less. +fn shared_directories( + resolved: &crate::persist::config::Resolved, + out: &Out, +) -> Vec { + let mut valid = Vec::new(); + for dir in &resolved.shared_dirs { + match crate::directories::resolve_shared_directory(Some(dir)) { + Ok(Some(path)) => valid.push(path.display().to_string()), + Ok(None) => {} + Err(e) => out.warn(&format!("ignoring shared directory: {e}")), + } + } + valid +} + +/// Decide how much this run says on stderr. +/// +/// `-v` raises the level; without it, an explicit `LOG_LEVEL`/`RUST_LOG` from +/// the environment is honoured, and otherwise logging stays at errors only so +/// stderr carries this program's own output and nothing else. +pub fn init_logging(cli: &Cli) { + let level = match cli.verbose { + 0 => (env::var_os("LOG_LEVEL").is_none() + && env::var_os("RUST_LOG").is_none()) + .then_some("ERROR"), + 1 => Some("WARN"), + 2 => Some("INFO"), + 3 => Some("DEBUG"), + _ => Some("TRACE"), + }; + // SAFETY: called before any threads are spawned. + unsafe { + if let Some(level) = level { + env::set_var("LOG_LEVEL", level); + } + if let Some(log_file) = &cli.log_file { + env::set_var("LOG_FILE", log_file.as_os_str()); + } + } +} + +/// Run the interactive TUI (the default no-subcommand path): bring the +/// terminal up first, run the login/registration screen (skipped past when +/// stored credentials work), persist whatever logged in, then enter the +/// main UI. +fn run_default_tui( + cli: &Cli, + resolved: &crate::persist::config::Resolved, + config_path: Option, + file_config: &crate::persist::config::FileConfig, +) -> CliResult { + use ratatui::crossterm::{ + event::EnableMouseCapture, + execute, + terminal::{Clear, ClearType}, + }; + + // Without a terminal the TUI would panic on its first draw. A script that + // reached this point wanted a subcommand, so say that instead of crashing. + if !std::io::stdout().is_terminal() { + return Err(CliError::usage( + "the interactive interface needs a terminal; run a subcommand for \ + scripted use (see --help)", + )); + } + + let out = Out::new(false, false); + let (server_host, server_port) = + parse_server_address(&resolved.server).map_err(CliError::usage)?; + + // Make sure the download folder exists up front: first-run defaults point + // download and shared at the same fresh Downloads/Soulseek folder, and + // shared-directory validation requires an existing path. + if let Err(e) = std::fs::create_dir_all(&resolved.download_dir) { + out.warn(&format!( + "could not create download directory {}: {e}", + resolved.download_dir + )); + } + + 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 enable_listen = !resolved.disable_listener; + let listen_port = resolved.listener_port; + let make_settings = + move |username: String, password: String| ClientSettings { + username, + password, + server_address: PeerAddress::new(server_host.clone(), server_port), + enable_listen, + listen_port, + shared_directories: shared_directories.clone(), + version: ClientVersion::REFERENCE_CLIENT, + }; + + // Clear screen and enable mouse capture before initializing TUI + let _ = + execute!(std::io::stdout(), Clear(ClearType::All), EnableMouseCapture); + let mut terminal = ratatui::init(); + + let outcome = crate::ui::login::run_login_flow( + &mut terminal, + &make_settings, + resolved.username.clone(), + initial_password, + ); + + let outcome = match outcome { + Ok(Some(outcome)) => outcome, + Ok(None) => { + // User cancelled at the login screen. + ratatui::restore(); + soulseek_rs::utils::logger::disable_buffering(); + return Ok(()); + } + Err(e) => { + ratatui::restore(); + soulseek_rs::utils::logger::disable_buffering(); + return Err(CliError::new(Exit::Failure, e.to_string())); + } + }; + + persist_credentials(&outcome, config_path, 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(), + resolved.max_concurrent_downloads, + Duration::from_secs(resolved.search_timeout), + store, + ) + .map_err(|e| CliError::new(Exit::Failure, e.to_string())) +} + +/// Run the TUI against a daemon's session. +/// +/// No login screen: the daemon is already logged in, so there is nothing to +/// ask for and nothing to store. Everything the UI shows comes back over the +/// socket, and what it persists stays daemon-side. +fn run_attached_tui( + cli: &Cli, + resolved: &crate::persist::config::Resolved, + endpoint: &crate::remote::Endpoint, +) -> CliResult { + use ratatui::crossterm::{ + event::EnableMouseCapture, + execute, + terminal::{Clear, ClearType}, + }; + + if !std::io::stdout().is_terminal() { + return Err(CliError::usage( + "the interactive interface needs a terminal; run a subcommand for \ + scripted use (see --help)", + )); + } + + let token = cli + .daemon_token + .clone() + .or_else(crate::daemon::stored_token); + let session = crate::remote::RemoteSession::connect(endpoint, token) + .map_err(|e| CliError::connection(format!("cannot use the daemon at {endpoint}: {e}")))?; + + soulseek_rs::utils::logger::enable_buffering(); + let _ = + execute!(std::io::stdout(), Clear(ClearType::All), EnableMouseCapture); + let terminal = ratatui::init(); + + // No StateStore: the daemon owns what survives a restart, so a second + // writer here would fight it for the same files. + launch_main_tui( + terminal, + Arc::new(session), + resolved.download_dir.clone(), + resolved.max_concurrent_downloads, + Duration::from_secs(resolved.search_timeout), + None, + ) + .map_err(|e| CliError::new(Exit::Failure, e.to_string())) +} + +/// After a successful login, remember the username in config.toml and — when +/// it was typed into the form — the password in the OS keychain. Both are +/// best-effort: failing to persist must not break a working session. +fn persist_credentials( + outcome: &crate::ui::login::LoginOutcome, + config_path: Option, + 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 outcome.entered_via_form + && let Err(e) = secret_store.set(&outcome.username, &outcome.password) + { + eprintln!("⚠️ Could not store password in keychain: {e}"); + } +} diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 7c132d5..66e036d 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -10,7 +10,7 @@ use crate::api::SessionApi; use crate::models::AppState; use crate::persist::{ snapshot::{Snapshot, restore_messages, restore_searches}, - state::StateStore, + state::{PersistedMessage, StateStore}, }; use color_eyre::Result; use ratatui::{ @@ -58,7 +58,29 @@ impl MainTui { /// (rejoined on the server), and downloads — incomplete ones are /// re-enqueued so they resume automatically. fn restore_persisted_state(&mut self) { - let Some(store) = &self.store else { return }; + // Whatever the session has been collecting comes first: attached to a + // daemon this is the conversation so far, including everything that + // arrived while nothing was showing it. Locally it is empty and the + // snapshot below is the whole record. + let history: Vec = self + .client + .message_history() + .into_iter() + .map(|message| PersistedMessage { + peer: message.peer, + outgoing: message.outgoing, + text: message.text, + at: chrono::DateTime::from_timestamp(message.at, 0) + .unwrap_or_default() + .into(), + }) + .collect(); + restore_messages(&mut self.state, &history); + + let Some(store) = &self.store else { + self.saved_snapshot = Snapshot::capture(&self.state); + return; + }; restore_searches(&mut self.state, &store.load_search_queries()); restore_messages(&mut self.state, &store.load_messages()); diff --git a/soulseek-rs/tests/cli_e2e.rs b/soulseek-rs/tests/cli_e2e.rs index 1ffa9ee..7389e74 100644 --- a/soulseek-rs/tests/cli_e2e.rs +++ b/soulseek-rs/tests/cli_e2e.rs @@ -98,18 +98,6 @@ fn mode() -> Mode { } } -/// Skip the rest of a test that has no meaning against a daemon — one about -/// logging in, or about what happens when a listener port is taken, both of -/// which are the daemon's business rather than a client's. -macro_rules! local_only { - ($why:expr) => { - if mode() == Mode::Daemon { - println!("skipped in daemon mode: {}", $why); - return; - } - }; -} - fn run(args: &[&str]) -> Output { command(args).output().expect("the binary should run") } @@ -744,12 +732,15 @@ impl TestServer { ] } - /// Client arguments pointing at a daemon logged in as `user` with - /// `session_flags`, starting one if this combination has not been seen. + /// Client arguments pointing at a daemon logged in as `user`, starting one + /// if this account has not been seen. /// - /// Keyed by the flags as well as the user: a test that shares a different - /// folder needs a daemon that shares it, and reusing one configured for - /// something else would quietly test the wrong thing. + /// Strictly one daemon per account: the server allows a single session per + /// name, so a second daemon under the same name would displace the first + /// and every command already routed to it would start failing with + /// "session lost". The session flags of the first call configure it — a + /// later call's are ignored, which is exactly what happens in real daemon + /// mode, where shares and the listener belong to the daemon. fn daemon_args(&self, user: &str, session_flags: &[String]) -> Vec { let socket = self.daemon_for(user, session_flags); vec![ @@ -761,7 +752,7 @@ impl TestServer { } fn daemon_for(&self, user: &str, session_flags: &[String]) -> PathBuf { - let key = format!("{}|{user}|{}", self.address(), session_flags.join(" ")); + let key = format!("{}|{user}", self.address()); let mut daemons = self.daemons.lock().expect("daemon registry"); if let Some(existing) = daemons.get(&key) { return existing.socket.clone();