feat(daemon): route commands and the TUI through a running daemon
Session::open attaches to a daemon when one is reachable and logs in otherwise, so every existing command works unchanged against either. A local socket is found automatically, which is what makes an agent's invocation need no flags at all; --no-daemon opts out. Commands now read identity, shares, server, and download directory from the session rather than from this run's config: routed to a daemon those differ, and the session is the one actually on the network. Without that, whoami named the wrong server and shares reindex would have imposed the client machine's folders on the daemon.
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
//! The client-side copy of what the daemon drained.
|
||||
//!
|
||||
//! A remote session cannot ask the daemon to `take_*` on its behalf — that is
|
||||
//! destructive, and with two clients attached the first to ask would steal the
|
||||
//! other's events. So the daemon pushes, this mirror accumulates, and
|
||||
//! `take_*` empties the mirror instead. From the caller's side the semantics
|
||||
//! are identical: each event is delivered once, to each client.
|
||||
|
||||
use crate::daemon::proto::{
|
||||
BrowseEvent, DownloadStatusEvent, Event, RoomEventDto, SessionLossEvent,
|
||||
UploadInfoDto, UserMessageDto,
|
||||
};
|
||||
use soulseek_rs::{
|
||||
DownloadStatus, RoomEvent, SessionLoss, SharedDirectory, UploadInfo,
|
||||
UserMessage,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
/// Everything a pushed event can land in, behind one lock.
|
||||
///
|
||||
/// One lock rather than several: it is taken for the length of a `Vec::push`
|
||||
/// on an event thread that has nothing else to do, and a single lock cannot
|
||||
/// deadlock against itself.
|
||||
#[derive(Default)]
|
||||
struct Buffers {
|
||||
rooms: Vec<RoomEvent>,
|
||||
messages: Vec<UserMessage>,
|
||||
uploads: Vec<UploadInfo>,
|
||||
browses: HashMap<String, Vec<SharedDirectory>>,
|
||||
/// Where each in-flight transfer's status goes, keyed the way the daemon
|
||||
/// names it. Registered by `download`, dropped when the transfer ends.
|
||||
downloads: HashMap<(String, String), Sender<DownloadStatus>>,
|
||||
loss: Option<SessionLoss>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Mirror(Mutex<Buffers>);
|
||||
|
||||
impl Mirror {
|
||||
/// File one pushed event. Anything unparseable is dropped with a warning
|
||||
/// rather than killing the connection: a newer daemon may legitimately
|
||||
/// send a shape this client does not know yet.
|
||||
pub fn apply(&self, event: Event, params: serde_json::Value) {
|
||||
let Ok(mut buffers) = self.0.lock() else {
|
||||
return;
|
||||
};
|
||||
match event {
|
||||
Event::Room => {
|
||||
if let Ok(room) = parse::<RoomEventDto>(event, params) {
|
||||
buffers.rooms.push(room.into());
|
||||
}
|
||||
}
|
||||
Event::Message => {
|
||||
if let Ok(message) = parse::<UserMessageDto>(event, params) {
|
||||
buffers.messages.push(message.into());
|
||||
}
|
||||
}
|
||||
Event::Upload => {
|
||||
if let Ok(upload) = parse::<UploadInfoDto>(event, params) {
|
||||
buffers.uploads.push(upload.into());
|
||||
}
|
||||
}
|
||||
Event::Browse => {
|
||||
if let Ok(browse) = parse::<BrowseEvent>(event, params) {
|
||||
buffers.browses.insert(
|
||||
browse.username,
|
||||
browse
|
||||
.directories
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::DownloadStatus => {
|
||||
if let Ok(update) = parse::<DownloadStatusEvent>(event, params)
|
||||
{
|
||||
Self::route(&mut buffers, update);
|
||||
}
|
||||
}
|
||||
Event::SessionLoss => {
|
||||
if let Ok(loss) = parse::<SessionLossEvent>(event, params) {
|
||||
// The first loss is the one worth keeping: a displaced
|
||||
// session also drops its socket a moment later.
|
||||
buffers.loss.get_or_insert_with(|| loss.loss.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deliver a transfer update to whoever is waiting on it, forgetting the
|
||||
/// transfer once it can produce nothing further.
|
||||
fn route(buffers: &mut Buffers, update: DownloadStatusEvent) {
|
||||
let key = (update.username, update.filename);
|
||||
let status = DownloadStatus::from(update.status);
|
||||
let terminal = matches!(
|
||||
status,
|
||||
DownloadStatus::Completed
|
||||
| DownloadStatus::Failed(_)
|
||||
| DownloadStatus::TimedOut
|
||||
);
|
||||
let Some(sender) = buffers.downloads.get(&key) else {
|
||||
return;
|
||||
};
|
||||
// A caller that dropped its receiver is not an error: it stopped
|
||||
// caring, which is exactly what `remove_download` does.
|
||||
let delivered = sender.send(status).is_ok();
|
||||
if terminal || !delivered {
|
||||
buffers.downloads.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register where a transfer's updates should go before asking for it, so
|
||||
/// an update that lands immediately is not dropped on the floor.
|
||||
pub fn expect_download(
|
||||
&self,
|
||||
username: &str,
|
||||
filename: &str,
|
||||
sender: Sender<DownloadStatus>,
|
||||
) {
|
||||
if let Ok(mut buffers) = self.0.lock() {
|
||||
buffers
|
||||
.downloads
|
||||
.insert((username.to_string(), filename.to_string()), sender);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forget_download(&self, username: &str, filename: &str) {
|
||||
if let Ok(mut buffers) = self.0.lock() {
|
||||
buffers
|
||||
.downloads
|
||||
.remove(&(username.to_string(), filename.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_rooms(&self) -> Vec<RoomEvent> {
|
||||
self.take(|buffers| std::mem::take(&mut buffers.rooms))
|
||||
}
|
||||
|
||||
pub fn take_messages(&self) -> Vec<UserMessage> {
|
||||
self.take(|buffers| std::mem::take(&mut buffers.messages))
|
||||
}
|
||||
|
||||
pub fn take_uploads(&self) -> Vec<UploadInfo> {
|
||||
self.take(|buffers| std::mem::take(&mut buffers.uploads))
|
||||
}
|
||||
|
||||
pub fn take_browse(&self, username: &str) -> Option<Vec<SharedDirectory>> {
|
||||
self.0.lock().ok()?.browses.remove(username)
|
||||
}
|
||||
|
||||
pub fn loss(&self) -> Option<SessionLoss> {
|
||||
self.0.lock().ok()?.loss
|
||||
}
|
||||
|
||||
fn take<T: Default>(&self, drain: impl FnOnce(&mut Buffers) -> T) -> T {
|
||||
self.0.lock().map_or_else(|_| T::default(), |mut b| drain(&mut b))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse<T: serde::de::DeserializeOwned>(
|
||||
event: Event,
|
||||
params: serde_json::Value,
|
||||
) -> Result<T, ()> {
|
||||
serde_json::from_value(params).map_err(|e| {
|
||||
soulseek_rs::warn!("ignoring an unreadable {event} event: {e}");
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::daemon::proto::DownloadStatusDto;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn a_room_event_is_delivered_once_and_then_gone() {
|
||||
let mirror = Mirror::default();
|
||||
mirror.apply(
|
||||
Event::Room,
|
||||
json!({ "event": "left", "room": "lobby" }),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
mirror.take_rooms(),
|
||||
[RoomEvent::Left {
|
||||
room: "lobby".into()
|
||||
}]
|
||||
);
|
||||
assert!(
|
||||
mirror.take_rooms().is_empty(),
|
||||
"draining twice must not replay what was already handed over"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_browse_result_is_keyed_by_peer() {
|
||||
let mirror = Mirror::default();
|
||||
mirror.apply(
|
||||
Event::Browse,
|
||||
json!({
|
||||
"username": "bob",
|
||||
"directories": [{ "name": "@@x", "files": [["a.mp3", 42]] }],
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(mirror.take_browse("sue").is_none());
|
||||
let listing = mirror.take_browse("bob").expect("bob answered");
|
||||
assert_eq!(listing[0].name, "@@x");
|
||||
assert_eq!(listing[0].files, [("a.mp3".to_string(), 42)]);
|
||||
assert!(mirror.take_browse("bob").is_none(), "taken means taken");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transfer_update_reaches_the_caller_waiting_on_it() {
|
||||
let mirror = Mirror::default();
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
mirror.expect_download("bob", "a.mp3", sender);
|
||||
|
||||
mirror.apply(
|
||||
Event::DownloadStatus,
|
||||
serde_json::to_value(DownloadStatusEvent {
|
||||
username: "bob".into(),
|
||||
filename: "a.mp3".into(),
|
||||
status: DownloadStatusDto::Queued,
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Queued)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_transfer_stops_being_tracked() {
|
||||
let mirror = Mirror::default();
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
mirror.expect_download("bob", "a.mp3", sender);
|
||||
|
||||
for status in [DownloadStatusDto::Completed, DownloadStatusDto::Queued]
|
||||
{
|
||||
mirror.apply(
|
||||
Event::DownloadStatus,
|
||||
serde_json::to_value(DownloadStatusEvent {
|
||||
username: "bob".into(),
|
||||
filename: "a.mp3".into(),
|
||||
status,
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Completed)));
|
||||
assert!(
|
||||
receiver.try_recv().is_err(),
|
||||
"a completed transfer must not keep receiving updates"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_update_for_an_unknown_transfer_is_harmless() {
|
||||
let mirror = Mirror::default();
|
||||
mirror.apply(
|
||||
Event::DownloadStatus,
|
||||
serde_json::to_value(DownloadStatusEvent {
|
||||
username: "nobody".into(),
|
||||
filename: "x.mp3".into(),
|
||||
status: DownloadStatusDto::Completed,
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_session_loss_is_the_one_reported() {
|
||||
let mirror = Mirror::default();
|
||||
assert!(mirror.loss().is_none());
|
||||
mirror.apply(Event::SessionLoss, json!({ "loss": "displaced" }));
|
||||
mirror.apply(Event::SessionLoss, json!({ "loss": "disconnected" }));
|
||||
assert_eq!(
|
||||
mirror.loss(),
|
||||
Some(SessionLoss::Displaced),
|
||||
"being displaced is the reason worth reporting; the dropped socket \
|
||||
is its consequence"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_event_is_ignored_rather_than_fatal() {
|
||||
// A newer daemon may send a shape this client does not know. Dropping
|
||||
// one event beats dropping the connection.
|
||||
let mirror = Mirror::default();
|
||||
mirror.apply(Event::Room, json!({ "event": "invented" }));
|
||||
assert!(mirror.take_rooms().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1 +1,887 @@
|
||||
//! placeholder
|
||||
//! Talking to a daemon as if it were a local client.
|
||||
//!
|
||||
//! [`RemoteSession`] implements the same [`SessionApi`] the commands and the
|
||||
//! TUI already use, so nothing above the seam knows whether the session is in
|
||||
//! this process or another one. Request/response methods are RPCs; the
|
||||
//! destructive `take_*` drains read from a mirror the daemon feeds by pushing.
|
||||
|
||||
mod mirror;
|
||||
|
||||
use crate::api::SessionApi;
|
||||
use crate::daemon::proto::{
|
||||
Ack, AuthParams, AuthResult, CODE_APPLICATION, ChatMessageDto,
|
||||
DaemonStatus, DirectoriesParams, DownloadDto, DownloadStartParams,
|
||||
DownloadStarted, Downloads, Event, IntervalSeconds, Members, MessageParams,
|
||||
Messages, Method, PROTOCOL_VERSION, QueryParams, Request, Response,
|
||||
RoomRef, RpcError, SayParams, SearchResults, Seconds, SharesStatus,
|
||||
SlotsParams, TransferRef, Uploads, UserRef, UserResult,
|
||||
};
|
||||
use mirror::Mirror;
|
||||
use serde::de::DeserializeOwned;
|
||||
use soulseek_rs::types::{Download, DownloadMetadata};
|
||||
use soulseek_rs::{
|
||||
DownloadStatus, Result, RoomEvent, SearchResult, SessionLoss,
|
||||
SharedDirectory, SoulseekRs, UploadInfo, UserInfo, UserMessage,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long to wait for the daemon to answer one request. Every method is
|
||||
/// either a lock-and-read or a message onto an already-open socket, so a
|
||||
/// daemon that has not answered by now is wedged, not busy.
|
||||
const CALL_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Where a daemon is listening.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Endpoint {
|
||||
/// A Unix socket path: local, and authenticated by its permissions.
|
||||
Socket(PathBuf),
|
||||
/// `host:port`: needs the token.
|
||||
Tcp(String),
|
||||
}
|
||||
|
||||
impl Endpoint {
|
||||
/// Read what the user wrote. The distinction is unambiguous because a
|
||||
/// socket path always has a separator and `host:port` never does.
|
||||
#[must_use]
|
||||
pub fn parse(value: &str) -> Self {
|
||||
if value.contains(std::path::MAIN_SEPARATOR) || value.contains('/') {
|
||||
return Self::Socket(PathBuf::from(value));
|
||||
}
|
||||
Self::Tcp(value.to_string())
|
||||
}
|
||||
|
||||
/// Whether the transport itself proves who we are, in which case no token
|
||||
/// has to be found.
|
||||
const fn self_authenticating(&self) -> bool {
|
||||
matches!(self, Self::Socket(_))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Endpoint {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Socket(path) => write!(f, "{}", path.display()),
|
||||
Self::Tcp(address) => f.write_str(address),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls awaiting a reply, keyed by JSON-RPC id.
|
||||
type Answer = std::result::Result<serde_json::Value, RpcError>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Pending(Mutex<HashMap<u64, Sender<Answer>>>);
|
||||
|
||||
impl Pending {
|
||||
fn register(&self, id: u64) -> Receiver<Answer> {
|
||||
let (sender, receiver) = channel();
|
||||
if let Ok(mut pending) = self.0.lock() {
|
||||
pending.insert(id, sender);
|
||||
}
|
||||
receiver
|
||||
}
|
||||
|
||||
fn settle(&self, id: u64, outcome: Answer) {
|
||||
let sender = self.0.lock().ok().and_then(|mut p| p.remove(&id));
|
||||
if let Some(sender) = sender {
|
||||
let _ = sender.send(outcome);
|
||||
}
|
||||
}
|
||||
|
||||
fn abandon(&self, id: u64) {
|
||||
if let Ok(mut pending) = self.0.lock() {
|
||||
pending.remove(&id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fail everything still waiting. Called when the reader stops, so callers
|
||||
/// blocked on a dead connection are told rather than left to time out.
|
||||
fn fail_all(&self) {
|
||||
let Ok(mut pending) = self.0.lock() else {
|
||||
return;
|
||||
};
|
||||
for (_, sender) in pending.drain() {
|
||||
let _ = sender.send(Err(RpcError::new(
|
||||
CODE_APPLICATION,
|
||||
"the daemon closed the connection",
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The half of a connection that can be closed from another thread.
|
||||
///
|
||||
/// Dropping the write half is not enough to end the reader: it is blocked in
|
||||
/// `read` on its own cloned handle, so the socket would stay open and the
|
||||
/// daemon would keep a connection — and a hub subscription — for a client that
|
||||
/// has gone. An explicit shutdown is what makes a dropped session actually
|
||||
/// disconnect.
|
||||
enum Closer {
|
||||
Tcp(TcpStream),
|
||||
#[cfg(unix)]
|
||||
Unix(std::os::unix::net::UnixStream),
|
||||
}
|
||||
|
||||
impl Closer {
|
||||
fn close(&self) {
|
||||
let _ = match self {
|
||||
Self::Tcp(stream) => stream.shutdown(std::net::Shutdown::Both),
|
||||
#[cfg(unix)]
|
||||
Self::Unix(stream) => stream.shutdown(std::net::Shutdown::Both),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// One connection to a daemon, presented as a session.
|
||||
pub struct RemoteSession {
|
||||
outgoing: Mutex<Box<dyn Write + Send>>,
|
||||
closer: Closer,
|
||||
pending: Arc<Pending>,
|
||||
mirror: Arc<Mirror>,
|
||||
next_id: AtomicU64,
|
||||
username: String,
|
||||
/// Set when the reader thread stops, so a call fails immediately rather
|
||||
/// than waiting out its timeout against a socket nobody is reading.
|
||||
closed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl RemoteSession {
|
||||
/// Connect and authenticate.
|
||||
///
|
||||
/// `token` is required on TCP and optional on a Unix socket, where the
|
||||
/// socket's permissions have already answered the question.
|
||||
pub fn connect(endpoint: &Endpoint, token: Option<String>) -> Result<Self> {
|
||||
let (read, write, closer) = open(endpoint)?;
|
||||
|
||||
let pending = Arc::new(Pending::default());
|
||||
let mirror = Arc::new(Mirror::default());
|
||||
let closed = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let pending = Arc::clone(&pending);
|
||||
let mirror = Arc::clone(&mirror);
|
||||
let closed = Arc::clone(&closed);
|
||||
std::thread::spawn(move || {
|
||||
read_loop(read, &pending, &mirror);
|
||||
closed.store(true, Ordering::Relaxed);
|
||||
pending.fail_all();
|
||||
});
|
||||
}
|
||||
|
||||
let mut session = Self {
|
||||
outgoing: Mutex::new(write),
|
||||
closer,
|
||||
pending,
|
||||
mirror,
|
||||
next_id: AtomicU64::new(1),
|
||||
username: String::new(),
|
||||
closed,
|
||||
};
|
||||
|
||||
let token = if endpoint.self_authenticating() {
|
||||
token
|
||||
} else {
|
||||
Some(token.ok_or_else(|| {
|
||||
network(
|
||||
"a remote daemon needs a token; see `soulseek-rs daemon \
|
||||
token`",
|
||||
)
|
||||
})?)
|
||||
};
|
||||
let authenticated: AuthResult = session.request(
|
||||
Method::Auth,
|
||||
AuthParams {
|
||||
token,
|
||||
protocol: PROTOCOL_VERSION,
|
||||
},
|
||||
)?;
|
||||
if authenticated.protocol != PROTOCOL_VERSION {
|
||||
return Err(network(format!(
|
||||
"the daemon speaks protocol {} and this client speaks \
|
||||
{PROTOCOL_VERSION}; upgrade whichever is older",
|
||||
authenticated.protocol
|
||||
)));
|
||||
}
|
||||
|
||||
session.username = authenticated.username;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Send a request and wait for its reply.
|
||||
fn request<P: serde::Serialize, R: DeserializeOwned>(
|
||||
&self,
|
||||
method: Method,
|
||||
params: P,
|
||||
) -> Result<R> {
|
||||
let value = self.call(method, params)?;
|
||||
serde_json::from_value(value).map_err(|e| {
|
||||
network(format!(
|
||||
"the daemon's answer to {method} was unreadable: {e}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn call<P: serde::Serialize>(
|
||||
&self,
|
||||
method: Method,
|
||||
params: P,
|
||||
) -> Result<serde_json::Value> {
|
||||
if self.closed.load(Ordering::Relaxed) {
|
||||
return Err(SoulseekRs::ConnectionClosed);
|
||||
}
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let request = Request {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: Some(serde_json::json!(id)),
|
||||
method: method.wire_name().to_string(),
|
||||
params: serde_json::to_value(params)
|
||||
.map_err(|e| network(format!("cannot encode {method}: {e}")))?,
|
||||
};
|
||||
let line = serde_json::to_string(&request)
|
||||
.map_err(|e| network(format!("cannot encode {method}: {e}")))?;
|
||||
|
||||
let answer = self.pending.register(id);
|
||||
{
|
||||
let mut outgoing =
|
||||
self.outgoing.lock().map_err(|_| SoulseekRs::LockPoisoned)?;
|
||||
if writeln!(outgoing, "{line}").is_err() || outgoing.flush().is_err()
|
||||
{
|
||||
self.pending.abandon(id);
|
||||
return Err(SoulseekRs::ConnectionClosed);
|
||||
}
|
||||
}
|
||||
|
||||
match answer.recv_timeout(CALL_TIMEOUT) {
|
||||
Ok(Ok(value)) => Ok(value),
|
||||
Ok(Err(error)) => Err(network(error.message)),
|
||||
Err(_) => {
|
||||
self.pending.abandon(id);
|
||||
Err(SoulseekRs::Timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A call whose reply carries nothing worth reading.
|
||||
fn tell<P: serde::Serialize>(
|
||||
&self,
|
||||
method: Method,
|
||||
params: P,
|
||||
) -> Result<()> {
|
||||
self.call(method, params).map(|_| ())
|
||||
}
|
||||
|
||||
/// A call whose reply is a yes or a no. A daemon that cannot answer is a
|
||||
/// "no": every caller of these already reads false as "nothing to do".
|
||||
fn ask<P: serde::Serialize>(&self, method: Method, params: P) -> bool {
|
||||
self.request::<P, Ack>(method, params)
|
||||
.is_ok_and(|ack| ack.ok)
|
||||
}
|
||||
|
||||
fn status(&self) -> Option<DaemonStatus> {
|
||||
self.request(Method::DaemonStatus, ()).ok()
|
||||
}
|
||||
|
||||
fn shares(&self) -> SharesStatus {
|
||||
self.request(Method::SharesStatusOf, ())
|
||||
.unwrap_or(SharesStatus {
|
||||
folders: 0,
|
||||
files: 0,
|
||||
directories: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn transfer(username: &str, filename: &str) -> TransferRef {
|
||||
TransferRef {
|
||||
username: username.to_string(),
|
||||
filename: filename.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a transfer and hand back a channel that behaves like the library's.
|
||||
///
|
||||
/// The daemon owns the real transfer; this mints the channel the caller
|
||||
/// expects and registers it with the mirror, so pushed updates arrive on it
|
||||
/// exactly as a local client's would.
|
||||
fn start_download(
|
||||
&self,
|
||||
filename: String,
|
||||
username: String,
|
||||
size: u64,
|
||||
metadata: DownloadMetadata,
|
||||
) -> Result<(Download, Receiver<DownloadStatus>)> {
|
||||
let (sender, receiver) = channel();
|
||||
// Register before asking: a transfer that fails instantly would
|
||||
// otherwise report into a mirror that is not listening yet.
|
||||
self.mirror
|
||||
.expect_download(&username, &filename, sender.clone());
|
||||
|
||||
let started: DownloadStarted = self
|
||||
.request(
|
||||
Method::DownloadStart,
|
||||
DownloadStartParams {
|
||||
username: username.clone(),
|
||||
filename: filename.clone(),
|
||||
size,
|
||||
// The daemon writes to its own filesystem, which a remote
|
||||
// caller cannot see and so does not get to choose.
|
||||
download_directory: None,
|
||||
metadata: (&metadata).into(),
|
||||
},
|
||||
)
|
||||
.inspect_err(|_| self.mirror.forget_download(&username, &filename))?;
|
||||
|
||||
Ok((rebuild(started.download, sender, metadata), receiver))
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// caller's own channel spliced in.
|
||||
fn rebuild(
|
||||
dto: DownloadDto,
|
||||
sender: Sender<DownloadStatus>,
|
||||
metadata: DownloadMetadata,
|
||||
) -> Download {
|
||||
Download {
|
||||
username: dto.username,
|
||||
filename: dto.filename,
|
||||
token: dto.token,
|
||||
size: dto.size,
|
||||
download_directory: dto.download_directory,
|
||||
status: dto.status.into(),
|
||||
sender,
|
||||
queue_position: dto.queue_position,
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
fn network(message: impl Into<String>) -> SoulseekRs {
|
||||
SoulseekRs::InvalidMessage(message.into())
|
||||
}
|
||||
|
||||
type Halves = (Box<dyn std::io::Read + Send>, Box<dyn Write + Send>, Closer);
|
||||
|
||||
fn open(endpoint: &Endpoint) -> Result<Halves> {
|
||||
match endpoint {
|
||||
Endpoint::Tcp(address) => {
|
||||
let stream = TcpStream::connect(address).map_err(|e| {
|
||||
network(format!("cannot reach the daemon at {address}: {e}"))
|
||||
})?;
|
||||
let read = stream.try_clone().map_err(SoulseekRs::NetworkError)?;
|
||||
let closer =
|
||||
Closer::Tcp(stream.try_clone().map_err(SoulseekRs::NetworkError)?);
|
||||
Ok((Box::new(read), Box::new(stream), closer))
|
||||
}
|
||||
Endpoint::Socket(path) => connect_socket(path),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn connect_socket(path: &Path) -> Result<Halves> {
|
||||
let stream = std::os::unix::net::UnixStream::connect(path).map_err(|e| {
|
||||
network(format!("cannot reach the daemon at {}: {e}", path.display()))
|
||||
})?;
|
||||
let read = stream.try_clone().map_err(SoulseekRs::NetworkError)?;
|
||||
let closer =
|
||||
Closer::Unix(stream.try_clone().map_err(SoulseekRs::NetworkError)?);
|
||||
Ok((Box::new(read), Box::new(stream), closer))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn connect_socket(path: &Path) -> Result<Halves> {
|
||||
Err(network(format!(
|
||||
"this platform has no Unix sockets, so {} cannot be a daemon address; \
|
||||
use host:port",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
|
||||
impl Drop for RemoteSession {
|
||||
fn drop(&mut self) {
|
||||
// Tell the daemon we are gone, so it can release the connection and
|
||||
// stop fanning events at a client that is not there.
|
||||
self.closer.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Route everything the daemon sends: replies to the call waiting for them,
|
||||
/// events to the mirror.
|
||||
fn read_loop(
|
||||
read: Box<dyn std::io::Read + Send>,
|
||||
pending: &Pending,
|
||||
mirror: &Mirror,
|
||||
) {
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else {
|
||||
return;
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(response) = serde_json::from_str::<Response>(&line) else {
|
||||
soulseek_rs::warn!("ignoring an unreadable line from the daemon");
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(method) = response.method.as_deref() {
|
||||
if let Some(event) = Event::from_wire(method) {
|
||||
mirror.apply(event, response.params.unwrap_or_default());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(id) = response.id.as_ref().and_then(serde_json::Value::as_u64)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
match (response.result, response.error) {
|
||||
(Some(result), _) => pending.settle(id, Ok(result)),
|
||||
(None, Some(error)) => pending.settle(id, Err(error)),
|
||||
(None, None) => pending.settle(id, Ok(serde_json::Value::Null)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionApi for RemoteSession {
|
||||
fn username(&self) -> String {
|
||||
self.username.clone()
|
||||
}
|
||||
|
||||
fn listen_port(&self) -> Option<u16> {
|
||||
self.status().and_then(|status| status.listen_port)
|
||||
}
|
||||
|
||||
fn session_loss(&self) -> Option<SessionLoss> {
|
||||
// The pushed event is the prompt answer; the status call also covers a
|
||||
// loss that happened before this client attached.
|
||||
self.mirror.loss().or_else(|| {
|
||||
self.status()
|
||||
.and_then(|status| status.session_loss)
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
self.search_with_cancel(query, timeout, None)
|
||||
}
|
||||
|
||||
fn search_with_cancel(
|
||||
&self,
|
||||
query: &str,
|
||||
timeout: Duration,
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
self.tell(
|
||||
Method::SearchStart,
|
||||
QueryParams {
|
||||
query: query.to_string(),
|
||||
},
|
||||
)?;
|
||||
// The window is spent here rather than in the daemon: one client's
|
||||
// search must not stop the daemon answering everybody else.
|
||||
soulseek_rs::Client::collect_for(timeout, cancel);
|
||||
Ok(self.get_search_results(query))
|
||||
}
|
||||
|
||||
fn get_search_results(&self, key: &str) -> Vec<SearchResult> {
|
||||
self.request::<_, SearchResults>(
|
||||
Method::SearchResults,
|
||||
QueryParams {
|
||||
query: key.to_string(),
|
||||
},
|
||||
)
|
||||
.map(|found| found.results.into_iter().map(Into::into).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn get_search_results_count(&self, key: &str) -> usize {
|
||||
self.get_search_results(key).len()
|
||||
}
|
||||
|
||||
fn try_get_search_results(&self, key: &str) -> Option<Vec<SearchResult>> {
|
||||
Some(self.get_search_results(key))
|
||||
}
|
||||
|
||||
fn start_wishlist_search(&self, query: &str) -> Result<()> {
|
||||
self.tell(
|
||||
Method::SearchWishlist,
|
||||
QueryParams {
|
||||
query: query.to_string(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn wishlist_interval(&self) -> Duration {
|
||||
self.request::<_, IntervalSeconds>(Method::SearchWishlistInterval, ())
|
||||
.map_or(Duration::from_mins(12), |interval| {
|
||||
Duration::from_secs(interval.seconds)
|
||||
})
|
||||
}
|
||||
|
||||
fn download(
|
||||
&self,
|
||||
filename: String,
|
||||
username: String,
|
||||
size: u64,
|
||||
_download_directory: String,
|
||||
// the daemon's own download directory wins — a remote caller
|
||||
// cannot see the filesystem the bytes land on. Plumb a per-transfer
|
||||
// directory through if a client ever needs to sort into subfolders.
|
||||
) -> Result<(Download, Receiver<DownloadStatus>)> {
|
||||
self.start_download(
|
||||
filename,
|
||||
username,
|
||||
size,
|
||||
DownloadMetadata::default(),
|
||||
)
|
||||
}
|
||||
|
||||
fn download_with_metadata(
|
||||
&self,
|
||||
filename: String,
|
||||
username: String,
|
||||
size: u64,
|
||||
_download_directory: String,
|
||||
metadata: DownloadMetadata,
|
||||
) -> Result<(Download, Receiver<DownloadStatus>)> {
|
||||
self.start_download(filename, username, size, metadata)
|
||||
}
|
||||
|
||||
fn get_all_downloads(&self) -> Vec<Download> {
|
||||
let Ok(listed) = self.request::<_, Downloads>(Method::DownloadList, ())
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
listed
|
||||
.downloads
|
||||
.into_iter()
|
||||
.map(|dto| {
|
||||
// Nothing reads this sender: a listing covers other clients'
|
||||
// transfers too, and the ones this client started already have
|
||||
// a live channel from `download`.
|
||||
let (sender, _) = channel();
|
||||
let metadata = dto.metadata.clone().into();
|
||||
rebuild(dto, sender, metadata)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pause_download(&self, username: &str, filename: &str) -> bool {
|
||||
self.ask(Method::DownloadPause, Self::transfer(username, filename))
|
||||
}
|
||||
|
||||
fn resume_download(&self, username: &str, filename: &str) -> bool {
|
||||
self.ask(Method::DownloadResume, Self::transfer(username, filename))
|
||||
}
|
||||
|
||||
fn remove_queued_download(&self, username: &str, filename: &str) -> bool {
|
||||
self.ask(
|
||||
Method::DownloadRemoveQueued,
|
||||
Self::transfer(username, filename),
|
||||
)
|
||||
}
|
||||
|
||||
fn remove_download(&self, username: &str, filename: &str) -> bool {
|
||||
self.mirror.forget_download(username, filename);
|
||||
self.ask(Method::DownloadRemove, Self::transfer(username, filename))
|
||||
}
|
||||
|
||||
fn uploads(&self) -> Vec<UploadInfo> {
|
||||
self.request::<_, Uploads>(Method::UploadList, ())
|
||||
.map(|listed| listed.uploads.into_iter().map(Into::into).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn take_upload_events(&self) -> Vec<UploadInfo> {
|
||||
self.mirror.take_uploads()
|
||||
}
|
||||
|
||||
fn cancel_upload(&self, username: &str, filename: &str) -> bool {
|
||||
self.ask(Method::UploadCancel, Self::transfer(username, filename))
|
||||
}
|
||||
|
||||
fn set_upload_slots(&self, slots: usize) {
|
||||
let _ = self.tell(Method::UploadSlots, SlotsParams { slots });
|
||||
}
|
||||
|
||||
fn check_privileges(&self) -> Result<()> {
|
||||
self.tell(Method::PrivilegesCheck, ())
|
||||
}
|
||||
|
||||
fn own_privilege_seconds(&self) -> Option<u32> {
|
||||
self.request::<_, Seconds>(Method::PrivilegesOwn, ())
|
||||
.ok()?
|
||||
.seconds
|
||||
}
|
||||
|
||||
fn request_room_list(&self) -> Result<()> {
|
||||
self.tell(Method::RoomListRequest, ())
|
||||
}
|
||||
|
||||
fn join_room(&self, room: &str) -> Result<()> {
|
||||
self.tell(
|
||||
Method::RoomJoin,
|
||||
RoomRef {
|
||||
room: room.to_string(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn leave_room(&self, room: &str) -> Result<()> {
|
||||
self.tell(
|
||||
Method::RoomLeave,
|
||||
RoomRef {
|
||||
room: room.to_string(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn say_in_room(&self, room: &str, message: &str) -> Result<()> {
|
||||
self.tell(
|
||||
Method::RoomSay,
|
||||
SayParams {
|
||||
room: room.to_string(),
|
||||
message: message.to_string(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn room_members(&self, room: &str) -> Vec<String> {
|
||||
self.request::<_, Members>(
|
||||
Method::RoomMembers,
|
||||
RoomRef {
|
||||
room: room.to_string(),
|
||||
},
|
||||
)
|
||||
.map(|members| members.users)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn take_room_events(&self) -> Vec<RoomEvent> {
|
||||
self.mirror.take_rooms()
|
||||
}
|
||||
|
||||
fn send_private_message(
|
||||
&self,
|
||||
username: &str,
|
||||
message: &str,
|
||||
) -> Result<()> {
|
||||
self.tell(
|
||||
Method::MessageSend,
|
||||
MessageParams {
|
||||
username: username.to_string(),
|
||||
message: message.to_string(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn take_private_messages(&self) -> Vec<UserMessage> {
|
||||
self.mirror.take_messages()
|
||||
}
|
||||
|
||||
fn browse_user(&self, username: &str) -> Result<()> {
|
||||
self.tell(
|
||||
Method::BrowseUser,
|
||||
UserRef {
|
||||
username: username.to_string(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn take_browse_result(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> Option<Vec<SharedDirectory>> {
|
||||
self.mirror.take_browse(username)
|
||||
}
|
||||
|
||||
fn request_user_info(&self, username: &str) -> Result<()> {
|
||||
self.tell(
|
||||
Method::UserRequest,
|
||||
UserRef {
|
||||
username: username.to_string(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn user_info(&self, username: &str) -> Option<UserInfo> {
|
||||
self.request::<_, UserResult>(
|
||||
Method::UserInfoOf,
|
||||
UserRef {
|
||||
username: username.to_string(),
|
||||
},
|
||||
)
|
||||
.ok()?
|
||||
.user
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn shared_counts(&self) -> (u32, u32) {
|
||||
let shares = self.shares();
|
||||
(shares.folders, shares.files)
|
||||
}
|
||||
|
||||
fn shared_directories(&self) -> Vec<String> {
|
||||
self.shares().directories
|
||||
}
|
||||
|
||||
fn set_shared_directories(&self, directories: Vec<String>) -> Result<()> {
|
||||
self.tell(Method::SharesSet, DirectoriesParams { directories })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_endpoint_tells_a_socket_path_from_a_host_and_port() {
|
||||
assert_eq!(
|
||||
Endpoint::parse("/run/soulseek/daemon.sock"),
|
||||
Endpoint::Socket(PathBuf::from("/run/soulseek/daemon.sock"))
|
||||
);
|
||||
assert_eq!(
|
||||
Endpoint::parse("127.0.0.1:5030"),
|
||||
Endpoint::Tcp("127.0.0.1:5030".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
Endpoint::parse("daemon.example:5030"),
|
||||
Endpoint::Tcp("daemon.example:5030".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_socket_authenticates_itself() {
|
||||
assert!(Endpoint::parse("/tmp/daemon.sock").self_authenticating());
|
||||
assert!(!Endpoint::parse("host:5030").self_authenticating());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_settles_the_call_that_is_waiting_for_it() {
|
||||
let pending = Pending::default();
|
||||
let answer = pending.register(7);
|
||||
pending.settle(7, Ok(serde_json::json!({ "ok": true })));
|
||||
assert_eq!(
|
||||
answer.recv().unwrap().unwrap(),
|
||||
serde_json::json!({ "ok": true })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_for_an_abandoned_call_is_dropped_quietly() {
|
||||
let pending = Pending::default();
|
||||
let answer = pending.register(7);
|
||||
pending.abandon(7);
|
||||
pending.settle(7, Ok(serde_json::Value::Null));
|
||||
assert!(answer.recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_connection_fails_every_waiting_call() {
|
||||
// Otherwise each blocked caller would sit out the full timeout against
|
||||
// a socket nobody is reading.
|
||||
let pending = Pending::default();
|
||||
let first = pending.register(1);
|
||||
let second = pending.register(2);
|
||||
pending.fail_all();
|
||||
assert!(first.recv().unwrap().is_err());
|
||||
assert!(second.recv().unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reader_routes_replies_to_calls_and_events_to_the_mirror() {
|
||||
let pending = Pending::default();
|
||||
let mirror = Mirror::default();
|
||||
let answer = pending.register(1);
|
||||
|
||||
let lines = concat!(
|
||||
r#"{"jsonrpc":"2.0","method":"event.room","params":{"event":"left","room":"lobby"}}"#,
|
||||
"\n",
|
||||
r#"{"jsonrpc":"2.0","id":1,"result":{"ok":true}}"#,
|
||||
"\n",
|
||||
);
|
||||
read_loop(Box::new(lines.as_bytes()), &pending, &mirror);
|
||||
|
||||
assert_eq!(
|
||||
answer.recv().unwrap().unwrap(),
|
||||
serde_json::json!({ "ok": true })
|
||||
);
|
||||
assert_eq!(
|
||||
mirror.take_rooms(),
|
||||
[RoomEvent::Left {
|
||||
room: "lobby".into()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_error_reply_reaches_the_caller_as_an_error() {
|
||||
let pending = Pending::default();
|
||||
let mirror = Mirror::default();
|
||||
let answer = pending.register(1);
|
||||
|
||||
read_loop(
|
||||
Box::new(
|
||||
concat!(
|
||||
r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"nope","data":{"exit":4}}}"#,
|
||||
"\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
),
|
||||
&pending,
|
||||
&mirror,
|
||||
);
|
||||
|
||||
let error = answer.recv().unwrap().expect_err("an error reply");
|
||||
assert_eq!(error.message, "nope");
|
||||
assert_eq!(error.exit(), crate::output::Exit::NoResults);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_junk_line_does_not_stop_the_reader() {
|
||||
let pending = Pending::default();
|
||||
let mirror = Mirror::default();
|
||||
let answer = pending.register(1);
|
||||
|
||||
read_loop(
|
||||
Box::new(
|
||||
concat!(
|
||||
"not json at all\n",
|
||||
r#"{"jsonrpc":"2.0","id":1,"result":{"ok":true}}"#,
|
||||
"\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
),
|
||||
&pending,
|
||||
&mirror,
|
||||
);
|
||||
|
||||
assert!(
|
||||
answer.recv().unwrap().is_ok(),
|
||||
"one bad line must not cost the reply behind it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user