518 lines
20 KiB
TypeScript
518 lines
20 KiB
TypeScript
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<dyn std::error::Error>> {
|
|
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 <code>portmap</code> ask
|
|
your router.
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
key: 'disable_listener',
|
|
fallback: 'false',
|
|
body: (
|
|
<>
|
|
<code>true</code> 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.{' '}
|
|
<code>shares add</code> writes this; the older <code>shared_dir</code> spelling
|
|
still reads.
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
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 <code>host:port</code> or a Unix
|
|
socket path.
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
key: 'daemon_token',
|
|
fallback: 'unset',
|
|
body: (
|
|
<>
|
|
The secret a remote daemon asks for, printed by <code>daemon token</code> 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) => (
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[auto_1fr] sm:gap-5">
|
|
<div className="flex size-[34px] items-center justify-center rounded-md border border-line text-sm text-accent-text">
|
|
{n}
|
|
</div>
|
|
<div className="flex min-w-0 flex-col gap-3">
|
|
<h2 className="text-heading leading-[var(--text-heading--line-height)] font-medium">
|
|
{title}
|
|
</h2>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
const Steps = () => (
|
|
<Section>
|
|
<div className="flex flex-col gap-9">
|
|
<Step n={1} title="Install the client">
|
|
<p className="text-secondary">
|
|
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 <Code>--nightly</Code> for the current
|
|
successful build from <Code>develop</Code>; nightly always downloads directly.
|
|
</p>
|
|
<Terminal lines={INSTALL_SCRIPT_LINES} wrap />
|
|
<p className="text-[13px] text-muted">
|
|
No curl? <Code>{`wget -qO- ${SITE_URL}install.sh | sh`}</Code> does the same.
|
|
Piping to a shell asks for trust; read the script first at{' '}
|
|
<ExtLink href={`${SITE_URL}install.sh`}>{`${SITE_URL}install.sh`}</ExtLink>.
|
|
</p>
|
|
<p className="text-[13px] text-muted">
|
|
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.
|
|
</p>
|
|
<p className="text-secondary">
|
|
Or pick a route per platform. Cargo needs Rust 1.91 or newer from{' '}
|
|
<ExtLink href={LINKS.rustup}>rustup.rs</ExtLink>. Nix and Homebrew{' '}
|
|
<Code>--HEAD</Code> also build from source, but bring their own Rust.
|
|
</p>
|
|
<InstallCards />
|
|
<LinuxRoutes />
|
|
<p className="text-[13px] text-muted">
|
|
Every route puts the same binary on your PATH. Run <Code>soulseek-rs</Code> in
|
|
a terminal and the TUI opens, in a script or a pipe it wants a subcommand
|
|
instead.
|
|
</p>
|
|
<p className="text-[13px] text-muted">
|
|
<Code>soulseek-rs completions install</Code> 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 <Code>--help</Code>. Open a new shell
|
|
to pick it up.
|
|
</p>
|
|
<div className="flex flex-wrap gap-2 sm:gap-3">
|
|
<Button href={LINKS.releases}>Prebuilt binaries</Button>
|
|
<Button href={LINKS.nightlyRelease}>Nightly binaries</Button>
|
|
</div>
|
|
</Step>
|
|
|
|
<Step n={2} title="Build from source">
|
|
<p className="text-secondary">
|
|
Clone the workspace and build a release binary.
|
|
</p>
|
|
<Terminal
|
|
lines={[
|
|
{ t: 'cmd', text: 'git clone https://github.com/michel/soulseek-rs.git' },
|
|
{ t: 'cmd', text: 'cd soulseek-rs' },
|
|
{ t: 'cmd', text: 'cargo build --release' },
|
|
]}
|
|
/>
|
|
<p className="text-[13px] text-muted">
|
|
The binary lands at <Code>target/release/soulseek-rs</Code>. Prebuilt archives for
|
|
tagged releases are on the{' '}
|
|
<ExtLink href={LINKS.releases}>releases page</ExtLink>.
|
|
</p>
|
|
</Step>
|
|
|
|
<Step n={3} title="Build on the library">
|
|
<p className="text-secondary">
|
|
Write your own client on <Code>soulseek-rs-lib</Code>: the protocol
|
|
implementation, separate from the TUI.
|
|
</p>
|
|
<Terminal lines={[{ t: 'cmd', text: 'cargo add soulseek-rs-lib' }]} />
|
|
<Terminal label="src/main.rs" lines={LIB_LINES} copy={LIB_SRC} />
|
|
<p className="text-[13px] text-muted">
|
|
Full API reference on <ExtLink href={LINKS.docsrs}>docs.rs</ExtLink>. The lib API
|
|
changes between majors, so check the{' '}
|
|
<ExtLink href={LINKS.changelog}>changelog</ExtLink> when you upgrade.
|
|
</p>
|
|
</Step>
|
|
</div>
|
|
</Section>
|
|
)
|
|
const Reachable = () => (
|
|
<Section>
|
|
<SectionHead eyebrow="being reachable" title="Let peers connect back.">
|
|
Browsing and downloading are peer-to-peer, so at least one side has to accept an
|
|
incoming connection. <Code>serve</Code> refuses to start with the listener off.
|
|
</SectionHead>
|
|
<Cols start>
|
|
<Prose>
|
|
<p>
|
|
With the listener on (the default), soulseek-rs tries to open its listen port
|
|
on your router via{' '}
|
|
<b className="font-medium text-primary">UPnP-IGD</b> and{' '}
|
|
<b className="font-medium text-primary">NAT-PMP</b>. It’s best-effort:
|
|
if your router has those disabled it’s a no-op, and you forward the port
|
|
yourself.
|
|
</p>
|
|
<p>
|
|
The port is your <Code>--listener-port</Code> (env{' '}
|
|
<Code>SOULSEEK_LISTENER_PORT</Code>, default <Code>2234</Code>); soulseek-rs
|
|
renews it while it runs and removes it on exit. Pass{' '}
|
|
<Code>--no-listener</Code> to turn it off. Check your own network without
|
|
launching the client:
|
|
</p>
|
|
<div className="mt-4">
|
|
<Terminal
|
|
lines={[
|
|
{ t: 'cmd', text: 'soulseek-rs portmap' },
|
|
{ t: 'cm', text: "# exits 0 when it works, 4 when it doesn't" },
|
|
]}
|
|
/>
|
|
</div>
|
|
</Prose>
|
|
<Callout tone="warn" title="honest about limits">
|
|
<p>
|
|
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 <Code>--listener-port</Code>, or let{' '}
|
|
<Code>portmap</Code> try UPnP.
|
|
</p>
|
|
</Callout>
|
|
</Cols>
|
|
</Section>
|
|
)
|
|
const Platforms = () => (
|
|
<Section id="platforms">
|
|
<SectionHead eyebrow="platform notes" title="Where things live.">
|
|
Sensible defaults per OS, all overridable. Set <Code>SOULSEEK_CONFIG_DIR</Code> or{' '}
|
|
<Code>SOULSEEK_STATE_DIR</Code> to relocate either one, which also makes a
|
|
portable install possible.
|
|
</SectionHead>
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
|
{PLATFORMS.map((platform) => (
|
|
<div
|
|
key={platform.id}
|
|
id={`${platform.id}-paths`}
|
|
className="scroll-mt-20 rounded-md border border-hairline bg-panel p-[18px] sm:p-[22px]"
|
|
>
|
|
<h3 className="mb-4 text-heading leading-[var(--text-heading--line-height)] font-medium">
|
|
{platform.name}
|
|
</h3>
|
|
<dl className="grid grid-cols-1 gap-x-3.5 gap-y-0.5 sm:grid-cols-[auto_1fr] sm:gap-y-2 lg:grid-cols-1 lg:gap-y-0.5">
|
|
{(
|
|
[
|
|
['Password', platform.credentialStore, false],
|
|
['Config', platform.config, true],
|
|
['State', platform.state, true],
|
|
['Downloads', platform.downloads, true],
|
|
] as const
|
|
).map(([label, value, mono]) => (
|
|
<div key={label} className="contents">
|
|
<dt className="pt-2 text-[11px] uppercase tracking-[var(--tracking-label)] text-secondary sm:pt-0.5 lg:pt-2">
|
|
{label}
|
|
</dt>
|
|
<dd className="min-w-0 text-[12.5px] leading-[19px] text-primary">
|
|
{mono ? (
|
|
<code className="font-mono text-xs break-all">{value}</code>
|
|
) : (
|
|
value
|
|
)}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
<p className="mt-4 border-t border-hairline pt-3.5 text-[12.5px] leading-5 text-secondary">
|
|
{platform.note}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<Cols start className="mt-7">
|
|
<FeatureCard title="Secrets">
|
|
Your password goes to the OS keychain, never a plain-text file. Point{' '}
|
|
<code>password_cmd</code> at your own password manager and it is never stored
|
|
at all. For CI and containers,{' '}
|
|
<code>--password-stdin</code> reads it off a pipe so it never reaches{' '}
|
|
<code>ps</code> or the environment.
|
|
</FeatureCard>
|
|
<FeatureCard title="Settings and state">
|
|
<code>config.toml</code> 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 <code>.bak</code> rather than overwriting it. Attached to a daemon, that
|
|
state is the daemon’s, and the state folder also holds its{' '}
|
|
<code>daemon.sock</code> and <code>daemon.token</code>, both 0600 on macOS and
|
|
Linux.
|
|
</FeatureCard>
|
|
</Cols>
|
|
</Section>
|
|
)
|
|
const Config = () => (
|
|
<Section id="config">
|
|
<SectionHead eyebrow="config.toml" title="Eleven settings, all optional.">
|
|
Every one has a working default, so an empty file, or no file at all, is valid.{' '}
|
|
<Code>config list</Code> prints the effective value of each.
|
|
</SectionHead>
|
|
<Cols start>
|
|
<div className="flex flex-col">
|
|
{SETTINGS.map((setting) => (
|
|
<div
|
|
key={setting.key}
|
|
className="grid grid-cols-1 gap-x-4 border-t border-hairline py-3.5 first:border-t-0 first:pt-0 sm:grid-cols-[minmax(0,15rem)_1fr]"
|
|
>
|
|
<div className="flex min-w-0 flex-col gap-1">
|
|
<code className="font-mono text-[12.5px] break-all text-primary">
|
|
{setting.key}
|
|
</code>
|
|
<span className="text-[11px] uppercase tracking-[var(--tracking-label)] text-secondary">
|
|
{setting.fallback}
|
|
</span>
|
|
</div>
|
|
<p className="mt-1.5 text-[13px] leading-[21px] text-pretty text-secondary sm:mt-0 [&_code]:font-mono [&_code]:text-primary">
|
|
{setting.body}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex flex-col gap-4">
|
|
<Terminal label="config.toml" lines={CONFIG_EXAMPLE} />
|
|
<Callout title="editing it without an editor">
|
|
<p>
|
|
<Code>config path</Code> prints the file in use, <Code>config get</Code>{' '}
|
|
reads one setting and <Code>config set</Code> writes one. It rejects
|
|
unknown keys, so a typo fails instead of doing nothing.{' '}
|
|
<Code>--no-config</Code> ignores the file entirely and{' '}
|
|
<Code>--config FILE</Code> points at another one. <Code>wishlist</Code> is in
|
|
the file too, written by <Code>wish add</Code> rather than{' '}
|
|
<Code>config set</Code>, since a standing search may itself contain a comma.
|
|
</p>
|
|
</Callout>
|
|
</div>
|
|
</Cols>
|
|
</Section>
|
|
)
|
|
// 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 = () => (
|
|
<Section band>
|
|
<SectionHead eyebrow="uninstall" title="Removing it completely.">
|
|
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{' '}
|
|
<Code>--log-file</Code> somewhere. No telemetry to opt out of.
|
|
</SectionHead>
|
|
<Cols start>
|
|
<Terminal
|
|
label="macOS · Linux"
|
|
lines={[
|
|
{ t: 'cm', text: '# while the binary is still there' },
|
|
{ t: 'cmd', text: 'soulseek-rs completions uninstall' },
|
|
{ t: 'cmd', text: 'soulseek-rs skills uninstall' },
|
|
{ t: 'cm', text: '# whichever way you installed it' },
|
|
{ t: 'cmd', text: 'brew uninstall soulseek-rs' },
|
|
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
|
{ t: 'cmd', text: 'sudo apt remove soulseek-rs' },
|
|
{ t: 'cmd', text: 'sudo dnf remove soulseek-rs' },
|
|
{ t: 'cm', text: '# script install without brew: the binary itself' },
|
|
{ t: 'cmd', text: 'rm -f /usr/local/bin/soulseek-rs ~/.local/bin/soulseek-rs' },
|
|
{ t: 'cm', text: '# brew only: drop the tap it came from' },
|
|
{ t: 'cmd', text: 'brew untap michel/tap' },
|
|
{ t: 'cm', text: '# config and state: searches, downloads, rooms,' },
|
|
{ t: 'cm', text: '# messages, the daemon token' },
|
|
{
|
|
t: 'cmd',
|
|
text: 'rm -rf ~/.config/soulseek-rs ~/.local/share/soulseek-rs',
|
|
},
|
|
{ t: 'cm', text: '# Linux: drop the saved password' },
|
|
{ t: 'cmd', text: 'secret-tool clear service soulseek-rs' },
|
|
]}
|
|
/>
|
|
<Terminal
|
|
label="Windows · PowerShell"
|
|
lines={[
|
|
{ t: 'cmd', text: 'soulseek-rs skills uninstall' },
|
|
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
|
{
|
|
t: 'cmd',
|
|
text: 'Remove-Item -Recurse -Force $env:APPDATA\\soulseek-rs',
|
|
},
|
|
{ t: 'cm', text: '# then remove the soulseek-rs entry from' },
|
|
{ t: 'cm', text: '# Credential Manager > Windows Credentials' },
|
|
]}
|
|
/>
|
|
</Cols>
|
|
<div className="mt-5">
|
|
<Callout className="bg-raised" title="two things it will not touch">
|
|
<p>
|
|
Your downloads and your shared directories are yours: they stay where they
|
|
are. On macOS, delete the keychain entry for <code>soulseek-rs</code> in
|
|
Keychain Access if you saved a password.
|
|
</p>
|
|
</Callout>
|
|
</div>
|
|
</Section>
|
|
)
|
|
export const Install = () => (
|
|
<>
|
|
<PageHead eyebrow="install" title="Three ways in.">
|
|
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.
|
|
</PageHead>
|
|
<Steps />
|
|
<Reachable />
|
|
<Platforms />
|
|
<Config />
|
|
<Uninstall />
|
|
</>
|
|
)
|