feat(config): remember which daemon to use, and document the download box

A remote daemon could only be named with --daemon and --daemon-token on every
invocation, which made the one workflow daemon mode is most useful for — a
headless download box driven from a laptop — a matter of retyping a 64-character
token. Both are now config keys, so it is two 'config set' commands once and
plain commands thereafter.

Fixes a bug that setup exposed: the up-front daemon status probe read the token
from the command line only, so with the token in the config file it failed
silently and every command that names a path or a server fell back to this
machine's defaults — whoami claiming downloads land in the laptop's home
directory when they land on the box. The regression test was checked by
reintroducing the bug.

Documents the download-box workflow in plain terms across the README, the
website and the agent skill, and says plainly that the schema is open: OpenRPC,
served by the daemon itself, so an Android, iOS or web remote is something
anyone can build without asking.
This commit is contained in:
Michel de Graaf
2026-07-27 14:11:51 +02:00
parent 3f914c4425
commit 2a906df1b5
6 changed files with 279 additions and 29 deletions
+64 -11
View File
@@ -165,23 +165,76 @@ file *and* update a running daemon, so a folder is served straight away rather
than at its next start. Files land on the *daemon's* filesystem — `daemon
status` reports where.
For a client on another machine, bind a TCP port as well and give it the
token:
#### A download box you drive from your laptop
This is what daemon mode is really for. Put soulseek-rs on the machine that
should be doing the downloading — a home server, a NAS, a Raspberry Pi, a
seedbox — and drive it from wherever you happen to be.
**On that machine**, run the daemon and let it accept connections from your
network:
```bash
soulseek-rs daemon --bind 0.0.0.0:5030
soulseek-rs daemon token # the secret that connection needs
soulseek-rs daemon token # copy this, you need it once
```
There is no transport encryption, so put SSH or a TLS proxy in front of
anything non-loopback.
It needs no terminal and no one logged in; hand it to systemd or launchd and
forget about it.
Third-party clients are first-class: the daemon speaks newline-delimited
JSON-RPC 2.0 described by an [OpenRPC](https://open-rpc.org) document
([`docs/openrpc.json`](docs/openrpc.json), also served by `rpc.discover`), so
a generator produces a client in any language.
[`docs/daemon-protocol.md`](docs/daemon-protocol.md) covers the framing, the
handshake, and how pushed events behave.
**On your laptop**, say once where it lives:
```bash
soulseek-rs config set daemon nas.local:5030
soulseek-rs config set daemon_token <the token you copied>
```
That is the whole setup. Every command from now on is the normal command —
your laptop needs no Soulseek account of its own, because it is using the
server's:
```bash
soulseek-rs search 'aphex twin' --json
soulseek-rs get 'selected ambient works'
soulseek-rs daemon status # what it's doing right now
```
Then close the lid. **The downloads keep going**, because they belong to the
daemon, not to the command that asked for them or to the laptop that ran it.
Come back tomorrow, run `soulseek-rs daemon status` again, and pick up where
you left off. The files are on the download box, which is where you wanted
them.
When you are finished with it:
```bash
soulseek-rs daemon stop # shuts down the remote daemon
```
One caution: there is no encryption on that TCP port. On a home network that
is usually fine. Over the internet, don't expose it — reach it through SSH
instead, either with a tunnel (`ssh -L 5030:localhost:5030 nas`) or by running
the commands over `ssh` on the box itself.
#### Build your own remote
The daemon's interface is open and documented, so nothing about it is specific
to this CLI. It speaks newline-delimited JSON-RPC 2.0 over a socket, described
by an [OpenRPC](https://open-rpc.org) document —
[`docs/openrpc.json`](docs/openrpc.json), which the daemon also serves itself
via `rpc.discover`, so a client can ask a running daemon what it can do.
OpenRPC is the JSON-RPC counterpart to OpenAPI: point a client generator at
that document and you get typed bindings in whatever language you are working
in. If you want an Android or iOS app that queues downloads on your home
server from the sofa, or a small web page that shows what is transferring, you
have everything you need and no permission to ask for. Searches, transfers,
rooms, private messages and shares are all on the same interface this CLI
uses — there is no private back channel it keeps for itself, and live updates
are pushed to every connected client rather than polled for.
[`docs/daemon-protocol.md`](docs/daemon-protocol.md) covers the parts a schema
cannot state: the framing, the handshake, and how pushed events behave.
### Scripting
+7
View File
@@ -165,6 +165,13 @@ accepts connections from other machines, which need the token.
**`daemon token`** — the secret a client on another machine needs. Local
clients never need it.
The daemon can also be on a *different* machine — a home server or NAS doing
the downloading. Point at it once with `config set daemon host:5030` and
`config set daemon_token <token>`, and every command afterwards drives that
machine; files land there, not here. The interface is an open JSON-RPC schema
(`rpc.discover` serves it), so a purpose-built client is a reasonable thing to
write.
**`shares list`** — one object per configured folder: `directory`, `usable`.
**`shares add <directory>`** and **`shares remove <directory>`** — change the
config file; they print nothing. With a daemon running they also update it in
+25
View File
@@ -23,6 +23,14 @@ pub struct FileConfig {
/// Command whose stdout is the password (headless fallback, like mutt's
/// `password_cmd`). Never store the password itself in the file.
pub password_cmd: Option<String>,
/// A daemon to control instead of opening a session of our own —
/// `host:port`, or a Unix socket path. Unset means "use the local one if
/// there is one", which is what a daemon on this machine wants.
pub daemon: Option<String>,
/// The token for a remote daemon. Not a secret about *this* account, and
/// useless without reaching the host, so unlike a password it is
/// reasonable to keep here rather than behind a command.
pub daemon_token: Option<String>,
/// Standing searches the server lets us repeat once per wishlist interval.
/// A TOML array rather than a `config set` key, because a query may itself
/// contain a comma.
@@ -43,6 +51,8 @@ impl FileConfig {
"max_concurrent_downloads",
"search_timeout",
"password_cmd",
"daemon",
"daemon_token",
];
/// The value of `key`, or `None` when it is unset or unknown. Lists are
@@ -64,6 +74,8 @@ impl FileConfig {
}
"search_timeout" => self.search_timeout.map(|v| v.to_string()),
"password_cmd" => self.password_cmd.clone(),
"daemon" => self.daemon.clone(),
"daemon_token" => self.daemon_token.clone(),
_ => None,
}
}
@@ -88,6 +100,8 @@ impl FileConfig {
"server" => text(&mut self.server),
"download_dir" => text(&mut self.download_dir),
"password_cmd" => text(&mut self.password_cmd),
"daemon" => text(&mut self.daemon),
"daemon_token" => text(&mut self.daemon_token),
"listener_port" => {
self.listener_port = parse_opt(value, clear, key)?;
}
@@ -242,6 +256,8 @@ pub struct Resolved {
pub max_concurrent_downloads: usize,
pub search_timeout: u64,
pub password_cmd: Option<String>,
pub daemon: Option<String>,
pub daemon_token: Option<String>,
}
pub const DEFAULT_SERVER: &str = "server.slsknet.org:2416";
@@ -297,6 +313,11 @@ pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved {
.password_cmd
.clone()
.or_else(|| file.password_cmd.clone()),
daemon: cli.daemon.clone().or_else(|| file.daemon.clone()),
daemon_token: cli
.daemon_token
.clone()
.or_else(|| file.daemon_token.clone()),
}
}
@@ -488,6 +509,8 @@ mod tests {
max_concurrent_downloads: Some(2),
search_timeout: Some(30),
password_cmd: Some("pass show slsk".into()),
daemon: Some("nas.local:5030".into()),
daemon_token: Some("deadbeef".into()),
wishlist: None,
};
let resolved = resolve(&bare_cli(), &file);
@@ -497,6 +520,8 @@ mod tests {
assert!(resolved.disable_listener);
assert_eq!(resolved.download_dir, "/music");
assert_eq!(resolved.shared_dirs, vec!["/shared".to_string()]);
assert_eq!(resolved.daemon.as_deref(), Some("nas.local:5030"));
assert_eq!(resolved.daemon_token.as_deref(), Some("deadbeef"));
assert_eq!(resolved.max_concurrent_downloads, 2);
assert_eq!(resolved.search_timeout, 30);
assert_eq!(resolved.password_cmd.as_deref(), Some("pass show slsk"));
+21 -13
View File
@@ -37,7 +37,7 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
// A running daemon already holds a session; the TUI attaches to it
// rather than logging in a second time, which the server would answer
// by cutting the daemon off.
if let Some(endpoint) = daemon_endpoint(&cli) {
if let Some(endpoint) = daemon_endpoint(&cli, &resolved) {
return run_attached_tui(&cli, &resolved, &endpoint);
}
return run_default_tui(&cli, &resolved, config_path, &file_config);
@@ -87,7 +87,7 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
out,
&store,
directory,
&live_daemon(&cli),
&live_daemon(&cli, &resolved),
);
}
Commands::Shares(SharesCommand::Remove { ref directory }) => {
@@ -95,7 +95,7 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
out,
&store,
directory,
&live_daemon(&cli),
&live_daemon(&cli, &resolved),
);
}
Commands::Wish(WishCommand::Add { ref query }) => {
@@ -114,8 +114,8 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
command: Some(ref command),
..
}) => {
let endpoint = daemon_endpoint(&cli);
let token = cli.daemon_token.clone();
let endpoint = daemon_endpoint(&cli, &resolved);
let token = resolved.daemon_token;
return match command {
DaemonCommand::Token => crate::commands::daemon::token(out),
DaemonCommand::Status => crate::commands::daemon::status(
@@ -182,7 +182,7 @@ fn context(
resolved: &crate::persist::config::Resolved,
out: &Out,
) -> CliResult<Ctx> {
let daemon = daemon_endpoint(cli);
let daemon = daemon_endpoint(cli, resolved);
// A run routed to a daemon needs no account of its own: the daemon is
// already logged in, and asking for credentials it will not use would
// stop a script that never had any.
@@ -213,7 +213,7 @@ fn context(
// honest — `whoami` naming the right server, `download` naming the file
// where the bytes actually landed.
let described = daemon.as_ref().and_then(|endpoint| {
describe_daemon(endpoint, cli.daemon_token.clone())
describe_daemon(endpoint, resolved.daemon_token.clone())
});
let server_address = described.as_ref().map_or_else(
|| PeerAddress::new(host, port),
@@ -244,16 +244,19 @@ fn context(
max_concurrent_downloads: resolved.max_concurrent_downloads,
search_timeout: Duration::from_secs(resolved.search_timeout),
daemon,
daemon_token: cli.daemon_token.clone(),
daemon_token: resolved.daemon_token.clone(),
session: std::sync::OnceLock::new(),
})
}
/// The daemon a config change should also reach, if one is running.
fn live_daemon(cli: &Cli) -> crate::commands::settings::LiveDaemon {
fn live_daemon(
cli: &Cli,
resolved: &crate::persist::config::Resolved,
) -> crate::commands::settings::LiveDaemon {
crate::commands::settings::LiveDaemon {
endpoint: daemon_endpoint(cli),
token: cli.daemon_token.clone(),
endpoint: daemon_endpoint(cli, resolved),
token: resolved.daemon_token.clone(),
}
}
@@ -278,11 +281,16 @@ fn describe_daemon(
/// ssh-agent set, and what lets an agent run a command with no flags at all.
/// With nothing listening, the run opens its own session exactly as it always
/// has.
fn daemon_endpoint(cli: &Cli) -> Option<crate::remote::Endpoint> {
fn daemon_endpoint(
cli: &Cli,
resolved: &crate::persist::config::Resolved,
) -> Option<crate::remote::Endpoint> {
if cli.no_daemon {
return None;
}
if let Some(address) = &cli.daemon {
// Flag, then environment, then config file — `resolved` has already
// layered those — and otherwise a daemon on this machine, if one is up.
if let Some(address) = &resolved.daemon {
return Some(crate::remote::Endpoint::parse(address));
}
crate::daemon::local_daemon().map(crate::remote::Endpoint::Socket)
+73
View File
@@ -70,6 +70,8 @@ impl Daemon {
"pw",
"--listener-port",
&free_port()?.to_string(),
"--download-dir",
state.join("downloads").to_str()?,
"daemon",
"--bind",
&format!("127.0.0.1:{port}"),
@@ -492,3 +494,74 @@ fn scratch(label: &str) -> PathBuf {
std::fs::create_dir_all(&path).expect("scratch directory");
path
}
/// A client configured for a remote daemon reports the *daemon's* world.
///
/// This is the download-box workflow: the token lives in the client's config
/// file rather than on its command line, and it has no Soulseek account of its
/// own. Reading the token from the flag alone made the status probe fail
/// silently, and every command that names a path or a server then quietly fell
/// back to this machine's defaults — `whoami` claiming downloads land in the
/// laptop's home directory when they land on the box.
#[test]
fn a_config_driven_remote_client_reports_the_daemons_world() {
let daemon = daemon_or_skip!();
let config = scratch("remote-client-config");
let state = scratch("remote-client-state");
// A laptop that knows only where the daemon is. No username, no password,
// no server — exactly what the documented setup produces.
std::fs::write(
config.join("config.toml"),
format!(
"daemon = \"127.0.0.1:{}\"\ndaemon_token = \"{}\"\n",
daemon.port,
daemon.token()
),
)
.expect("config file");
let mut command = Command::new(BIN);
for name in CLI_ENV_VARS {
command.env_remove(name);
}
command.env("SOULSEEK_CONFIG_DIR", &config);
command.env("SOULSEEK_STATE_DIR", &state);
command.current_dir(std::env::temp_dir());
command.args(["--json", "whoami"]);
let output = command.output().expect("the binary should run");
assert!(
output.status.success(),
"a client configured for a remote daemon should just work: {}",
String::from_utf8_lossy(&output.stderr)
);
let record: serde_json::Value = serde_json::from_str(
String::from_utf8_lossy(&output.stdout)
.lines()
.find(|line| !line.trim().is_empty())
.expect("one record"),
)
.expect("valid JSON");
assert_eq!(
record["user"], "cli_e2e_proto",
"the daemon's account, not this machine's"
);
assert!(
record["server"]
.as_str()
.is_some_and(|server| server.starts_with("127.0.0.1:")),
"the server the daemon is connected to, not a local default: {record}"
);
assert!(
record["download_dir"].as_str().is_some_and(
|dir| dir.starts_with(std::env::temp_dir().to_str().unwrap())
),
"the directory the bytes actually land in, on the daemon's host, not \
this machine's Downloads folder: {record}"
);
let _ = std::fs::remove_dir_all(&config);
let _ = std::fs::remove_dir_all(&state);
}
+89 -5
View File
@@ -1,6 +1,7 @@
import { Link } from 'react-router'
import { Button } from '@/components/ui/button'
import { ExtLink } from '@/components/ui/ext-link'
import { Code } from '@/components/ui/inline-code'
import {
Cols,
@@ -257,18 +258,99 @@ const Daemon = () => (
rescan every invocation, and downloads that outlive the command that queued
them. If you drive this from a script or an agent, start one.
</p>
</Prose>
</Cols>
</Section>
)
const DownloadBox = () => (
<Section>
<SectionHead
eyebrow="6 · a download box"
title="Put it on the machine that should be downloading."
>
A home server, a NAS, a Raspberry Pi. It needs no terminal and nobody logged in.
You drive it from your laptop, and it keeps working after you close the lid.
</SectionHead>
<Cols start>
<Terminal
label="on the box"
lines={[
{ t: 'cmd', text: 'soulseek-rs daemon --bind 0.0.0.0:5030' },
{ t: 'cmd', text: 'soulseek-rs daemon token' },
{ t: 'cm', text: '# copy this once' },
{ t: 'cm', text: '' },
{ t: 'cm', text: '# on your laptop, one-time setup:' },
{ t: 'cmd', text: 'soulseek-rs config set daemon nas.local:5030' },
{ t: 'cmd', text: 'soulseek-rs config set daemon_token <token>' },
{ t: 'cm', text: '' },
{ t: 'cm', text: '# then just use it, no flags:' },
{ t: 'cmd', text: 'soulseek-rs get "selected ambient works"' },
{ t: 'cmd', text: 'soulseek-rs daemon status' },
{ t: 'cmd', text: 'soulseek-rs daemon stop' },
{ t: 'cm', text: '# shuts down the remote one when you are done' },
]}
/>
<Prose>
<p>
It speaks newline-delimited JSON-RPC 2.0 described by an{' '}
<Code>openrpc.json</Code> the daemon serves itself, so a client generator gives
you one in any language. <Code>daemon --bind</Code> adds a TCP port for other
machines; those need the token from <Code>daemon token</Code>, while the local
socket never does.
Your laptop needs no Soulseek account of its own &mdash; it is using the box&rsquo;s.
Queue what you want, then close the lid: the downloads keep going, because they
belong to the daemon rather than to the command that asked for them. Come back
tomorrow, run <Code>daemon status</Code>, and pick up where you left off. The
files are on the box, which is where you wanted them.
</p>
<p>
There is no encryption on that port. On a home network that is usually fine;
over the internet, reach it through SSH instead &mdash;{' '}
<Code>ssh -L 5030:localhost:5030 nas</Code>, or just run the commands on the box.
</p>
</Prose>
</Cols>
</Section>
)
const OpenSchema = () => (
<Section>
<SectionHead eyebrow="7 · build your own" title="The interface is open.">
Nothing about the daemon is specific to this CLI. Point a client generator at its
schema and write your own remote.
</SectionHead>
<Cols start>
<Prose>
<p>
It speaks newline-delimited JSON-RPC 2.0, described by an{' '}
<ExtLink href="https://open-rpc.org">OpenRPC</ExtLink> document. The daemon
serves that document itself through <Code>rpc.discover</Code>, so a client can
ask a running daemon what it can do.
</p>
<p>
OpenRPC is the JSON-RPC counterpart to OpenAPI: point a generator at it and you
get typed bindings in whatever language you are working in. An Android or iOS app
that queues downloads on your home server from the sofa, a small web page showing
what is transferring &mdash; you have everything you need and nobody to ask.
</p>
<p>
Searches, transfers, rooms, private messages and shares are all on the same
interface this CLI uses. There is no private back channel it keeps for itself,
and live updates are pushed to every connected client rather than polled for.
</p>
</Prose>
<Terminal
label="any language"
lines={[
{ t: 'cmd', text: 'soulseek-rs daemon --bind 0.0.0.0:5030' },
{ t: 'cm', text: '# then, from your own client:' },
{ t: 'out', text: '{"jsonrpc":"2.0","id":1,"method":"auth",' },
{ t: 'out', text: ' "params":{"protocol":1,"token":"..."}}' },
{ t: 'out', text: '{"jsonrpc":"2.0","id":2,"method":"search.start",' },
{ t: 'out', text: ' "params":{"query":"aphex twin"}}' },
{ t: 'cm', text: '# events arrive unasked: transfers, chat, uploads' },
]}
/>
</Cols>
</Section>
)
const TheRest = () => (
<Section band>
<Panel title="the rest" className="bg-raised [&>span]:bg-panel">
@@ -305,6 +387,8 @@ export const Docs = () => (
<Keys />
<FromTheShell />
<Daemon />
<DownloadBox />
<OpenSchema />
<TheRest />
</>
)