//! 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::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}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use std::{env, io}; pub fn run(mut cli: Cli, out: &Out) -> CliResult { // Before anything opens a socket: every entry point that logs in — the // TUI, `serve`, one-shot commands, the daemon — holds a socket per peer, // and the soft limit a shell hands its children (256 on macOS) is gone // after one busy search. The raise lived in the daemon first; the other // entry points run the same peer machinery and crashed the same way. match raise_open_file_limit() { Ok(limit) if limit < WANTED_OPEN_FILES => out.warn(&format!( "only {limit} open files allowed; a busy search wants \ {WANTED_OPEN_FILES}. Raise it with `ulimit -n \ {WANTED_OPEN_FILES}` first." )), Ok(_) => {} Err(e) => { out.warn(&format!("could not check the open-file limit: {e}")); } } 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 { // 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)", )); } // 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, &resolved) { return run_attached_tui(&resolved, &endpoint, config_path); } return run_default_tui(&cli, &resolved, config_path); }; // 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); } // 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); } 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, || live_daemon(&cli, &resolved), ); } 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, &live_daemon(&cli, &resolved), ); } Commands::Shares(SharesCommand::Remove { ref directory }) => { return crate::commands::settings::shares_remove( out, &store, directory, &live_daemon(&cli, &resolved), ); } 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, &resolved); let token = resolved.daemon_token; 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::Files { ref filter }) => { crate::commands::settings::shares_files(&ctx, filter.as_deref()) } 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, resolved); // 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(missing_username)?, 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, resolved.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), accept_children: resolved.accept_children, version: ClientVersion::REFERENCE_CLIENT, }, download_dir, max_concurrent_downloads: resolved.max_concurrent_downloads, search_timeout: Duration::from_secs(resolved.search_timeout), daemon, daemon_token: resolved.daemon_token.clone(), session: std::sync::OnceLock::new(), }) } /// The daemon a config change should also reach, if one is running. fn live_daemon( cli: &Cli, resolved: &crate::persist::config::Resolved, ) -> crate::commands::settings::LiveDaemon { crate::commands::settings::LiveDaemon { endpoint: daemon_endpoint(cli, resolved), token: resolved.daemon_token.clone(), } } /// 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, resolved: &crate::persist::config::Resolved, ) -> Option { if cli.no_daemon { return None; } // Flag, then environment, then config file — `resolved` has already // layered those — and otherwise a daemon on this machine, if one is up. if let Some(address) = &resolved.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 { 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 { let mut line = String::new(); io::stdin() .lock() .read_line(&mut line) .map_err(|e| CliError::usage(format!("cannot read stdin: {e}")))?; let line = line.trim_end_matches(['\r', '\n']); if line.is_empty() { return Err(CliError::usage("no password on stdin")); } Ok(line.to_string()) } /// What to say when nothing supplied a username. #[must_use] pub fn missing_username() -> CliError { CliError::usage( "no username: pass --username, set SOULSEEK_USERNAME, or put one in \ config.toml", ) } /// Validate the configured shares. A bad entry is a warning, not a failure: /// the client simply shares less. fn shared_directories( 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()); } } } /// Clear the screen, turn on mouse reporting and take over the terminal. fn enter_terminal() -> ratatui::DefaultTerminal { use ratatui::crossterm::{ event::EnableMouseCapture, execute, terminal::{Clear, ClearType}, }; let _ = execute!(std::io::stdout(), Clear(ClearType::All), EnableMouseCapture); ratatui::init() } /// 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. Logging out from the settings popup starts the round again. fn run_default_tui( cli: &Cli, resolved: &crate::persist::config::Resolved, config_path: Option, ) -> CliResult { 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 mut initial_password = password(cli, resolved)?; let enable_listen = !resolved.disable_listener; let listen_port = resolved.listener_port; let accept_children = resolved.accept_children; 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(), accept_children, 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, username.clone(), initial_password.take(), ); let outcome = match outcome { Ok(Some(outcome)) => outcome, // The user cancelled at the login screen. Ok(None) => return leave_terminal(Ok(())), Err(e) => { return leave_terminal(Err(CliError::new( Exit::Failure, e.to_string(), ))); } }; persist_credentials(&outcome, config_path.as_deref(), &secret_store); username = Some(outcome.username.clone()); // Best-effort: make ourselves reachable behind a home router so // firewalled peers can connect back. Mapped only once the listener is // up, and for the port it really bound. Kept alive for the session. let _port_mapper = outcome .client .listen_port() .map(crate::port_mapping::PortMapper::spawn); let store = crate::persist::paths::state_dir() .map(crate::persist::state::StateStore::new); let exit = launch_main_tui( terminal, Arc::new(outcome.client), resolved.download_dir.clone(), Duration::from_secs(resolved.search_timeout), store, config_path.clone(), ) .map_err(|e| CliError::new(Exit::Failure, e.to_string()))?; if exit == TuiExit::Quit { return Ok(()); } // 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. /// /// 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( resolved: &crate::persist::config::Resolved, endpoint: &crate::remote::Endpoint, config_path: Option, ) -> CliResult { // The resolved token, not the flag alone: `daemon_token` in config.toml // is the documented remote setup, and the flag/env are layered into it. let token = resolved .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}" )) })?; // The settings form shows and edits the *daemon's* download folder — the // one transfers actually land in — not this machine's configured default. // The connect above just succeeded, so a daemon that cannot answer this // is a real problem to report, not one to paper over with a local path. let download_dir = session .ask_for::( crate::daemon::proto::Method::DaemonStatus, ) .map(|status| status.download_dir) .map_err(|e| { CliError::connection(format!( "the daemon at {endpoint} did not report its status: {e}" )) })?; soulseek_rs::utils::logger::enable_buffering(); let terminal = enter_terminal(); // 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), download_dir, Duration::from_secs(resolved.search_timeout), None, config_path, ) .map(|_| ()) .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<&Path>, secret_store: &dyn crate::persist::secret::SecretStore, ) { 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) { eprintln!("⚠️ Could not store password in keychain: {e}"); } } /// 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 /// during a busy search, plus transfers, the share index, and control /// connections. A shell hands its children a soft limit of 256 on macOS, /// which a single search blows through. const WANTED_OPEN_FILES: u64 = 8192; /// Ask the OS for enough open files to run a busy session, and report what we /// actually got. /// /// Raising our own soft limit is what a service does: the limit a shell /// inherits is sized for a shell. The hard limit is the ceiling, and asking /// past it fails outright rather than clamping, so this asks for the smaller /// of what we want and what we are allowed. #[cfg(unix)] fn raise_open_file_limit() -> std::io::Result { // SAFETY: every call takes a pointer to a `rlimit` this function owns, and // none of them retains it. unsafe { let mut limit = std::mem::zeroed::(); if libc::getrlimit(libc::RLIMIT_NOFILE, &raw mut limit) != 0 { return Err(std::io::Error::last_os_error()); } if limit.rlim_cur >= WANTED_OPEN_FILES { return Ok(limit.rlim_cur); } let previous = limit.rlim_cur; let ceiling = if limit.rlim_max == libc::RLIM_INFINITY { WANTED_OPEN_FILES } else { limit.rlim_max.min(WANTED_OPEN_FILES) }; // macOS rejects an unlimited *hard* limit for open files, so raising // the soft one while leaving `rlim_max` as it came back fails with // EINVAL. Naming both is what it will accept. for candidate in [ libc::rlimit { rlim_cur: ceiling, rlim_max: limit.rlim_max, }, libc::rlimit { rlim_cur: ceiling, rlim_max: ceiling, }, ] { if libc::setrlimit(libc::RLIMIT_NOFILE, &raw const candidate) == 0 { return Ok(ceiling); } } Ok(previous) } } #[cfg(not(unix))] const fn raise_open_file_limit() -> std::io::Result { Ok(WANTED_OPEN_FILES) }