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
+50 -12
View File
@@ -55,29 +55,45 @@ pub struct LiveDaemon {
pub token: Option<String>,
}
/// Tell a running daemon what this machine now shares.
/// Tell a running daemon about a change it should apply now.
///
/// Locally, `shares add` means "shared from the next run on", because there is
/// no session yet. A daemon *is* that next run and it is already going, so
/// leaving it on its startup list would make the command look like it had
/// done nothing — the folder configured, and still invisible to the network.
fn push_to_daemon(out: &Out, daemon: &LiveDaemon, directories: Vec<String>) {
/// Locally a config change means "from the next run on", because there is no
/// session yet. A daemon *is* that next run and it is already going, so
/// leaving it on its startup settings would make the command look like it
/// had done nothing — the folder configured, and the network none the wiser.
fn push_to_daemon(
out: &Out,
daemon: &LiveDaemon,
applied: &str,
kept: &str,
apply: impl FnOnce(&RemoteSession) -> soulseek_rs::Result<()>,
) {
let Some(endpoint) = daemon.endpoint.as_ref() else {
return;
};
let token = daemon.token.clone().or_else(crate::daemon::stored_token);
let outcome = RemoteSession::connect(endpoint, token)
.and_then(|session| session.set_shared_directories(directories));
.and_then(|session| apply(&session));
match outcome {
Ok(()) => out.status("the running daemon is re-indexing"),
Ok(()) => out.status(applied),
// Not fatal: the config file is written either way, so the change
// still takes effect when the daemon next starts.
Err(e) => out.warn(&format!(
"the daemon kept its own shares ({e}); restart it to pick this up"
"the daemon kept {kept} ({e}); restart it to pick this up"
)),
}
}
fn push_shares(out: &Out, daemon: &LiveDaemon, directories: Vec<String>) {
push_to_daemon(
out,
daemon,
"the running daemon is re-indexing",
"its own shares",
move |session| session.set_shared_directories(directories),
);
}
pub fn shares_add(
out: &Out,
store: &Store,
@@ -104,7 +120,7 @@ pub fn shares_add(
updated.shared_dir = None;
store.save(&updated)?;
out.status(&format!("sharing {directory}"));
push_to_daemon(out, daemon, dirs);
push_shares(out, daemon, dirs);
Ok(())
}
@@ -128,7 +144,7 @@ pub fn shares_remove(
updated.shared_dir = None;
store.save(&updated)?;
out.status(&format!("no longer sharing {directory}"));
push_to_daemon(out, daemon, dirs);
push_shares(out, daemon, dirs);
Ok(())
}
@@ -228,14 +244,36 @@ pub fn config_set(
store: &Store,
key: &str,
value: &str,
daemon: impl FnOnce() -> LiveDaemon,
) -> CliResult {
let mut updated = store.config.clone();
updated.set(key, value).map_err(CliError::usage)?;
store.save(&updated)?;
let current = updated.get(key);
out.emit(&ConfigRecord {
key: key.to_string(),
value: updated.get(key),
value: current.clone(),
});
// A running daemon holds the live settings, so for the keys it owns a
// config write alone would look like it did nothing until its next
// restart — the same trap `shares add` closes by pushing to the session.
// `daemon` is resolved lazily: finding one means connecting to a socket,
// which the other keys should not pay for.
match key {
"download_dir" => {
if let Some(directory) = current {
push_to_daemon(
out,
&daemon(),
"the running daemon will download there now",
"its own download folder",
move |session| session.set_download_directory(directory),
);
}
}
"shared_dirs" => push_shares(out, &daemon(), share_list(&updated)),
_ => {}
}
Ok(())
}