test(daemon): drive the whole CLI suite against a daemon, and fix what it caught
SOULSEEK_E2E_MODE=daemon makes the existing e2e harness serve every session from a daemon instead of the command's own login, so the same 83 tests assert the same records and exit codes both ways. CI runs the suite twice. Two defects it found: serve judged whether anything was shared from this run's configuration rather than from the session, so routed to a daemon it would start with nothing to serve and report success. The TUI showed an empty conversation when attached, because the daemon's message history was never asked for. It is now a SessionApi method, empty locally, which puts the difference behind the seam. main.rs is now a thin entry point over the library rather than a second copy of every module, so the program compiles once instead of twice.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<UserInfo>;
|
||||
|
||||
/// 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<ChatMessageDto> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn shared_counts(&self) -> (u32, u32);
|
||||
fn shared_directories(&self) -> Vec<String>;
|
||||
fn set_shared_directories(&self, directories: Vec<String>) -> Result<()>;
|
||||
|
||||
@@ -106,18 +106,27 @@ pub fn serve(
|
||||
args: &ServeArgs,
|
||||
mut sweeper: Option<crate::commands::wish::Sweeper>,
|
||||
) -> 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 {
|
||||
|
||||
@@ -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<Vec<String>>);
|
||||
pub struct PendingBrowses(Mutex<Vec<(String, Instant)>>);
|
||||
|
||||
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<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,20 +523,21 @@ fn handle(stream: Stream, daemon: Arc<Daemon>) -> 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);
|
||||
|
||||
@@ -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<String> {
|
||||
let mut resolved = Vec::new();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+7
-516
@@ -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 = <soulseek_rs_tui::cli::Cli as clap::Parser>::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<PathBuf> {
|
||||
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<Ctx> {
|
||||
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<String>,
|
||||
) -> Option<daemon::proto::DaemonStatus> {
|
||||
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<remote::Endpoint> {
|
||||
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<Option<String>> {
|
||||
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<String> {
|
||||
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<PathBuf>,
|
||||
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<PathBuf>,
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<R: DeserializeOwned>(&self, method: Method) -> Result<R> {
|
||||
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<Vec<ChatMessageDto>> {
|
||||
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<ChatMessageDto> {
|
||||
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)
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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<Ctx> {
|
||||
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<String>,
|
||||
) -> Option<crate::daemon::proto::DaemonStatus> {
|
||||
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<crate::remote::Endpoint> {
|
||||
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<Option<String>> {
|
||||
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<String> {
|
||||
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<PathBuf>,
|
||||
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<PathBuf>,
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -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<PersistedMessage> = 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());
|
||||
|
||||
@@ -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<String> {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user