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:
@@ -28,6 +28,9 @@ pub struct MainTui {
|
||||
search_timeout: Duration,
|
||||
spinner_state: usize,
|
||||
store: Option<StateStore>,
|
||||
/// Where settings changes are persisted — the config file this run
|
||||
/// resolved, so `--config` is honoured.
|
||||
config_path: Option<std::path::PathBuf>,
|
||||
/// Last snapshot written to disk, to skip no-op saves.
|
||||
saved_snapshot: Snapshot,
|
||||
}
|
||||
@@ -39,6 +42,7 @@ impl MainTui {
|
||||
max_concurrent_downloads: usize,
|
||||
search_timeout: Duration,
|
||||
store: Option<StateStore>,
|
||||
config_path: Option<std::path::PathBuf>,
|
||||
) -> Self {
|
||||
let mut tui = Self {
|
||||
client,
|
||||
@@ -48,6 +52,7 @@ impl MainTui {
|
||||
search_timeout,
|
||||
spinner_state: 0,
|
||||
store,
|
||||
config_path,
|
||||
saved_snapshot: Snapshot::default(),
|
||||
};
|
||||
tui.restore_persisted_state();
|
||||
@@ -254,6 +259,7 @@ pub fn launch_main_tui(
|
||||
max_concurrent_downloads: usize,
|
||||
search_timeout: Duration,
|
||||
store: Option<StateStore>,
|
||||
config_path: Option<std::path::PathBuf>,
|
||||
) -> Result<()> {
|
||||
let tui = MainTui::new(
|
||||
client,
|
||||
@@ -261,6 +267,7 @@ pub fn launch_main_tui(
|
||||
max_concurrent_downloads,
|
||||
search_timeout,
|
||||
store,
|
||||
config_path,
|
||||
);
|
||||
tui.run(terminal)
|
||||
}
|
||||
@@ -280,6 +287,10 @@ mod tests {
|
||||
searches: std::sync::Mutex<Vec<crate::api::SessionSearch>>,
|
||||
/// Whether this session is a daemon's. Local sessions do not sync.
|
||||
shared: bool,
|
||||
/// What `set_download_directory` was last asked for, if anything.
|
||||
download_dir_set: std::sync::Mutex<Option<String>>,
|
||||
/// What `set_shared_directories` was last asked for, if anything.
|
||||
shares_set: std::sync::Mutex<Option<Vec<String>>>,
|
||||
}
|
||||
|
||||
fn queued(username: &str, filename: &str) -> soulseek_rs::types::Download {
|
||||
@@ -468,8 +479,17 @@ mod tests {
|
||||
}
|
||||
fn set_shared_directories(
|
||||
&self,
|
||||
_directories: Vec<String>,
|
||||
directories: Vec<String>,
|
||||
) -> soulseek_rs::Result<()> {
|
||||
*self.shares_set.lock().expect("not poisoned") = Some(directories);
|
||||
Ok(())
|
||||
}
|
||||
fn set_download_directory(
|
||||
&self,
|
||||
directory: String,
|
||||
) -> soulseek_rs::Result<()> {
|
||||
*self.download_dir_set.lock().expect("not poisoned") =
|
||||
Some(directory);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -509,6 +529,7 @@ mod tests {
|
||||
1,
|
||||
Duration::from_secs(1),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -710,4 +731,109 @@ mod tests {
|
||||
// record, so nothing is invented.
|
||||
assert!(tui(Vec::new()).state.messages.is_empty());
|
||||
}
|
||||
|
||||
/// Attached, both folders in the settings form name paths on the
|
||||
/// *daemon's* machine: they go over the socket exactly as typed, and
|
||||
/// nothing is created or validated against this machine's filesystem.
|
||||
#[test]
|
||||
fn applying_settings_attached_pushes_both_folders_to_the_daemon() {
|
||||
let session = Arc::new(TalkativeSession {
|
||||
shared: true,
|
||||
..TalkativeSession::default()
|
||||
});
|
||||
let scratch = tempfile::tempdir().expect("a temp dir");
|
||||
let config_path = scratch.path().join("config.toml");
|
||||
let mut tui = MainTui::new(
|
||||
session.clone(),
|
||||
"/daemon/old".to_string(),
|
||||
1,
|
||||
Duration::from_secs(1),
|
||||
None,
|
||||
Some(config_path.clone()),
|
||||
);
|
||||
|
||||
let daemon_only = scratch.path().join("only-on-the-daemon");
|
||||
let daemon_dir = daemon_only.to_str().expect("utf-8").to_string();
|
||||
tui.open_settings();
|
||||
{
|
||||
let settings = tui.state.settings.as_mut().expect("open");
|
||||
settings.download_dir.clone_from(&daemon_dir);
|
||||
settings.share_dirs = vec!["/not-here-either".to_string()];
|
||||
}
|
||||
tui.apply_settings();
|
||||
|
||||
assert_eq!(
|
||||
*session.download_dir_set.lock().expect("not poisoned"),
|
||||
Some(daemon_dir.clone()),
|
||||
"the folder is pushed to the daemon"
|
||||
);
|
||||
assert_eq!(
|
||||
*session.shares_set.lock().expect("not poisoned"),
|
||||
Some(vec!["/not-here-either".to_string()]),
|
||||
"shares go over as typed — the daemon's filesystem judges them"
|
||||
);
|
||||
assert!(
|
||||
!daemon_only.exists(),
|
||||
"no directory is created on this machine for a daemon's path"
|
||||
);
|
||||
assert_eq!(tui.download_dir, daemon_dir);
|
||||
|
||||
let saved = crate::persist::config::FileConfig::load(&config_path)
|
||||
.expect("config readable");
|
||||
assert_eq!(
|
||||
saved.download_dir.as_deref(),
|
||||
Some(daemon_dir.as_str()),
|
||||
"the change still lands in config.toml for the next restart"
|
||||
);
|
||||
}
|
||||
|
||||
/// Locally the same form works on this machine: the folder is created
|
||||
/// here and share paths that do not exist here are dropped before the
|
||||
/// session hears about them.
|
||||
#[test]
|
||||
fn applying_settings_locally_stays_on_this_machine() {
|
||||
let session = Arc::new(TalkativeSession {
|
||||
shared: false,
|
||||
..TalkativeSession::default()
|
||||
});
|
||||
let scratch = tempfile::tempdir().expect("a temp dir");
|
||||
let config_path = scratch.path().join("config.toml");
|
||||
let share = scratch.path().join("music");
|
||||
std::fs::create_dir_all(&share).expect("share dir");
|
||||
let mut tui = MainTui::new(
|
||||
session.clone(),
|
||||
"/tmp".to_string(),
|
||||
1,
|
||||
Duration::from_secs(1),
|
||||
None,
|
||||
Some(config_path),
|
||||
);
|
||||
|
||||
let downloads = scratch.path().join("downloads");
|
||||
let downloads_dir = downloads.to_str().expect("utf-8").to_string();
|
||||
tui.open_settings();
|
||||
{
|
||||
let settings = tui.state.settings.as_mut().expect("open");
|
||||
settings.download_dir.clone_from(&downloads_dir);
|
||||
settings.share_dirs = vec![
|
||||
share.to_str().expect("utf-8").to_string(),
|
||||
"/definitely-not-a-real-share".to_string(),
|
||||
];
|
||||
}
|
||||
tui.apply_settings();
|
||||
|
||||
// The seam is told in both modes; a local session accepts and takes
|
||||
// the directory per download instead of storing it.
|
||||
assert_eq!(
|
||||
*session.download_dir_set.lock().expect("not poisoned"),
|
||||
Some(downloads_dir.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
*session.shares_set.lock().expect("not poisoned"),
|
||||
Some(vec![share.to_str().expect("utf-8").to_string()]),
|
||||
"paths missing on this machine are dropped before applying"
|
||||
);
|
||||
assert!(downloads.is_dir(), "the folder is created here");
|
||||
assert_eq!(tui.download_dir, downloads_dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Settings popup behavior: open, key handling, and applying changes
|
||||
//! (persist to config.toml + live share rescan on the client).
|
||||
//! Settings popup behavior: open, key handling, and applying changes —
|
||||
//! persisted to config.toml, applied live to the session (pushed over the
|
||||
//! socket when the session is a daemon's).
|
||||
|
||||
use super::MainTui;
|
||||
use crate::models::{SettingsAction, SettingsState};
|
||||
@@ -25,38 +26,68 @@ impl MainTui {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the edited settings and apply the share set live.
|
||||
fn apply_settings(&mut self) {
|
||||
/// Persist the edited settings and apply them live.
|
||||
///
|
||||
/// Attached to a daemon, both folders name paths on the *daemon's*
|
||||
/// machine: they are pushed over the socket and validated there, and no
|
||||
/// directory is created on this one. Locally they are this machine's and
|
||||
/// are validated here.
|
||||
pub(super) fn apply_settings(&mut self) {
|
||||
let Some(settings) = self.state.settings.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let download_dir = settings.download_dir.clone();
|
||||
let share_dirs = settings.share_dirs.clone();
|
||||
|
||||
if let Err(e) = std::fs::create_dir_all(&download_dir) {
|
||||
self.set_settings_status(format!(
|
||||
"Cannot create {download_dir}: {e}"
|
||||
));
|
||||
return;
|
||||
}
|
||||
self.download_dir.clone_from(&download_dir);
|
||||
|
||||
// Validate the share paths (tilde-expand, must exist) and apply.
|
||||
let valid = crate::directories::resolve_shared_directories(&share_dirs);
|
||||
let dropped = share_dirs.len() - valid.len();
|
||||
let mut status = match self.client.set_shared_directories(valid) {
|
||||
Ok(()) if dropped > 0 => {
|
||||
format!("Applied ({dropped} invalid path(s) ignored)")
|
||||
let shares = if self.client.daemon_endpoint().is_some() {
|
||||
share_dirs.clone()
|
||||
} else {
|
||||
if let Err(e) = std::fs::create_dir_all(&download_dir) {
|
||||
self.set_settings_status(format!(
|
||||
"Cannot create {download_dir}: {e}"
|
||||
));
|
||||
return;
|
||||
}
|
||||
Ok(()) => format!("Applied · sharing {}", self.share_counts()),
|
||||
Err(e) => format!("Could not apply shares: {e}"),
|
||||
// Validate the share paths (tilde-expand, must exist).
|
||||
crate::directories::resolve_shared_directories(&share_dirs)
|
||||
};
|
||||
let dropped = share_dirs.len() - shares.len();
|
||||
|
||||
// Persist to config.toml so the change survives a restart.
|
||||
if let Some(path) = crate::persist::paths::config_file() {
|
||||
// Two independent applies: a failure of one must not misreport or
|
||||
// silently discard the other, so both run and both are told.
|
||||
let mut refused = Vec::new();
|
||||
let dir_applied =
|
||||
match self.client.set_download_directory(download_dir.clone()) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
refused.push(format!("download folder: {e}"));
|
||||
false
|
||||
}
|
||||
};
|
||||
if let Err(e) = self.client.set_shared_directories(shares) {
|
||||
refused.push(format!("shares: {e}"));
|
||||
}
|
||||
|
||||
let mut status = if !refused.is_empty() {
|
||||
format!("Could not apply {}", refused.join("; "))
|
||||
} else if dropped > 0 {
|
||||
format!("Applied ({dropped} invalid path(s) ignored)")
|
||||
} else {
|
||||
format!("Applied · sharing {}", self.share_counts())
|
||||
};
|
||||
if dir_applied {
|
||||
self.download_dir.clone_from(&download_dir);
|
||||
}
|
||||
|
||||
// Persist to config.toml so the change survives a restart. What was
|
||||
// refused live is not written either: the file must not promise a
|
||||
// folder the session never accepted.
|
||||
if let Some(path) = self.config_path.clone() {
|
||||
let result = crate::persist::config::FileConfig::load(&path)
|
||||
.and_then(|mut config| {
|
||||
config.download_dir = Some(download_dir);
|
||||
if dir_applied {
|
||||
config.download_dir = Some(download_dir);
|
||||
}
|
||||
// The singular `shared_dir` is merged with `shared_dirs` on
|
||||
// load, so leaving it would resurrect a removed folder.
|
||||
config.shared_dir = None;
|
||||
|
||||
Reference in New Issue
Block a user