import { Button } from '@/components/ui/button' import { FeatureCard } from '@/components/ui/feature-card' import { ExtLink } from '@/components/ui/ext-link' import { Code } from '@/components/ui/inline-code' import { Cols, PageHead, Prose, Section, SectionHead } from '@/components/ui/layout' import { Callout } from '@/components/ui/panel' import { Terminal, type TermLine } from '@/components/ui/terminal' import { INSTALL_CMD, LINKS, NIGHTLY_INSTALL_CMD, SITE_URL } from '@/lib/links' import { InstallCards, LinuxRoutes } from '@/pages/install-routes' const INSTALL_SCRIPT_LINES: readonly TermLine[] = [ { t: 'cm', text: '# latest stable release' }, { t: 'cmd', text: INSTALL_CMD }, { t: 'cm', text: '# latest successful build from develop' }, { t: 'cmd', text: NIGHTLY_INSTALL_CMD }, ] const LIB_SRC = `use soulseek_rs::Client; use std::time::Duration; fn main() -> Result<(), Box> { let mut client = Client::new("username", "password"); client.connect()?; client.login()?; let results = client.search("public domain field recordings", Duration::from_secs(10))?; if let Some(r) = results.iter().find(|r| !r.files.is_empty()) { let f = &r.files[0]; client.download(f.name.clone(), f.username.clone(), f.size, "~/Downloads".to_string())?; } Ok(()) }` const LIB_LINES: TermLine[] = LIB_SRC.split('\n').map((text) => ({ t: 'code' as const, text, })) interface PlatformNote { id: string name: string credentialStore: string config: string state: string downloads: string note: string } const PLATFORMS: readonly PlatformNote[] = [ { id: 'macos', name: 'macOS', credentialStore: 'Apple Keychain', config: '~/.config/soulseek-rs/config.toml', state: '~/.local/share/soulseek-rs/state', downloads: '~/Downloads/Soulseek', note: 'Follows the XDG layout rather than Application Support.', }, { id: 'linux', name: 'Linux', credentialStore: 'Secret Service (pure-Rust backend, no libdbus needed)', config: '~/.config/soulseek-rs/config.toml', state: '~/.local/share/soulseek-rs/state', downloads: '~/Downloads/Soulseek', note: 'Honours XDG_CONFIG_HOME and XDG_DATA_HOME when they are set.', }, { id: 'windows', name: 'Windows', credentialStore: 'Windows Credential Manager', config: '%APPDATA%\\soulseek-rs\\config\\config.toml', state: '%APPDATA%\\soulseek-rs\\data\\state', downloads: '%USERPROFILE%\\Downloads\\Soulseek', note: 'Uses the standard known-folder locations for roaming app data. Without Unix sockets, a daemon here needs --bind 127.0.0.1:5030, and clients reach it with config set daemon plus config set daemon_token; that token file carries no permissions of its own.', }, ] interface Setting { key: string fallback: string body: React.ReactNode } const SETTINGS: readonly Setting[] = [ { key: 'username', fallback: 'unset', body: <>Your Soulseek account name. The TUI writes it here after a first login., }, { key: 'server', fallback: 'server.slsknet.org:2416', body: <>The server to log in to, as host:port., }, { key: 'listener_port', fallback: '2234', body: ( <> The TCP port peers connect back on. Forward it, or let portmap ask your router. ), }, { key: 'disable_listener', fallback: 'false', body: ( <> true refuses incoming peer connections. You can still download from reachable peers, but firewalled ones can no longer reach you. ), }, { key: 'download_dir', fallback: 'the Downloads path above', body: <>Where finished transfers land., }, { key: 'shared_dirs', fallback: 'the download folder', body: ( <> Folders offered to the network. An empty list shares nothing.{' '} shares add writes this; the older shared_dir spelling still reads. ), }, { key: 'use_username_dirs', fallback: 'false', body: <>Save each peer's files in a subfolder named after them., }, { key: 'max_concurrent_downloads', fallback: '20', body: <>Transfers allowed to run at once. The rest queue., }, { key: 'search_timeout', fallback: '10', body: ( <> Seconds a search collects replies before reporting. Results trickle in from separate peers, so a short window finds less. ), }, { key: 'password_cmd', fallback: 'unset', body: ( <> A shell command whose stdout is the password, used when the keychain has none. The password itself never belongs in this file. ), }, { key: 'daemon', fallback: 'the local socket, if one is running', body: ( <> A daemon to drive instead of logging in here, as host:port or a Unix socket path. ), }, { key: 'daemon_token', fallback: 'unset', body: ( <> The secret a remote daemon asks for, printed by daemon token on the machine running it. A local socket needs none. ), }, ] const CONFIG_EXAMPLE: TermLine[] = [ 'username = "your-name"', 'download_dir = "~/Music/Soulseek"', 'shared_dirs = ["~/Music/Soulseek", "~/Music/Library"]', 'max_concurrent_downloads = 3', 'search_timeout = 15', 'listener_port = 2234', '', '# never store the password itself; shell out for it instead', 'password_cmd = "pass show soulseek"', ].map((text) => ({ t: 'code' as const, text })) interface StepProps { n: number title: string children: React.ReactNode } const Step = ({ n, title, children }: StepProps) => (
{n}

{title}

{children}
) const Steps = () => (

One command on macOS and Linux. The script installs the latest stable release through Homebrew when you have it, or downloads the prebuilt binary, checks its sha256, and puts it on your PATH. Pass --nightly for the current successful build from develop; nightly always downloads directly.

No curl? {`wget -qO- ${SITE_URL}install.sh | sh`} does the same. Piping to a shell asks for trust; read the script first at{' '} {`${SITE_URL}install.sh`}.

Nightly is rebuilt every day and can be replaced by a manual build. It may be less stable than a numbered release. The installer stages and runs the verified download before replacing an existing copy, so a bad build leaves that copy in place.

Or pick a route per platform. Cargo needs Rust 1.91 or newer from{' '} rustup.rs. Nix and Homebrew{' '} --HEAD also build from source, but bring their own Rust.

Every route puts the same binary on your PATH. Run soulseek-rs in a terminal and the TUI opens, in a script or a pipe it wants a subcommand instead.

soulseek-rs completions install adds tab completion for whichever of bash, zsh and fish this machine uses, generated from the same definition the binary parses with, so it never drifts from --help. Open a new shell to pick it up.

Clone the workspace and build a release binary.

The binary lands at target/release/soulseek-rs. Prebuilt archives for tagged releases are on the{' '} releases page.

Write your own client on soulseek-rs-lib: the protocol implementation, separate from the TUI.

Full API reference on docs.rs. The lib API changes between majors, so check the{' '} changelog when you upgrade.

) const Reachable = () => (
Browsing and downloading are peer-to-peer, so at least one side has to accept an incoming connection. serve refuses to start with the listener off.

With the listener on (the default), soulseek-rs tries to open its listen port on your router via{' '} UPnP-IGD and{' '} NAT-PMP. It’s best-effort: if your router has those disabled it’s a no-op, and you forward the port yourself.

The port is your --listener-port (env{' '} SOULSEEK_LISTENER_PORT, default 2234); soulseek-rs renews it while it runs and removes it on exit. Pass{' '} --no-listener to turn it off. Check your own network without launching the client:

If both you and a peer are behind routers with no forwarded port, browsing that peer can’t work, that’s a fundamental Soulseek/peer-to-peer limitation, not a bug. Forward --listener-port, or let{' '} portmap try UPnP.

) const Platforms = () => (
Sensible defaults per OS, all overridable. Set SOULSEEK_CONFIG_DIR or{' '} SOULSEEK_STATE_DIR to relocate either one, which also makes a portable install possible.
{PLATFORMS.map((platform) => (

{platform.name}

{( [ ['Password', platform.credentialStore, false], ['Config', platform.config, true], ['State', platform.state, true], ['Downloads', platform.downloads, true], ] as const ).map(([label, value, mono]) => (
{label}
{mono ? ( {value} ) : ( value )}
))}

{platform.note}

))}
Your password goes to the OS keychain, never a plain-text file. Point{' '} password_cmd at your own password manager and it is never stored at all. For CI and containers,{' '} --password-stdin reads it off a pipe so it never reaches{' '} ps or the environment. config.toml is layered: flags beat environment variables, which beat the file, which beats the defaults. State (searches, downloads, rooms) is versioned JSON and restores on restart; soulseek-rs sets anything unreadable aside as .bak rather than overwriting it. Attached to a daemon, that state is the daemon’s, and the state folder also holds its{' '} daemon.sock and daemon.token, both 0600 on macOS and Linux.
) const Config = () => (
Every one has a working default, so an empty file, or no file at all, is valid.{' '} config list prints the effective value of each.
{SETTINGS.map((setting) => (
{setting.key} {setting.fallback}

{setting.body}

))}

config path prints the file in use, config get{' '} reads one setting and config set writes one. It rejects unknown keys, so a typo fails instead of doing nothing.{' '} --no-config ignores the file entirely and{' '} --config FILE points at another one. wishlist is in the file too, written by wish add rather than{' '} config set, since a standing search may itself contain a comma.

) // aislop-ignore-file code-quality/duplicate-block -- the two uninstall `lines` arrays repeat the `{ t, text }` shape around different commands. The rule matches punctuation, not logic. const Uninstall = () => (
Three things exist on disk: the binary, the config, and the state, plus the completion scripts and the agent skill if you asked for those. A .deb or .rpm also brings a man page and its own completions, and takes them back when removed. Nothing else, unless you point{' '} --log-file somewhere. No telemetry to opt out of. Windows Credentials' }, ]} />

Your downloads and your shared directories are yours: they stay where they are. On macOS, delete the keychain entry for soulseek-rs in Keychain Access if you saved a password.

) export const Install = () => ( <> One command with the install script, your package manager, Homebrew or cargo. Build from source if you’d rather. Or depend on the library and write your own client. )