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:
@@ -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
|
than at its next start. Files land on the *daemon's* filesystem — `daemon
|
||||||
status` reports where.
|
status` reports where.
|
||||||
|
|
||||||
For a client on another machine, bind a TCP port as well and give it the
|
#### A download box you drive from your laptop
|
||||||
token:
|
|
||||||
|
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
|
```bash
|
||||||
soulseek-rs daemon --bind 0.0.0.0:5030
|
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
|
It needs no terminal and no one logged in; hand it to systemd or launchd and
|
||||||
anything non-loopback.
|
forget about it.
|
||||||
|
|
||||||
Third-party clients are first-class: the daemon speaks newline-delimited
|
**On your laptop**, say once where it lives:
|
||||||
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
|
```bash
|
||||||
a generator produces a client in any language.
|
soulseek-rs config set daemon nas.local:5030
|
||||||
[`docs/daemon-protocol.md`](docs/daemon-protocol.md) covers the framing, the
|
soulseek-rs config set daemon_token <the token you copied>
|
||||||
handshake, and how pushed events behave.
|
```
|
||||||
|
|
||||||
|
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
|
### Scripting
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,13 @@ accepts connections from other machines, which need the token.
|
|||||||
**`daemon token`** — the secret a client on another machine needs. Local
|
**`daemon token`** — the secret a client on another machine needs. Local
|
||||||
clients never need it.
|
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 list`** — one object per configured folder: `directory`, `usable`.
|
||||||
**`shares add <directory>`** and **`shares remove <directory>`** — change the
|
**`shares add <directory>`** and **`shares remove <directory>`** — change the
|
||||||
config file; they print nothing. With a daemon running they also update it in
|
config file; they print nothing. With a daemon running they also update it in
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ pub struct FileConfig {
|
|||||||
/// Command whose stdout is the password (headless fallback, like mutt's
|
/// Command whose stdout is the password (headless fallback, like mutt's
|
||||||
/// `password_cmd`). Never store the password itself in the file.
|
/// `password_cmd`). Never store the password itself in the file.
|
||||||
pub password_cmd: Option<String>,
|
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.
|
/// 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
|
/// A TOML array rather than a `config set` key, because a query may itself
|
||||||
/// contain a comma.
|
/// contain a comma.
|
||||||
@@ -43,6 +51,8 @@ impl FileConfig {
|
|||||||
"max_concurrent_downloads",
|
"max_concurrent_downloads",
|
||||||
"search_timeout",
|
"search_timeout",
|
||||||
"password_cmd",
|
"password_cmd",
|
||||||
|
"daemon",
|
||||||
|
"daemon_token",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The value of `key`, or `None` when it is unset or unknown. Lists are
|
/// 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()),
|
"search_timeout" => self.search_timeout.map(|v| v.to_string()),
|
||||||
"password_cmd" => self.password_cmd.clone(),
|
"password_cmd" => self.password_cmd.clone(),
|
||||||
|
"daemon" => self.daemon.clone(),
|
||||||
|
"daemon_token" => self.daemon_token.clone(),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,6 +100,8 @@ impl FileConfig {
|
|||||||
"server" => text(&mut self.server),
|
"server" => text(&mut self.server),
|
||||||
"download_dir" => text(&mut self.download_dir),
|
"download_dir" => text(&mut self.download_dir),
|
||||||
"password_cmd" => text(&mut self.password_cmd),
|
"password_cmd" => text(&mut self.password_cmd),
|
||||||
|
"daemon" => text(&mut self.daemon),
|
||||||
|
"daemon_token" => text(&mut self.daemon_token),
|
||||||
"listener_port" => {
|
"listener_port" => {
|
||||||
self.listener_port = parse_opt(value, clear, key)?;
|
self.listener_port = parse_opt(value, clear, key)?;
|
||||||
}
|
}
|
||||||
@@ -242,6 +256,8 @@ pub struct Resolved {
|
|||||||
pub max_concurrent_downloads: usize,
|
pub max_concurrent_downloads: usize,
|
||||||
pub search_timeout: u64,
|
pub search_timeout: u64,
|
||||||
pub password_cmd: Option<String>,
|
pub password_cmd: Option<String>,
|
||||||
|
pub daemon: Option<String>,
|
||||||
|
pub daemon_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const DEFAULT_SERVER: &str = "server.slsknet.org:2416";
|
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
|
.password_cmd
|
||||||
.clone()
|
.clone()
|
||||||
.or_else(|| file.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),
|
max_concurrent_downloads: Some(2),
|
||||||
search_timeout: Some(30),
|
search_timeout: Some(30),
|
||||||
password_cmd: Some("pass show slsk".into()),
|
password_cmd: Some("pass show slsk".into()),
|
||||||
|
daemon: Some("nas.local:5030".into()),
|
||||||
|
daemon_token: Some("deadbeef".into()),
|
||||||
wishlist: None,
|
wishlist: None,
|
||||||
};
|
};
|
||||||
let resolved = resolve(&bare_cli(), &file);
|
let resolved = resolve(&bare_cli(), &file);
|
||||||
@@ -497,6 +520,8 @@ mod tests {
|
|||||||
assert!(resolved.disable_listener);
|
assert!(resolved.disable_listener);
|
||||||
assert_eq!(resolved.download_dir, "/music");
|
assert_eq!(resolved.download_dir, "/music");
|
||||||
assert_eq!(resolved.shared_dirs, vec!["/shared".to_string()]);
|
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.max_concurrent_downloads, 2);
|
||||||
assert_eq!(resolved.search_timeout, 30);
|
assert_eq!(resolved.search_timeout, 30);
|
||||||
assert_eq!(resolved.password_cmd.as_deref(), Some("pass show slsk"));
|
assert_eq!(resolved.password_cmd.as_deref(), Some("pass show slsk"));
|
||||||
|
|||||||
+21
-13
@@ -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
|
// A running daemon already holds a session; the TUI attaches to it
|
||||||
// rather than logging in a second time, which the server would answer
|
// rather than logging in a second time, which the server would answer
|
||||||
// by cutting the daemon off.
|
// 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_attached_tui(&cli, &resolved, &endpoint);
|
||||||
}
|
}
|
||||||
return run_default_tui(&cli, &resolved, config_path, &file_config);
|
return run_default_tui(&cli, &resolved, config_path, &file_config);
|
||||||
@@ -87,7 +87,7 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
|||||||
out,
|
out,
|
||||||
&store,
|
&store,
|
||||||
directory,
|
directory,
|
||||||
&live_daemon(&cli),
|
&live_daemon(&cli, &resolved),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Commands::Shares(SharesCommand::Remove { ref directory }) => {
|
Commands::Shares(SharesCommand::Remove { ref directory }) => {
|
||||||
@@ -95,7 +95,7 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
|||||||
out,
|
out,
|
||||||
&store,
|
&store,
|
||||||
directory,
|
directory,
|
||||||
&live_daemon(&cli),
|
&live_daemon(&cli, &resolved),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Commands::Wish(WishCommand::Add { ref query }) => {
|
Commands::Wish(WishCommand::Add { ref query }) => {
|
||||||
@@ -114,8 +114,8 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
|||||||
command: Some(ref command),
|
command: Some(ref command),
|
||||||
..
|
..
|
||||||
}) => {
|
}) => {
|
||||||
let endpoint = daemon_endpoint(&cli);
|
let endpoint = daemon_endpoint(&cli, &resolved);
|
||||||
let token = cli.daemon_token.clone();
|
let token = resolved.daemon_token;
|
||||||
return match command {
|
return match command {
|
||||||
DaemonCommand::Token => crate::commands::daemon::token(out),
|
DaemonCommand::Token => crate::commands::daemon::token(out),
|
||||||
DaemonCommand::Status => crate::commands::daemon::status(
|
DaemonCommand::Status => crate::commands::daemon::status(
|
||||||
@@ -182,7 +182,7 @@ fn context(
|
|||||||
resolved: &crate::persist::config::Resolved,
|
resolved: &crate::persist::config::Resolved,
|
||||||
out: &Out,
|
out: &Out,
|
||||||
) -> CliResult<Ctx> {
|
) -> 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
|
// 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
|
// already logged in, and asking for credentials it will not use would
|
||||||
// stop a script that never had any.
|
// stop a script that never had any.
|
||||||
@@ -213,7 +213,7 @@ fn context(
|
|||||||
// honest — `whoami` naming the right server, `download` naming the file
|
// honest — `whoami` naming the right server, `download` naming the file
|
||||||
// where the bytes actually landed.
|
// where the bytes actually landed.
|
||||||
let described = daemon.as_ref().and_then(|endpoint| {
|
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(
|
let server_address = described.as_ref().map_or_else(
|
||||||
|| PeerAddress::new(host, port),
|
|| PeerAddress::new(host, port),
|
||||||
@@ -244,16 +244,19 @@ fn context(
|
|||||||
max_concurrent_downloads: resolved.max_concurrent_downloads,
|
max_concurrent_downloads: resolved.max_concurrent_downloads,
|
||||||
search_timeout: Duration::from_secs(resolved.search_timeout),
|
search_timeout: Duration::from_secs(resolved.search_timeout),
|
||||||
daemon,
|
daemon,
|
||||||
daemon_token: cli.daemon_token.clone(),
|
daemon_token: resolved.daemon_token.clone(),
|
||||||
session: std::sync::OnceLock::new(),
|
session: std::sync::OnceLock::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The daemon a config change should also reach, if one is running.
|
/// 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 {
|
crate::commands::settings::LiveDaemon {
|
||||||
endpoint: daemon_endpoint(cli),
|
endpoint: daemon_endpoint(cli, resolved),
|
||||||
token: cli.daemon_token.clone(),
|
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.
|
/// 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
|
/// With nothing listening, the run opens its own session exactly as it always
|
||||||
/// has.
|
/// 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 {
|
if cli.no_daemon {
|
||||||
return None;
|
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));
|
return Some(crate::remote::Endpoint::parse(address));
|
||||||
}
|
}
|
||||||
crate::daemon::local_daemon().map(crate::remote::Endpoint::Socket)
|
crate::daemon::local_daemon().map(crate::remote::Endpoint::Socket)
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ impl Daemon {
|
|||||||
"pw",
|
"pw",
|
||||||
"--listener-port",
|
"--listener-port",
|
||||||
&free_port()?.to_string(),
|
&free_port()?.to_string(),
|
||||||
|
"--download-dir",
|
||||||
|
state.join("downloads").to_str()?,
|
||||||
"daemon",
|
"daemon",
|
||||||
"--bind",
|
"--bind",
|
||||||
&format!("127.0.0.1:{port}"),
|
&format!("127.0.0.1:{port}"),
|
||||||
@@ -492,3 +494,74 @@ fn scratch(label: &str) -> PathBuf {
|
|||||||
std::fs::create_dir_all(&path).expect("scratch directory");
|
std::fs::create_dir_all(&path).expect("scratch directory");
|
||||||
path
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link } from 'react-router'
|
import { Link } from 'react-router'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ExtLink } from '@/components/ui/ext-link'
|
||||||
import { Code } from '@/components/ui/inline-code'
|
import { Code } from '@/components/ui/inline-code'
|
||||||
import {
|
import {
|
||||||
Cols,
|
Cols,
|
||||||
@@ -257,18 +258,99 @@ const Daemon = () => (
|
|||||||
rescan every invocation, and downloads that outlive the command that queued
|
rescan every invocation, and downloads that outlive the command that queued
|
||||||
them. If you drive this from a script or an agent, start one.
|
them. If you drive this from a script or an agent, start one.
|
||||||
</p>
|
</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>
|
<p>
|
||||||
It speaks newline-delimited JSON-RPC 2.0 described by an{' '}
|
Your laptop needs no Soulseek account of its own — it is using the box’s.
|
||||||
<Code>openrpc.json</Code> the daemon serves itself, so a client generator gives
|
Queue what you want, then close the lid: the downloads keep going, because they
|
||||||
you one in any language. <Code>daemon --bind</Code> adds a TCP port for other
|
belong to the daemon rather than to the command that asked for them. Come back
|
||||||
machines; those need the token from <Code>daemon token</Code>, while the local
|
tomorrow, run <Code>daemon status</Code>, and pick up where you left off. The
|
||||||
socket never does.
|
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 —{' '}
|
||||||
|
<Code>ssh -L 5030:localhost:5030 nas</Code>, or just run the commands on the box.
|
||||||
</p>
|
</p>
|
||||||
</Prose>
|
</Prose>
|
||||||
</Cols>
|
</Cols>
|
||||||
</Section>
|
</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 — 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 = () => (
|
const TheRest = () => (
|
||||||
<Section band>
|
<Section band>
|
||||||
<Panel title="the rest" className="bg-raised [&>span]:bg-panel">
|
<Panel title="the rest" className="bg-raised [&>span]:bg-panel">
|
||||||
@@ -305,6 +387,8 @@ export const Docs = () => (
|
|||||||
<Keys />
|
<Keys />
|
||||||
<FromTheShell />
|
<FromTheShell />
|
||||||
<Daemon />
|
<Daemon />
|
||||||
|
<DownloadBox />
|
||||||
|
<OpenSchema />
|
||||||
<TheRest />
|
<TheRest />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user