feat(cli)!: replace the one-shot commands with a scriptable surface
The one-shot half of the binary could not finish its headline task without a human at the keyboard: `search` opened a full-screen picker, panicked with exit 101 when there was no terminal, and no command could fetch a known file at all. Everything exited 0 whatever happened, wrote emoji prose to stdout, and ignored --download-dir, SOULSEEK_DOWNLOAD_DIR and config.toml. The commands are now built for scripts and agents: search download get browse room list|say|listen message send|read stdout carries records only (tab-separated, or NDJSON under --json), stderr carries progress, and the exit code carries the verdict: 2 usage, 3 connect, 4 no results, 5 timed out, 6 transfer failed. `get` does search, rank and fetch in one step, and `search --json | jq ... | download --stdin` composes. Global flags parse in any position and every one has a SOULSEEK_ variable and a config.toml key resolved through a single precedence chain. Defects found and fixed along the way, each covered by a test: - a download with an unknown size was finalized against zero bytes and saved as an empty file; the size now comes from the peer's share listing - transfers are keyed by a hash of the remote path, so the same path from two peers shadowed itself and two files sharing a basename overwrote each other; both collisions are now dropped with a warning - `message send` and `room say` slept 500ms and claimed success; they now wait for a server reply queued behind the message before reporting - Client::login waited on an unbounded channel and never returned when the server went quiet Deleted the TUI picker the old `search` drove (download_selector, MultiDownloadProgress, SearchConfig): 1,573 lines out, 926 in. The interactive TUI is otherwise untouched. BREAKING CHANGE: `rooms` and `chat` become `room list|say|listen` and `message send|read`; `--disable-listener` becomes `--no-listener`; the unprefixed DISABLE_LISTENER, LISTENER_PORT, LOG_FILE and MAX_CONCURRENT_DOWNLOADS variables are now SOULSEEK_-prefixed; `search` no longer opens a picker or downloads anything.
This commit is contained in:
@@ -61,43 +61,69 @@ impl Drop for PortMapper {
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of a port-mapping probe: a verdict a script can branch on
|
||||
/// plus the advice a human needs when it fails.
|
||||
pub struct Probe {
|
||||
pub ok: bool,
|
||||
/// `upnp`, `natpmp`, or `none`.
|
||||
pub backend: &'static str,
|
||||
/// External `address:port` the mapping exposes, when the backend reports
|
||||
/// one.
|
||||
pub external: Option<String>,
|
||||
pub advice: String,
|
||||
}
|
||||
|
||||
/// Synchronously probe whether automatic port mapping works on this network:
|
||||
/// try to map `port` (UPnP then NAT-PMP), report the result in human-readable
|
||||
/// form, then remove the probe mapping. Used by the `portmap` CLI command so a
|
||||
/// user can verify reachability on their actual router.
|
||||
/// try to map `port` (UPnP then NAT-PMP), then remove the probe mapping.
|
||||
/// Backs the `portmap` command so a user can verify reachability on their
|
||||
/// actual router.
|
||||
#[must_use]
|
||||
pub fn diagnose(port: u16) -> String {
|
||||
pub fn probe(port: u16) -> Probe {
|
||||
let Some(local_ip) = local_lan_ip() else {
|
||||
return "Could not determine this machine's LAN IP — are you connected \
|
||||
to a network?"
|
||||
.to_string();
|
||||
return Probe {
|
||||
ok: false,
|
||||
backend: "none",
|
||||
external: None,
|
||||
advice: "Could not determine this machine's LAN IP — are you \
|
||||
connected to a network?"
|
||||
.to_string(),
|
||||
};
|
||||
};
|
||||
|
||||
match map_once(local_ip, port) {
|
||||
Some((backend, _lease)) => {
|
||||
let detail = match &backend {
|
||||
Backend::Upnp { gateway, .. } => {
|
||||
let ip = gateway.get_external_ip().map_or_else(
|
||||
|_| "unknown".to_string(),
|
||||
|ip| ip.to_string(),
|
||||
);
|
||||
format!("UPnP-IGD (external address {ip}:{port})")
|
||||
}
|
||||
Backend::NatPmp { .. } => "NAT-PMP".to_string(),
|
||||
let (name, external) = match &backend {
|
||||
Backend::Upnp { gateway, .. } => (
|
||||
"upnp",
|
||||
gateway
|
||||
.get_external_ip()
|
||||
.ok()
|
||||
.map(|ip| format!("{ip}:{port}")),
|
||||
),
|
||||
Backend::NatPmp { .. } => ("natpmp", None),
|
||||
};
|
||||
remove(&backend); // clean up the probe mapping
|
||||
format!(
|
||||
"✅ Automatic port mapping works via {detail}.\n TCP {port} \
|
||||
was opened on your router, so firewalled peers and the server \
|
||||
can reach you. The app does this automatically while it runs."
|
||||
)
|
||||
Probe {
|
||||
ok: true,
|
||||
backend: name,
|
||||
external,
|
||||
advice: format!(
|
||||
"Automatic port mapping works. TCP {port} was opened on \
|
||||
your router, so firewalled peers and the server can \
|
||||
reach you. The app does this automatically while it runs."
|
||||
),
|
||||
}
|
||||
}
|
||||
None => format!(
|
||||
"❌ No UPnP/NAT-PMP router responded for TCP {port}.\n Automatic \
|
||||
mapping won't work on this network. To browse/download firewalled \
|
||||
peers, forward TCP {port} to this machine on your router (or \
|
||||
enable UPnP there)."
|
||||
),
|
||||
None => Probe {
|
||||
ok: false,
|
||||
backend: "none",
|
||||
external: None,
|
||||
advice: format!(
|
||||
"No UPnP/NAT-PMP router responded for TCP {port}. To \
|
||||
browse/download firewalled peers, forward TCP {port} to this \
|
||||
machine on your router (or enable UPnP there)."
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user