diff --git a/README.md b/README.md
index 7f98272..0f84666 100644
--- a/README.md
+++ b/README.md
@@ -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
+```
+
+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
diff --git a/soulseek-rs/src/SKILL.md b/soulseek-rs/src/SKILL.md
index 350a797..e6edbde 100644
--- a/soulseek-rs/src/SKILL.md
+++ b/soulseek-rs/src/SKILL.md
@@ -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 `, 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 `** and **`shares remove `** — change the
config file; they print nothing. With a daemon running they also update it in
diff --git a/soulseek-rs/src/persist/config.rs b/soulseek-rs/src/persist/config.rs
index f0c6d13..3781647 100644
--- a/soulseek-rs/src/persist/config.rs
+++ b/soulseek-rs/src/persist/config.rs
@@ -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,
+ /// 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,
+ /// 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,
/// 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,
+ pub daemon: Option,
+ pub daemon_token: Option,
}
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"));
diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs
index 7a47c13..05fec99 100644
--- a/soulseek-rs/src/run.rs
+++ b/soulseek-rs/src/run.rs
@@ -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 {
- 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 {
+fn daemon_endpoint(
+ cli: &Cli,
+ resolved: &crate::persist::config::Resolved,
+) -> Option {
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)
diff --git a/soulseek-rs/tests/daemon_protocol_e2e.rs b/soulseek-rs/tests/daemon_protocol_e2e.rs
index 06ea7f1..2ac33ba 100644
--- a/soulseek-rs/tests/daemon_protocol_e2e.rs
+++ b/soulseek-rs/tests/daemon_protocol_e2e.rs
@@ -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);
+}
diff --git a/website/src/pages/docs.tsx b/website/src/pages/docs.tsx
index 3c6c1c1..bada849 100644
--- a/website/src/pages/docs.tsx
+++ b/website/src/pages/docs.tsx
@@ -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.
+
+
+
+)
+
+const DownloadBox = () => (
+
+
+ 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.
+
+
+ ' },
+ { 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' },
+ ]}
+ />
+
- It speaks newline-delimited JSON-RPC 2.0 described by an{' '}
- openrpc.json the daemon serves itself, so a client generator gives
- you one in any language. daemon --bind adds a TCP port for other
- machines; those need the token from daemon token, while the local
- socket never does.
+ Your laptop needs no Soulseek account of its own — it is using the box’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 daemon status, and pick up where you left off. The
+ files are on the box, which is where you wanted them.
+
+
+ There is no encryption on that port. On a home network that is usually fine;
+ over the internet, reach it through SSH instead —{' '}
+ ssh -L 5030:localhost:5030 nas, or just run the commands on the box.
)
+const OpenSchema = () => (
+
+
+ Nothing about the daemon is specific to this CLI. Point a client generator at its
+ schema and write your own remote.
+
+
+
+
+ It speaks newline-delimited JSON-RPC 2.0, described by an{' '}
+ OpenRPC document. The daemon
+ serves that document itself through rpc.discover, so a client can
+ ask a running daemon what it can do.
+
+
+ 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 — you have everything you need and nobody to ask.
+
+
+ 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.
+
+
+
+
+
+)
+
const TheRest = () => (
@@ -305,6 +387,8 @@ export const Docs = () => (
+
+
>
)