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:
@@ -221,7 +221,7 @@ pub fn run(
|
||||
hub: Arc::clone(&hub),
|
||||
browses: Arc::clone(&browses),
|
||||
downloads: Arc::clone(&downloads),
|
||||
download_dir: ctx.download_dir.clone(),
|
||||
download_dir: Mutex::new(ctx.download_dir.clone()),
|
||||
server: ctx.settings.server_address.to_string(),
|
||||
token: token.clone(),
|
||||
started: Instant::now(),
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
|
||||
use super::proto::{
|
||||
Ack, AuthParams, AuthResult, BrowseEvent, DaemonStatus, DirectoriesParams,
|
||||
DownloadStartParams, DownloadStarted, DownloadStatusEvent, Downloads,
|
||||
Event, IntervalSeconds, Members, MessageParams, Messages, Method,
|
||||
QueryParams, RoomEventDto, RoomRef, SayParams, SearchResults, Searches,
|
||||
Seconds, SessionLossEvent, SharesStatus, SlotsParams, TransferRef,
|
||||
UploadInfoDto, Uploads, UserMessageDto, UserRef, UserResult,
|
||||
DirectoryParams, DownloadStartParams, DownloadStarted, DownloadStatusEvent,
|
||||
Downloads, Event, IntervalSeconds, Members, MessageParams, Messages,
|
||||
Method, QueryParams, RoomEventDto, RoomRef, SayParams, SearchResults,
|
||||
Searches, Seconds, SessionLossEvent, SharesStatus, SlotsParams,
|
||||
TransferRef, UploadInfoDto, Uploads, UserMessageDto, UserRef, UserResult,
|
||||
};
|
||||
use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings};
|
||||
use serde_json::{Value, json};
|
||||
@@ -49,6 +49,7 @@ fn params_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
|
||||
| Method::DownloadRemove
|
||||
| Method::DownloadRemoveQueued
|
||||
| Method::UploadCancel => schema_of::<TransferRef>(generator),
|
||||
Method::DownloadSetDir => schema_of::<DirectoryParams>(generator),
|
||||
Method::UploadSlots => schema_of::<SlotsParams>(generator),
|
||||
Method::RoomJoin | Method::RoomLeave | Method::RoomMembers => {
|
||||
schema_of::<RoomRef>(generator)
|
||||
@@ -104,6 +105,7 @@ fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
|
||||
| Method::DownloadResume
|
||||
| Method::DownloadRemove
|
||||
| Method::DownloadRemoveQueued
|
||||
| Method::DownloadSetDir
|
||||
| Method::UploadCancel
|
||||
| Method::UploadSlots
|
||||
| Method::PrivilegesCheck
|
||||
@@ -155,6 +157,9 @@ fn summary(method: Method) -> &'static str {
|
||||
Method::DownloadResume => "Resume a paused transfer.",
|
||||
Method::DownloadRemove => "Forget a transfer entirely.",
|
||||
Method::DownloadRemoveQueued => "Drop a transfer that has not started.",
|
||||
Method::DownloadSetDir => {
|
||||
"Land future transfers in a different folder on the daemon's host."
|
||||
}
|
||||
Method::UploadList => "Uploads being served, active first.",
|
||||
Method::UploadCancel => "Stop serving an upload in progress.",
|
||||
Method::UploadSlots => "How many uploads may run at once.",
|
||||
|
||||
@@ -81,6 +81,14 @@ pub struct DirectoriesParams {
|
||||
pub directories: Vec<String>,
|
||||
}
|
||||
|
||||
/// Where the daemon should land transfers from now on, on *its* filesystem.
|
||||
/// The same trust as `shares.set`: an authenticated caller is the owner.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct DirectoryParams {
|
||||
pub directory: String,
|
||||
}
|
||||
|
||||
/// Every search this session has run, newest last. Shared by all clients, so
|
||||
/// a second one attaching sees what the first has been looking for.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
|
||||
@@ -90,6 +90,7 @@ wire_vocabulary! {
|
||||
DownloadResume => "download.resume",
|
||||
DownloadRemove => "download.remove",
|
||||
DownloadRemoveQueued => "download.remove_queued",
|
||||
DownloadSetDir => "download.set_dir",
|
||||
UploadList => "upload.list",
|
||||
UploadCancel => "upload.cancel",
|
||||
UploadSlots => "upload.slots",
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
use super::hub::{Hub, PendingBrowses, PendingDownload};
|
||||
use super::proto::{
|
||||
Ack, AuthResult, CODE_APPLICATION, CODE_INVALID_PARAMS, ChatMessageDto,
|
||||
DaemonStatus, DirectoriesParams, DownloadDto, DownloadStartParams,
|
||||
DownloadStarted, Downloads, IntervalSeconds, Members, MessageParams,
|
||||
Messages, Method, OPENRPC, PROTOCOL_VERSION, QueryParams, RoomRef,
|
||||
RpcError, SayParams, SearchResultDto, SearchResults, SearchSummary,
|
||||
Searches, Seconds, SharesStatus, SlotsParams, TransferRef, UploadInfoDto,
|
||||
Uploads, UserInfoDto, UserRef, UserResult,
|
||||
DaemonStatus, DirectoriesParams, DirectoryParams, DownloadDto,
|
||||
DownloadStartParams, DownloadStarted, Downloads, IntervalSeconds, Members,
|
||||
MessageParams, Messages, Method, OPENRPC, PROTOCOL_VERSION, QueryParams,
|
||||
RoomRef, RpcError, SayParams, SearchResultDto, SearchResults,
|
||||
SearchSummary, Searches, Seconds, SharesStatus, SlotsParams, TransferRef,
|
||||
UploadInfoDto, Uploads, UserInfoDto, UserRef, UserResult,
|
||||
};
|
||||
use crate::api::SessionApi;
|
||||
use crate::output::Exit;
|
||||
@@ -29,9 +29,9 @@ pub struct Daemon {
|
||||
pub hub: Arc<Hub>,
|
||||
pub browses: Arc<PendingBrowses>,
|
||||
pub downloads: Arc<Mutex<Vec<PendingDownload>>>,
|
||||
/// Where transfers land, on *this* machine. A remote caller cannot see
|
||||
/// this filesystem, so it does not get to choose.
|
||||
pub download_dir: String,
|
||||
/// Where transfers land, on *this* machine. A caller may point it at a
|
||||
/// different folder with `download.set_dir`, but never per download.
|
||||
pub download_dir: Mutex<String>,
|
||||
pub server: String,
|
||||
pub token: String,
|
||||
pub started: Instant,
|
||||
@@ -126,6 +126,9 @@ impl Daemon {
|
||||
self.session.start_wishlist_search(&query.query).map_err(
|
||||
|e| RpcError::application(Exit::Connection, e.to_string()),
|
||||
)?;
|
||||
// Without a start time every other window would render this
|
||||
// search as finished the moment it goes out.
|
||||
self.record_search_start(&query.query);
|
||||
ok(Ack::OK)
|
||||
}
|
||||
Method::SearchWishlistInterval => ok(IntervalSeconds {
|
||||
@@ -133,6 +136,11 @@ impl Daemon {
|
||||
}),
|
||||
|
||||
Method::DownloadStart => self.start_download(parse(params)?),
|
||||
Method::DownloadSetDir => {
|
||||
let dir: DirectoryParams = parse(params)?;
|
||||
self.set_download_dir(dir.directory)?;
|
||||
ok(Ack::OK)
|
||||
}
|
||||
Method::DownloadList => ok(Downloads {
|
||||
downloads: self
|
||||
.session
|
||||
@@ -344,10 +352,10 @@ impl Daemon {
|
||||
params: DownloadStartParams,
|
||||
) -> Result<Value, RpcError> {
|
||||
// The daemon's own directory, always. Letting a caller name a path
|
||||
// would hand any authenticated client an arbitrary write on this
|
||||
// host's filesystem, and a remote one cannot see that filesystem to
|
||||
// choose sensibly anyway.
|
||||
let directory = self.download_dir.clone();
|
||||
// per download would scatter transfers in ways `daemon.status` cannot
|
||||
// report; changing the folder is a deliberate session-wide act,
|
||||
// `download.set_dir`.
|
||||
let directory = self.download_dir();
|
||||
let (download, updates) = self
|
||||
.session
|
||||
.download_with_metadata(
|
||||
@@ -376,6 +384,35 @@ impl Daemon {
|
||||
})
|
||||
}
|
||||
|
||||
fn download_dir(&self) -> String {
|
||||
self.download_dir
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Point future transfers at `directory`, creating it on *this* host.
|
||||
/// The same trust boundary as `shares.set`: authenticated means owner.
|
||||
fn set_download_dir(&self, directory: String) -> Result<(), RpcError> {
|
||||
if directory.trim().is_empty() {
|
||||
return Err(RpcError::new(
|
||||
CODE_INVALID_PARAMS,
|
||||
"the download directory cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
std::fs::create_dir_all(&directory).map_err(|e| {
|
||||
RpcError::application(
|
||||
Exit::Usage,
|
||||
format!("cannot create {directory}: {e}"),
|
||||
)
|
||||
})?;
|
||||
*self
|
||||
.download_dir
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = directory;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shares(&self) -> SharesStatus {
|
||||
let (folders, files) = self.session.shared_counts();
|
||||
SharesStatus {
|
||||
@@ -427,7 +464,7 @@ impl Daemon {
|
||||
listen_port: self.session.listen_port(),
|
||||
shared_folders: folders,
|
||||
shared_files: files,
|
||||
download_dir: self.download_dir.clone(),
|
||||
download_dir: self.download_dir(),
|
||||
session_loss: self.session.session_loss().map(Into::into),
|
||||
clients: self.hub.subscribers(),
|
||||
uptime_secs: self.started.elapsed().as_secs(),
|
||||
@@ -577,7 +614,7 @@ mod tests {
|
||||
hub: Arc::new(Hub::new()),
|
||||
browses: Arc::new(PendingBrowses::default()),
|
||||
downloads: Arc::new(Mutex::new(Vec::new())),
|
||||
download_dir: "/tmp".to_string(),
|
||||
download_dir: Mutex::new("/tmp".to_string()),
|
||||
server: "server.invalid:2416".to_string(),
|
||||
token: token.to_string(),
|
||||
started: Instant::now(),
|
||||
@@ -771,5 +808,11 @@ mod tests {
|
||||
) -> soulseek_rs::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn set_download_directory(
|
||||
&self,
|
||||
_directory: String,
|
||||
) -> soulseek_rs::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user