feat(daemon): make the download folder a live setting in both modes

The full-surface audit found the settings story half-true: `config set
download_dir` and the TUI's settings form edited a file a running daemon
never rereads, while the attached TUI displayed the laptop's path and
discarded the edit entirely.

- `download.set_dir`: a session-wide move, published in the contract;
  the per-transfer destination stays refused. `daemon.status` reports
  it and the next transfer lands there.
- `config set download_dir` and `config set shared_dirs` push to a
  live daemon the way `shares add` does, through one shared helper;
  the socket probe is paid only by the keys a daemon owns.
- The attached TUI seeds its settings form from the daemon's status,
  pushes both folders over the socket as typed, reports a partial
  refusal as exactly that, and creates no client-side directories. It
  reads the daemon token from the resolved config — with the token only
  in config.toml it could not attach over TCP — and settings
  persistence honours --config instead of hard-coding the default path.
- `search.wishlist` records a start time, so an attached window
  renders it as running rather than instantly finished.

The rest is the coverage the audit demanded: CLI-level token auth
(wrong, missing, and valid over TCP), `daemon status|stop` with
nothing running, --sort, --limit, --pick first, --password-cmd,
--log-file, --upload-slots, the config-named download folder in both
modes, and headless settings-form tests attached and local. Every
guard was checked by disabling what it protects. Both e2e modes run
green: 94 CLI tests each way, 16 protocol, 285 unit, 34 lib.
This commit is contained in:
Michel de Graaf
2026-07-28 10:44:06 +02:00
parent 35f5cd4f22
commit cd56fb3b39
16 changed files with 917 additions and 89 deletions
+37 -9
View File
@@ -10,7 +10,7 @@ use crate::output::{CliError, CliResult, Exit, Out};
use crate::ui::launch_main_tui;
use soulseek_rs::{ClientSettings, ClientVersion, PeerAddress};
use std::io::{BufRead, IsTerminal};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use std::{env, io};
@@ -38,7 +38,7 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
// 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(&cli, &resolved, &endpoint);
return run_attached_tui(&resolved, &endpoint, config_path);
}
return run_default_tui(&cli, &resolved, config_path, &file_config);
};
@@ -72,7 +72,11 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
}
Commands::Config(ConfigCommand::Set { ref key, ref value }) => {
return crate::commands::settings::config_set(
out, &store, key, value,
out,
&store,
key,
value,
|| live_daemon(&cli, &resolved),
);
}
Commands::Shares(SharesCommand::List) => {
@@ -442,7 +446,12 @@ fn run_default_tui(
}
};
persist_credentials(&outcome, config_path, file_config, &secret_store);
persist_credentials(
&outcome,
config_path.as_deref(),
file_config,
&secret_store,
);
// Best-effort: make ourselves reachable behind a home router so firewalled
// peers can connect back. Mapped only once the listener is up, and for the
@@ -462,6 +471,7 @@ fn run_default_tui(
resolved.max_concurrent_downloads,
Duration::from_secs(resolved.search_timeout),
store,
config_path,
)
.map_err(|e| CliError::new(Exit::Failure, e.to_string()))
}
@@ -472,9 +482,9 @@ fn run_default_tui(
/// 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,
config_path: Option<PathBuf>,
) -> CliResult {
use ratatui::crossterm::{
event::EnableMouseCapture,
@@ -482,7 +492,9 @@ fn run_attached_tui(
terminal::{Clear, ClearType},
};
let token = cli
// 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);
@@ -493,6 +505,21 @@ fn run_attached_tui(
))
})?;
// 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::DaemonStatus>(
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 _ =
execute!(std::io::stdout(), Clear(ClearType::All), EnableMouseCapture);
@@ -503,10 +530,11 @@ fn run_attached_tui(
launch_main_tui(
terminal,
Arc::new(session),
resolved.download_dir.clone(),
download_dir,
resolved.max_concurrent_downloads,
Duration::from_secs(resolved.search_timeout),
None,
config_path,
)
.map_err(|e| CliError::new(Exit::Failure, e.to_string()))
}
@@ -516,7 +544,7 @@ fn run_attached_tui(
/// best-effort: failing to persist must not break a working session.
fn persist_credentials(
outcome: &crate::ui::login::LoginOutcome,
config_path: Option<PathBuf>,
config_path: Option<&Path>,
file_config: &crate::persist::config::FileConfig,
secret_store: &dyn crate::persist::secret::SecretStore,
) {
@@ -525,7 +553,7 @@ fn persist_credentials(
{
let mut updated = file_config.clone();
updated.username = Some(outcome.username.clone());
if let Err(e) = updated.save(&path) {
if let Err(e) = updated.save(path) {
eprintln!("⚠️ Could not save config: {e}");
}
}