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:
Michel de Graaf
2026-07-27 10:33:41 +02:00
parent f54f8f9b82
commit a0354f66a8
13 changed files with 1622 additions and 28 deletions
+86
View File
@@ -0,0 +1,86 @@
//! Controlling the service: `daemon token`, `daemon status`, `daemon stop`.
//!
//! Running the daemon itself lives in [`crate::daemon`]; these are the
//! one-shot commands that talk *to* one. None of them needs a Soulseek
//! account — the daemon already has one — so they are dispatched before
//! credentials are demanded.
use crate::daemon::proto::{DaemonStatus, Method};
use crate::output::{CliError, CliResult, DaemonStatusRecord, Out, TokenRecord};
use crate::remote::{Endpoint, RemoteSession};
/// Print the token a client on another machine needs.
///
/// Local clients never need this — the socket's permissions already answer the
/// question — so it exists for the moment you point a second machine at this
/// one.
pub fn token(out: &Out) -> CliResult {
let path = crate::daemon::token_path().ok_or_else(|| {
CliError::usage("no state directory: set SOULSEEK_STATE_DIR")
})?;
let token = crate::daemon::load_or_create_token(&path).map_err(|e| {
CliError::usage(format!("cannot read {}: {e}", path.display()))
})?;
out.emit(&TokenRecord { token });
Ok(())
}
/// Ask a running daemon what it is doing.
pub fn status(
out: &Out,
endpoint: Option<&Endpoint>,
token: Option<String>,
) -> CliResult {
let status: DaemonStatus = ask(endpoint, token, Method::DaemonStatus)?;
out.emit(&DaemonStatusRecord {
user: status.username,
server: status.server,
version: status.daemon_version,
protocol: status.protocol,
listening: status.listen_port.is_some(),
listen_port: status.listen_port.unwrap_or(0),
shared_folders: status.shared_folders,
shared_files: status.shared_files,
download_dir: status.download_dir,
session_loss: status
.session_loss
.map(|loss| format!("{loss:?}").to_lowercase()),
clients: status.clients,
uptime_secs: status.uptime_secs,
});
Ok(())
}
/// Ask a running daemon to shut down.
pub fn stop(
out: &Out,
endpoint: Option<&Endpoint>,
token: Option<String>,
) -> CliResult {
let _: serde_json::Value = ask(endpoint, token, Method::DaemonStop)?;
out.status("the daemon is stopping");
Ok(())
}
/// One request to whichever daemon this run is pointed at.
fn ask<T: serde::de::DeserializeOwned>(
endpoint: Option<&Endpoint>,
token: Option<String>,
method: Method,
) -> CliResult<T> {
let endpoint = endpoint.ok_or_else(no_daemon)?;
let token = token.or_else(crate::daemon::stored_token);
let session = RemoteSession::connect(endpoint, token).map_err(|e| {
CliError::connection(format!("cannot reach the daemon: {e}"))
})?;
session
.ask_for(method)
.map_err(|e| CliError::connection(e.to_string()))
}
fn no_daemon() -> CliError {
CliError::connection(
"no daemon is running; start one with `soulseek-rs daemon`, or point \
at a remote one with --daemon",
)
}
+43
View File
@@ -6,6 +6,7 @@
//! dispatched by `main::run` before any credentials are demanded.
pub mod completions;
pub mod daemon;
pub mod peer;
pub mod settings;
pub mod skills;
@@ -16,6 +17,7 @@ pub mod wish;
use crate::cli::{Commands, MessageCommand, RoomCommand};
use crate::output::{CliError, CliResult, Exit, Out, PortmapRecord};
use crate::port_mapping::{self, PortMapper};
use crate::remote::{Endpoint, RemoteSession};
use soulseek_rs::types::{RoomEvent, RoomInfo};
use soulseek_rs::{Client, ClientSettings, SessionLoss};
use crate::api::SessionApi;
@@ -30,6 +32,11 @@ pub struct Ctx {
pub download_dir: String,
pub max_concurrent_downloads: usize,
pub search_timeout: Duration,
/// Where to find a daemon, when this run should use one rather than open
/// its own session. Resolved once in `main`, so every command agrees.
pub daemon: Option<Endpoint>,
/// Token for a remote daemon; a Unix socket needs none.
pub daemon_token: Option<String>,
/// The client of the session this command opened, once it has one, so the
/// verdict can be checked against the session that produced it.
pub session: OnceLock<Arc<dyn SessionApi>>,
@@ -86,7 +93,42 @@ const REACH_TIMEOUT: Duration = Duration::from_secs(10);
const LOGIN_TIMEOUT: Duration = Duration::from_secs(30);
impl Session {
/// Get a session: the daemon's, if this run is routed to one, or a fresh
/// login of its own.
pub fn open(ctx: &Ctx) -> CliResult<Self> {
match &ctx.daemon {
Some(endpoint) => Self::attach(ctx, endpoint),
None => Self::login(ctx),
}
}
/// Borrow the daemon's session instead of logging in again.
///
/// The server allows one login per account, so a second login would
/// displace the daemon — attaching is not just cheaper, it is the only way
/// two things can be on the network as the same user at once.
fn attach(ctx: &Ctx, endpoint: &Endpoint) -> CliResult<Self> {
ctx.out.status(&format!("using the daemon at {endpoint}"));
let token = ctx
.daemon_token
.clone()
.or_else(crate::daemon::stored_token);
let remote = RemoteSession::connect(endpoint, token).map_err(|e| {
CliError::connection(format!("cannot use the daemon: {e}"))
})?;
let client: Arc<dyn SessionApi> = Arc::new(remote);
ctx.out
.status(&format!("logged in as {}", client.username()));
let _ = ctx.session.set(Arc::clone(&client));
Ok(Self {
client,
// The daemon holds the listener, so it owns the port mapping too.
_mapper: None,
})
}
fn login(ctx: &Ctx) -> CliResult<Self> {
let address = ctx.settings.server_address.to_string();
ctx.out.status(&format!("connecting to {address}…"));
reachable(&address, REACH_TIMEOUT)?;
@@ -259,6 +301,7 @@ fn dispatch(ctx: &Ctx, command: Commands) -> CliResult {
// These need the config file as well as a session, so main.rs runs
// them where the store is in scope.
Commands::Serve(_)
| Commands::Daemon(_)
| Commands::Wish(_)
| Commands::Config(_)
| Commands::Shares(_)
+4 -1
View File
@@ -24,7 +24,10 @@ pub fn whoami(ctx: &Ctx) -> CliResult {
// reachable needs what is true, not what was asked for.
let bound = session.client.listen_port();
ctx.out.emit(&WhoamiRecord {
user: ctx.settings.username.clone(),
// The session's own name, not this run's configuration: routed to a
// daemon they are different, and the session is the one that is
// actually on the network.
user: session.client.username(),
server: ctx.settings.server_address.to_string(),
listening: bound.is_some(),
listen_port: bound.unwrap_or(0),
+9 -3
View File
@@ -96,7 +96,9 @@ pub fn shares_status(ctx: &Ctx) -> CliResult {
ctx.out.emit(&ShareStatusRecord {
folders,
files,
directories: ctx.settings.shared_directories.clone(),
// What the session shares, which locally is what this run configured
// and against a daemon is what the daemon configured.
directories: session.client.shared_directories(),
});
Ok(())
}
@@ -106,16 +108,20 @@ pub fn shares_status(ctx: &Ctx) -> CliResult {
pub fn shares_reindex(ctx: &Ctx) -> CliResult {
let session = Session::open(ctx)?;
ctx.out.status("re-indexing the shared folders…");
// Re-scan what the session already shares rather than imposing this run's
// configuration on it: against a daemon that would silently replace the
// daemon's shares with the client machine's.
let directories = session.client.shared_directories();
session
.client
.set_shared_directories(ctx.settings.shared_directories.clone())
.set_shared_directories(directories.clone())
.map_err(|e| CliError::usage(format!("cannot re-index: {e}")))?;
let (folders, files) = session.client.shared_counts();
ctx.out.emit(&ShareStatusRecord {
folders,
files,
directories: ctx.settings.shared_directories.clone(),
directories,
});
Ok(())
}
+13 -4
View File
@@ -240,7 +240,7 @@ pub fn download(ctx: &Ctx, args: &DownloadArgs) -> CliResult {
return Err(CliError::no_results("no files to download"));
}
ensure_download_dir(&ctx.download_dir)?;
ensure_download_dir(ctx)?;
let session = Session::open(ctx)?;
let requests = resolve_sizes(ctx, session.client.as_ref(), requests)?;
download_all(
@@ -252,7 +252,7 @@ pub fn download(ctx: &Ctx, args: &DownloadArgs) -> CliResult {
}
pub fn get(ctx: &Ctx, args: &GetArgs) -> CliResult {
ensure_download_dir(&ctx.download_dir)?;
ensure_download_dir(ctx)?;
let session = Session::open(ctx)?;
let results =
collect(ctx, &session.client, &args.query, args.pick == Pick::First)?;
@@ -421,8 +421,17 @@ fn resolve_sizes(
Ok(resolved)
}
fn ensure_download_dir(download_dir: &str) -> CliResult {
let path = expand_tilde(download_dir);
/// Make sure the bytes have somewhere to land.
///
/// A daemon writes to its own filesystem and has already done this; creating
/// the same path here would leave a stray directory on the client machine and,
/// for a daemon on another host, could fail outright over a path that is none
/// of this machine's business.
fn ensure_download_dir(ctx: &Ctx) -> CliResult {
if ctx.daemon.is_some() {
return Ok(());
}
let path = expand_tilde(&ctx.download_dir);
std::fs::create_dir_all(&path).map_err(|e| {
CliError::usage(format!(
"cannot use download directory {}: {e}",