Commit Graph
100 Commits
Author SHA1 Message Date
Michel de Graaf e43253a7db ci: let a token opt the release PR into running CI
release-plz opens the release PR as app/github-actions, and GitHub parks
workflow runs from that actor at action_required, so the PR shows no checks
until someone approves each one by hand. Passing a PAT instead makes the PR
come from that identity and its CI runs on its own.

The fallback keeps today's behaviour until RELEASE_PLZ_TOKEN exists, so this
is inert until you set it. Only the PR job needs it; tagging and publishing
are fine with GITHUB_TOKEN.
2026-07-25 04:08:37 +02:00
Michel de Graaf 0b332c5170 ci: let the tap update its own formula
The homebrew job needed a cross-repo PAT in HOMEBREW_TAP_TOKEN, which resolved
to an empty string and failed every release with "Input required and not
supplied: token". A tap can write to itself with the GITHUB_TOKEN it already
has, so the formula is now generated by an Update formula workflow in
michel/homebrew-tap that reads the latest release and commits when it changes.

That removes the secret from the release path entirely. Verified by corrupting
the formula and watching the workflow put it back.
2026-07-25 04:00:20 +02:00
Michel de Graaf c27f2b8467 ci: stop cancelling master runs, and revert the release job conditions
The empty homebrew tap was never a condition problem. The release job ran, the
matrix built all six targets, and the tap step then failed with "Input required
and not supplied: token" because HOMEBREW_TAP_TOKEN resolves to an empty
string. Four rounds of edits to those `if` expressions chased a symptom, so
they go back to what they were.

What stays is the CI concurrency change: a cancelled master run leaves the
release gate waiting on a CI run for a commit nothing will push again.
2026-07-25 03:56:10 +02:00
Michel de Graaf 53ff1045cb fix(ci): gate the binary and formula jobs on the event, and show the tag
Three context spellings were tried in these job conditions and all three left
the jobs skipped: `inputs.tag`, `github.event.inputs.tag`, and
`needs.release.outputs.tag`. The run logs show `Resolve tag` succeeding with
INPUT_TAG=v7.0.0 and writing the output, so the value exists in the step and
is gone by the time a downstream `if` reads it.

`github.event_name` is the one context this file already proves works in a job
condition, because ci-gate skips correctly on dispatch. Gate on that, and echo
both outputs at the end of the release job so the next failure names itself
instead of costing four runs.
2026-07-25 03:51:06 +02:00
Michel de Graaf a5339ac949 fix(ci): gate the binary and formula jobs on the resolved tag
Neither `inputs.tag` nor `github.event.inputs.tag` evaluates in a job-level
`if` on workflow_dispatch, so both spellings left the binaries and homebrew
jobs skipped on every manual recovery run, while the step-level condition on
the same input fired correctly and resolved the tag. The run log settles it:
INPUT_TAG reached the step as v7.0.0 and the jobs still skipped.

`Resolve tag` already runs exactly when there is something to build, so its
output is the honest signal, and a `needs` output does evaluate here.
2026-07-25 03:45:43 +02:00
Michel de Graaf c6d41796c9 feat(tui): give the panes the colour the demo promised
The cursor row set a foreground, and ratatui only skips a cell's colour
when that field is None — so selecting a row repainted its gold sizes,
blue usernames and status glyphs into one flat cream. Table cursors now
tint the background only, over a warm oxide wash instead of neutral.

Browse files, their sizes and the settings popup rendered in the raw
terminal foreground; they take the palette now, and free slots read
green when a peer has one.
2026-07-25 03:44:07 +02:00
Michel de Graaf fce4b765b7 fix(ci): make the manual tag input actually reach the recovery jobs
The `tag` dispatch input exists so binaries and the homebrew formula can be
rebuilt for a tag that already shipped. It never worked: the job-level
conditions read `inputs.tag`, which is empty in a job `if` on
workflow_dispatch, and `null != ''` coerces to false, so both jobs silently
skipped. That is why v7.0.0 reached crates.io with an empty homebrew tap.
The step-level condition used the same expression and did fire, which is what
made the two disagree. `github.event.inputs.tag` is populated in both places.
2026-07-25 03:43:34 +02:00
Michel de Graaf fe079b4c94 fix(website): give the wordmark cursor the gap it was drawn with
The cursor sat 0.06em after the text, close enough to read as a stray glyph.
The wordmark SVG puts it 15.4 units after a 27-unit block, so 0.57 cursor
widths, which is 0.33em at the component's 0.58em cursor.
2026-07-25 03:38:10 +02:00
Michel de Graaf 430a985152 fix(website): give the wordmark cursor the gap it was drawn with
The cursor sat 0.06em after the text, close enough to read as a stray glyph.
The wordmark SVG puts it 15.4 units after a 27-unit block, so 0.57 cursor
widths, which is 0.33em at the component's 0.58em cursor.
2026-07-25 03:38:03 +02:00
Michel de Graaf 06459ddced docs: use the wordmark at the top of the README
The mark is a glyph; the wordmark reads "soulseek-rs" outright, which is what
a README header wants. Both tones ship so prefers-color-scheme can pick one.
Named for the background they sit on rather than their Figma tone, because the
existing logo-ink/logo-light pair means the opposite of what it sounds like.
2026-07-25 03:34:44 +02:00
Michel de Graaf 6b755872ff docs: mention Homebrew where the README lists how users install
The project-structure summary still named cargo as the only route.
2026-07-25 03:22:45 +02:00
Michel de Graaf 9e41456507 docs: follow up the v7 release and the homebrew section
The homebrew install text arrived with an em-dash in it, and both READMEs still
told library users to depend on version 6.
2026-07-25 03:22:04 +02:00
Michel de Graaf 12c8147f71 docs: point the README logo at the website's copy
The logo already lived in website/public; copying it into fixtures meant two
files to keep in step for no gain. The README now references the original.
2026-07-25 03:21:33 +02:00
Michel de Graaf 9c966672af feat: install via homebrew (#9)
Add a release job that regenerates Formula/soulseek-rs.rb in michel/homebrew-tap
from the archives the binaries matrix uploads, and lead with
`brew install michel/tap/soulseek-rs` in the READMEs and on the website.
2026-07-25 03:18:53 +02:00
Michel de Graaf d84360a803 docs: drop the em-dashes from the README
Thirty-eight of them, in three roles that each wanted a different fix: label
separators in lists became colons, table cells standing in for "no value"
became the word they meant (required, unset, not a config key), and the
mid-sentence asides became commas, parentheses, or two sentences.
2026-07-25 03:18:37 +02:00
Michel de Graaf 13c58feafe docs: open the README with the logo and the site's own intro
The logo ships in both variants the site uses, picked by prefers-color-scheme
so it stays legible on either GitHub theme — the names are inverted from what
you would guess, `ink` being the cream one for dark backgrounds. The opening
copy now matches the site rather than describing the project twice in two
voices, and still says the repository holds a library as well as a client.
2026-07-25 02:55:47 +02:00
Michel de Graaf 692e8008e0 docs: link the website from the crates and the READMEs
The site went up but nothing pointed at it. `homepage` in the workspace
package metadata gives both crates.io pages a link, and each README opens with
one, so someone landing on the crate or the repo can find the install guide
and the config reference.
2026-07-25 02:53:27 +02:00
Michel de Graaf 9d58bb9b64 refactor: clear the quality-scan findings
`render_shortcuts` was 144 lines of "which shortcuts apply" wrapped around 40
lines of drawing; the decision moves into a `shortcuts` method beside the
`rooms_shortcuts` helper that already worked this way. Three identically
shaped comments over the popup guards collapse into one that carries the part
worth knowing — an open popup owns the keyboard, so the order matters. The
state tests trade bare `unwrap()` for `expect()` so a failure names the step.
2026-07-25 02:32:11 +02:00
Michel de Graaf 14b0321dbc docs: document the skills command and the scriptable surface
The READMEs still described the flags and spellings the one-shot rework
replaced, and said nothing about `skills`. The root README gains an agent
skills section covering install, update and removal; the crate READMEs catch
up with the current command set, record shapes and library API.
2026-07-25 02:32:05 +02:00
Michel de Graaf cd41688803 docs(website): explain what the skill unlocks, and document config.toml
The agent-native copy described what the skill contains rather than what it
buys you. A new home-page section shows the shape of the request that becomes
possible — describe the outcome, let the agent write the pipeline — and names
the specific knowledge --help cannot carry: which JSON keys a record has, that
exit 4 means widen the query, that a remote path goes back byte-identical,
that --follow never returns.

The install page gains a reference for all nine config.toml settings with
their defaults and an example file, next to the paths that were already there.
The list is the one `config set` accepts, checked against FileConfig::KEYS.
2026-07-25 02:27:47 +02:00
Michel de Graaf 17e4bf9fd3 docs(website): lead with the agent-native story
`skills install` becomes the featured card on the home page, in the wide slot
the grid gives its first entry: one command hands a coding agent the JSON keys
of every record, the exit-code meanings, and the --json rule, with nothing to
clone because the skill ships inside the binary. The docs page gains a callout
and the command in the shell listing.

Also catches the site up with the scriptable command surface the last two
releases introduced: `get`, `shares add`, `serve`, `room list`, `message send`
and `--json` replace the flags and spellings that no longer exist.
2026-07-25 02:24:58 +02:00
Michel de Graaf 95bc18672d feat(cli): ship the agent skill inside the binary
A `cargo install` leaves a binary and nothing else, so there is no file on
disk for a coding agent to read. `skills install` writes an embedded SKILL.md
into every agent found on the machine — Claude Code and opencode today, any
other via --dir — and installing over an older copy is what updating means.

The skill carries what --help cannot: the JSON keys of every record, what each
exit code means, and the rule that an agent always passes --json. A unit test
walks the clap command tree and holds SKILL.md to a documented entry for every
command, so a new subcommand fails the build until it is written up.
2026-07-25 02:17:11 +02:00
Michel de Graaf 03e8924a6b feat(cli): expose sharing, peer lookup and settings as one-shot commands
An agent driving the binary could search, fetch and chat, but it could not be
a peer: nothing kept a session online to serve files, nothing reported an
upload, shares could only be set at startup, and there was no way to ask
whether a peer was worth queueing to or to check credentials without doing
something else.

New commands:

  serve [--follow]                stay online sharing; one record per upload
  whoami                          credentials, connection and what we offer
  user <NAME>                     a peer's presence and share counts
  room users <ROOM>               the member roster
  shares list|add|remove|status|reindex
  config path|list|get|set

`config` and `shares list|add|remove` need no account, so they run before the
credential check — asking a script to log in for a `config get` would be a
poor trade. `serve` is the only place uploads are visible: they live in the
process serving them, so a one-shot `uploads list` could print nothing but an
empty list.

Library additions, all request/poll pairs matching the existing conventions:

- Client::room_members, kept current from the join and leave events
- Client::request_user_info / user_info, backed by new handlers for
  GetUserStatus (7) and GetUserStats (36) — codes the dispatcher previously
  logged as unhandled. The wire format is taken from soulfind's own source
  and pinned by an e2e test that asserts a peer's real share count.
- UserInfo carries presence and stats as Options, so a reply that never
  arrived cannot be read as "offline" or "shares nothing"
- request_user_info clears the previous answer, so the documented poll loop
  observes this request rather than the last one
- ServerMessage and ClientOperation are now #[non_exhaustive]: every protocol
  message the client learns adds a variant, and that should not break callers
- the crate root and prelude re-export every type a Client method returns

Also pins the output contract with tests: under --json, stdout carries only
newline-delimited JSON across fifteen command shapes while progress still
goes to stderr, and a failing command writes nothing to stdout at all.
2026-07-25 01:30:48 +02:00
Michel de Graaf be6581ee6c 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.
2026-07-25 01:30:48 +02:00
Michel de Graaf c2dd227cee chore: strip the comments this branch added
Removes every comment introduced on this branch: website/ in full, pages.yml
in full, and only the blocks added to ci.yml and release.yml — their
pre-existing comments are untouched.

Two directives survive because they are load-bearing rather than prose: the
triple-slash vite/client reference, and nothing else.

Two comments were carrying behaviour that the code did not state, so the
code now states it. useCopy's rejection handler clears the copied flag
instead of sitting empty, and writeTheme returns whether the write actually
landed instead of swallowing the failure in a bare catch. isExternal drops
its regex for two startsWith calls, which reads the same and no longer
hides a `//` inside a literal.
2026-07-25 01:29:55 +02:00
Michel de Graaf ba92e75fd3 fix(website): stop the hero mismatching its own prerendered markup
Reading prefers-reduced-motion in the state initialiser made the first
client render disagree with the server for anyone who has it on: the server
always serialises the searching state, the client painted the resting one,
and React bailed out of hydration with #418 on every load. The preference
now applies a tick later, from the opening-run effect, so the first render
matches the HTML and the resting state arrives immediately after.

The header's home link was 19px tall — the most reached-for control in the
nav, and the only one under the 24px target minimum, while every nav link
beside it was already 44. It gets a 44px box.

The demo's search field also carries an id, name and type, which clears the
"form field should have an id or name" issue and stops browsers offering
autofill on a fake terminal input.
2026-07-25 01:21:53 +02:00
Michel de Graaf 1eef464677 refactor(website): cut the stock patterns out of the design
Audited against the AI-design catalog. The world stays — the terminal
aesthetic, the oxide accent, IBM Plex Mono are all chosen, and the audit
cleared them. What went is the handful of defaults that had crept in
underneath it.

Eight feature cards at identical weight said every feature mattered equally.
Search is what the client is for, so it takes a double-width cell and the
grid drops to three up; eight cards plus that one double still comes out
square.

Each glyph sat in a 34px bordered square — the stock icon-in-a-chip
treatment that ships with every component library. The glyphs are the real
TUI's, so they now sit inline like the prompt markers they are.

Callouts lose the coloured left rule. The catalog puts it plainly: a
coloured left border is about as reliable a sign of generated design as an
em-dash is in prose. The tone already lives in the label, which was saying
the same thing twice.

Thirteen arrow glyphs were typed into labels and link text. The
leaving-the-site mark belongs to the control, not the copy, so Button draws
it once, aria-hidden, and only when the link actually leaves. The "read
more" arrows on internal links were pure flourish and are gone.

The hero also drops "blazingly fast Rust", which TONE-OF-VOICE.md lists
under banned, no exceptions.
2026-07-25 00:54:49 +02:00
Michel de Graaf 310149c718 fix(website): draw the keycap edge with a shadow instead of a heavy border
A 2px border on one side of a rounded box thins as it turns the corner, so
the keycap's bottom edge tapered exactly where a real key looks solid. An
unblurred 1px shadow sits under the full curve instead — same weight, no
taper — which is the look the design system's `.key` rule describes.

Also records the one detector finding that is not a defect: the single-font
warning on index.html. tokens/typography.css calls monospace
"non-negotiable: it's a terminal application", hierarchy comes from three
weights and a five-step scale (the rule's own stated exception), and the
second family it asks for is already there — --font-forum, a system stack
the scanner cannot see because it needs no @font-face. Scoped to that one
file so the rule keeps working everywhere else.

config.local.json holds this machine's hook consent, so it is gitignored
rather than shared.
2026-07-25 00:44:58 +02:00
Michel de Graaf 74ad230150 fix(website): clear WCAG AA across both themes, and stop the hero shifting
Contrast was measured, not eyeballed: 41 foreground/background pairs across
both themes and the terminal surface, each against the darkest (light) or
lightest (dark) surface it actually sits on. Five failed. Links on manila
read 3.64:1 and the footer's "No telemetry. Ever." — a claim the site goes
out of its way to make — sat at 2.86:1.

The accent needed splitting rather than moving. #c1502e clears 4.5:1 as a
button fill behind white but only 3.99:1 as an 11px eyebrow on vinyl, so
--accent keeps the brand hue for fills and a new --accent-text carries the
readable cut. The terminal gets the same treatment dark emulators give their
palettes: alarm and oxide brighten inside .tui-content so "Failed" is
legible next to its glyph.

Reduced motion no longer nukes everything. A blanket 0.01ms also kills the
transitions that answer a click, which is feedback rather than movement;
looping and travelling animation stops, the rest caps at 80ms. The TUI
already skipped its opening run and still does.

The hero reserves its box with aspect-ratio from the first paint. It was
prerendered at full unscaled height and collapsed once JS measured, moving
everything below it by ~145px. Measured shift is now 0.

Also: the webfont loads non-blocking (display=swap meant the first frame
never waited for it anyway), footer link groups nest under a heading instead
of sitting level with page sections, and eight tokens nothing referenced
stop shipping.
2026-07-25 00:40:13 +02:00
Michel de Graaf 8b8383afdf feat(website): drop the tmux bar from the hero demo
The demo is the client, not the terminal it happens to run in. Removing the
bar takes the tmux window list, session name, host and clock with it, plus
the three --tmux-* tokens that existed only to paint it.

The scale floor goes too: the terminal now shrinks to whatever width it is
given instead of stopping at half size and scrolling sideways, which makes
useFitScale's minScale parameter and the media query feeding it dead weight.
2026-07-25 00:20:46 +02:00
Michel de Graaf 723700753a feat(website): sync headline copy from the design
The headline drops "Blazingly fast, in Rust" and leads with what the thing
is, lowercase, the way you type it. The sub-heading loses "with no window
and no mouse", which the headline already implies.
2026-07-25 00:10:56 +02:00
Michel de Graaf 4c332de583 fix(website): point canonical and og:url at the custom Pages domain
GitHub Pages serves this account's project sites from re-invention.nl, not
michel.github.io, so every prerendered page was advertising a host that does
not serve it.
2026-07-24 23:59:30 +02:00
Michel de Graaf f60aaac0a8 feat(website): sync the latest design changes
Headline now leads with agents alongside people, and the second callout
swaps sharing advice for what the shell subcommands make possible — cron, a
script, or an agent driving the client with no daemon to stand up.

Mobile gets the design's newer treatment: the eyebrow and the sub-heading
step aside so the headline and the install button are all that precede the
terminal, nav links and the button row centre, the footer keeps two columns
with the brand spanning both, and under 520px the platform strip drops its
labels to a centred row of icons. That last one makes the icon the only
thing naming the platform, so it carries an aria-label now rather than
being hidden from assistive tech.
2026-07-24 23:58:49 +02:00
Michel de Graaf 33bfc34dc8 feat(website): add the project site and deploy it to GitHub Pages
A Vite + React + TypeScript + Tailwind v4 site under website/, built from
the soulseek-rs design system so the site and the TUI stay one product.
The colour tokens in src/index.css are the same values as
soulseek-rs/src/ui/styles.rs; change one, change both.

The hero is a working recreation of the TUI: it plays one scripted run and
then takes the keyboard, or lands on the resting state directly under
prefers-reduced-motion. Its sample data is invented or public domain, per
the tone-of-voice guardrail against naming copyrighted material.

Static output rather than an SPA shell. `bun run build` compiles the client,
compiles the same app for Node, then renders every route in src/lib/routes.ts
to its own file with that route's title, description and canonical URL. A
direct link to /install therefore answers 200 with real markup instead of
GitHub's 404 page. routes.ts is keyed into App.tsx by literal path, so adding
one without the other fails typecheck rather than shipping a page whose body
says "not found" under the right title.

Theme is resolved once by an inline script before first paint and adopted by
React through useSyncExternalStore, so there is no flash and no hydration
mismatch on the toggle. Light mode re-cuts the semantic hues darker: phosphor
and tape read on vinyl but wash out on manila.

CI and release skip website-only pushes, and Pages skips Rust-only ones. The
two Rust workflows share one paths-ignore list so they can only skip
together, which keeps the release ci-gate from polling for a run that was
never queued. That gate also now treats a cancelled CI as "keep waiting"
rather than a failure, since cancel-in-progress cancels the older SHA on
every back-to-back push.
2026-07-24 23:42:41 +02:00
Michel de Graaf 54feb3bc5a fix(peer): log disconnect cause; bind e2e browse listener on all interfaces
The brokered third-party browse e2e failed deterministically on CI. The test
bound its listener to 127.0.0.1 only, but the server brokers back the address
it observed for that client - the host's routable IP (172.17.0.2 under Docker,
10.x on a CI runner). The sharer's dial was refused, it fell back to
re-brokering, and the accept never arrived. The sibling third-party browse
tests already bind 0.0.0.0.

disconnect_with_error logged only "disconnect" and dropped the io::Error on the
floor, which is what made this invisible in the logs; it now logs the cause.
2026-07-24 22:21:06 +02:00
Michel de Graaf b69405a2c5 docs: pin soulseek-rs-lib install snippets to 6 2026-07-24 21:43:05 +02:00
Michel de Graaf be5dc6c064 fix(peer): serve full browse listings + harden the listener; TUI transfer/UX work
Peer layer:
- Stream peer messages with a partial-write queue instead of write_all, so a
  large SharedFileListResponse over the non-blocking socket is no longer
  truncated (third-party clients like SoulseekQt can now browse our shares).
- Bound the peer-init handshake with a read timeout and handle each accepted
  connection on its own thread, so one stalled peer can't wedge the listener.
- e2e: third-party direct/brokered/large-share browse + stalled-peer coverage.

TUI:
- Uploads share the downloads table columns (real Speed) and the info pane now
  renders details for a selected upload.
- Download progress bar counts only in-progress transfers and hides when idle.
- Always-visible unread badge (private messages + rooms) in the shortcuts bar,
  self-drawn blink so it works under tmux.
- Persist the unread private-message count across restarts.
2026-07-24 21:18:20 +02:00
Michel de Graaf 105c798855 feat(ui): theme the TUI with the soulseek-rs design system
Replace the ANSI Light* palette with the design system's tokens
(tokens/colors.css): oxide accent, phosphor focus, tape/signal/alarm
semantics, paper text on dust chrome. Only foregrounds and the row
highlight are painted so the user's terminal background shows through.

Route every bordered pane through a single pane_block() helper carrying
one column of horizontal padding, and give legends a consistent
[n] Title form with the tag in signal. The command bar now derives its
cursor position from the block's inner rect instead of a hardcoded
border offset.

Also collapses the download-status glyph match that was duplicated
across three render sites into download_status_glyph().
2026-07-24 21:18:20 +02:00
Michel de Graaf 2c29e1a39b ci: bound job runtime so a hung e2e cannot stall a release
The e2e suite talks to a real soulfind server over the network. On 298ec5a
it stopped responding and sat in_progress for over half an hour with every
other job green. No job declared timeout-minutes, so GitHub's six hour
default applied: six hours of runner time, and six hours during which the
new release gate correctly refuses to publish.

Limits are sized well above observed runtimes (lint ~1m, fmt ~15s, test
~2m, e2e ~1m) so they only fire on a genuine hang. A hang now surfaces as a
bounded red run that can be re-run, rather than an unbounded one.

This does not address why e2e hangs; it stops that from being unbounded.
2026-07-24 14:02:12 +02:00
Michel de Graaf 37d146b907 fix(persist): use XDG paths on macOS
Config and state now live in ~/.config/soulseek-rs and
~/.local/share/soulseek-rs instead of Application Support.
2026-07-24 14:02:12 +02:00
Michel de Graaf 298ec5aad5 build: strip release binaries and gate publishing on CI
Two gaps left over from the v6.0.0 release.

The v6.0.0 archives shipped unstripped: 3.63 MB vs 4.53 MB locally, 168
symbols instead of 9483. Setting it on the profile rather than in the
workflow covers every target at once and `cargo install` along with them.

Nothing gated the crates.io publish. A published version can be yanked but
never withdrawn, so the release job now waits for the CI run on the same
commit and refuses to publish unless it is green. release-pr deliberately
does not wait; opening the release PR is harmless.

This is not hypothetical: CI failed on eef7bf7, the v6.0.0 release commit.
It was a flaky e2e timeout rather than a regression - the very next commit
touched no code and passed - but the gate would have stopped that release,
which is the point. The gate reads the newest CI run for the commit, so
re-running a flaky job and then re-running Release clears it.
2026-07-24 13:27:09 +02:00
Michel de Graaf 237068cc2c ci: build musl targets with cargo instead of cross
upload-rust-binary-action reaches for `cross` on any *-musl target. Every
entry in the matrix builds on a runner of its own architecture — only the
libc differs — so cross buys nothing, and on the native arm64 runner it
hard-failed:

    error: toolchain 'stable-x86_64-unknown-linux-gnu' may not be able to
    run on this system

That cost v6.0.0 its aarch64-unknown-linux-musl archive; the other five
targets uploaded because the matrix is fail-fast: false.

Re-running the failed job from the Actions UI replays the old workflow file,
which is useless when the workflow is the bug. So the Release workflow now
takes an optional `tag` input that rebuilds and re-uploads binaries for an
existing tag. That value reaches actions/checkout as `ref:` and is typed by
whoever dispatches, so it is checked against a literal version tag first.
2026-07-24 12:56:49 +02:00
Michel de Graaf 720e0676f8 ci: stop running the matrix twice per pull request
The push and pull_request triggers both matched every branch, so each PR
kicked off two identical runs of lint, the three-OS test matrix and the
soulfind e2e job. Push now only covers master; branches are covered by the
pull_request trigger. Adds a concurrency group so a follow-up push cancels
the superseded run instead of queueing behind it.
2026-07-24 12:37:28 +02:00
Michel de Graaf 6403c11c8f ci: automate releases with release-plz
Replace the 326-line semantic-release/npm workflow with release-plz, which
handles the whole chain the old one only half did:

- versioning and CHANGELOG from the conventional commits already in use
- a `chore: release vX.Y.Z` PR as the gate; merging it tags and releases
- publishes both crates to crates.io in dependency order, waiting for the
  index between them (RELEASE.md used to ask you to do this by hand)
- release-plz.toml keeps the two crates on one version, one tag, one changelog

Binaries now cover six targets instead of four. Linux switches from gnu to
musl so the downloads are statically linked and stop caring about the host's
glibc version; linux-aarch64 and windows-aarch64 are new. Archives carry
sha256 checksums plus README and LICENSE.

CI drops twelve hand-rolled actions/cache steps for Swatinem/rust-cache.
2026-07-24 12:37:28 +02:00
michel 5e34722a1b fix: drop libdbus dependency from keyring Linux backend
CI (and any Linux build without libdbus-1-dev) failed compiling
libdbus-sys, pulled in by keyring's sync-secret-service feature. Switch
to the pure-Rust zbus backend (async-secret-service + async-io +
crypto-rust): same Secret Service keychain, no system C library
needed.
2026-07-22 17:14:02 +02:00
michel 724796c1eb feat: settings popup and uploads in the transfers pane
Press 'o' for a settings popup: edit the download folder, add/remove
share paths (applied live via a rescan and persisted to config.toml),
and force a re-index with 'r' for files changed on disk. The state
machine is unit-tested; feedback shows the shared file/folder counts.

The downloads pane is now Downloads/Uploads: uploads being served are
listed after downloads with an up-arrow marker, live progress, and
status; 'x' cancels an in-progress upload. Client::shared_counts exposes
the index size for the status line.
2026-07-20 20:56:39 +02:00
michel 4936d4dd2a fix: clippy cleanups in upload tracking and share plumbing 2026-07-20 20:52:08 +02:00
michel 6238c44312 feat: track uploads with progress and cancellation
serve_file streams in chunks, updating a shared byte counter and
aborting with Interrupted when a cancel flag is set. Each served upload
is registered (peer, virtual path, size, live progress, status) and
exposed via Client::uploads(); Client::cancel_upload flags a matching
in-progress transfer. e2e: the download test now also asserts the
uploader recorded a completed upload with the right peer and byte
count.
2026-07-20 20:51:22 +02:00
michel be8197fd31 feat: update shared directories at runtime
Client::set_shared_directories rescans the given roots into a fresh
in-memory index, swaps it for peers, and re-announces the folder/file
counts to the server. The current directory list moves into the client
context so the running set is queryable. e2e-verified: a browser sees a
directory that was added after login.
2026-07-20 20:49:01 +02:00
michel a605431a10 feat: multiple shared directories
Shares::scan_many merges several roots into one index, suffixing
duplicate root names so virtual paths cannot collide, and skipping
unreadable roots instead of failing. ClientSettings now takes
shared_directories: Vec<String>; config gains a shared_dirs list
(combined with the single shared_dir and --shared-dir, deduped), with an
explicitly empty value still disabling sharing and the
share-what-you-download default unchanged.
2026-07-20 20:47:23 +02:00
michel 8389d525e7 feat: conventional default download and share folders
The default download directory is now the platform Downloads folder plus
a Soulseek subfolder (via the directories crate — XDG on Linux, known
folder on Windows) instead of a tilde path that only worked with a HOME
variable. Sharing follows the Soulseek convention of sharing what you
download: shared_dir defaults to the download folder, and an explicitly
empty shared_dir opts out. The folder is created at startup so first-run
share validation succeeds.
2026-07-20 20:43:27 +02:00
michel 8221045aa0 feat: set aside unusable state files as .bak instead of overwriting
When a state file is corrupt, unreadable, or from a newer build, rename
it to <name>.json.bak (replacing any older backup) before loading empty,
so the next save can't silently destroy it and the user can inspect or
recover the contents.
2026-07-20 20:26:36 +02:00
michel 36819d4c42 test: persistence e2e and registration-relogin e2e; lint clean
Adds a lib target (soulseek_rs_tui) exposing cli/models/persist so
integration tests can drive the persistence pipeline: state and config
survive a simulated restart through the real files, with the incomplete
download flagged for auto-resume. The lib e2e suite gains a
register-then-relogin test against soulfind proving the stored-credential
flow's server assumptions. cargo clippy --all-targets and cargo fmt are
clean.
2026-07-20 20:20:54 +02:00
michel c945bd5d5e feat: persist and restore TUI state across restarts
The main TUI now loads state on startup — search history restored as
inert entries, saved chat rooms rejoined on the server, and incomplete
downloads (including failed/timed-out ones) automatically re-enqueued —
and saves a diffed snapshot each loop tick and on exit, so only changed
files are rewritten.
2026-07-20 20:17:44 +02:00
michel d410f9e834 feat: versioned JSON state stores for downloads, searches, rooms
StateStore writes {version, data} envelopes under the platform data dir
with atomic tmp+rename writes and a per-file migration chain (data at
version i is upgraded by MIGRATIONS[i]). Missing, corrupt, or
newer-than-known files load as empty so state can never block startup.
2026-07-20 20:15:33 +02:00
michel d913c5cc71 feat: in-TUI login/registration screen
The default TUI path now brings the terminal up first and runs a login
screen whenever no working credentials exist: a pure-state-machine form
(unit tested) driven by an async connect/login attempt on a background
thread. Soulseek registers unknown usernames on login, so the same form
covers registration. On success the username is saved to config.toml and
a form-entered password to the OS keychain; stored credentials skip the
form entirely. Failed logins show the server error and retry instead of
aborting.
2026-07-20 20:14:17 +02:00
michel aaeb62efa4 feat: password resolution via OS keychain and password_cmd
SecretStore trait with a keyring-backed implementation (macOS Keychain,
Windows Credential Manager, Linux Secret Service) under service
soulseek-rs. Precedence: --password/env > keychain > password_cmd from
config.toml. Keychain failures degrade to not-found so headless boxes
fall through to password_cmd.
2026-07-20 20:11:41 +02:00
michel 86e3d8c58f feat: config.toml settings layer (CLI > env > file > defaults)
New persist module in the binary crate (lib stays zero-dep): FileConfig
loaded from a platform-appropriate config path via the directories crate,
with resolution precedence CLI/env (clap) > config.toml > built-in
defaults. Clap defaults move into the resolver so the file layer can sit
between.
2026-07-20 20:10:33 +02:00
Michel de Graaf 089a9da31a docs: drop zero-dependency bullet from README features 2026-07-20 11:19:38 +02:00
Michel de Graaf 96ec2a27ab docs: add features section to README 2026-07-20 11:03:24 +02:00
Michel de Graaf c463d80b3c refactor: apply review cuts
- replace hand-rolled clamp_cursor_to_char_boundary with
  str::floor_char_boundary at its four call sites, drop the helper
- dedupe the duplicated Layout::vertical constraint arms in render()
- drop stale #[allow(dead_code)] on spinner_state (field is read)
2026-07-20 11:02:43 +02:00
Michel de Graaf 2918b0ce75 refactor: resolve all lint findings
- remove 34 trivial restating comments (auto-fix)
- replace all 20 production .unwrap() calls with graceful handling:
  ?-propagation via LockPoisoned, unwrap_or_default on infallible
  conversions, let _ = on actor-channel sends, and a downloads_sender()
  helper in the TUI that also dedupes three channel-init blocks
- collapse nested if into match guard (clippy::collapsible_match)
- split client.rs (2275 lines) into a client/ module: connection,
  search, downloads, uploads, rooms, operations
- split ui/main_tui.rs (2027 lines) into a main_tui/ module: render,
  input, browse, rooms, downloads, search
- extract oversized functions: peer_actor/server_actor handle_message
  into per-message helpers, main() into init_logging/run_default_tui,
  download_selector and main_tui render into per-section helpers

Mechanical only, zero behavior change. Verified: clippy clean
(pedantic+nursery), fmt clean, 143 unit tests + 16 e2e tests against
soulfind pass, lint clean.
2026-07-20 10:53:57 +02:00
Michel de Graaf 17864ac9b0 fix: tui lag keyboard navigation 2026-07-20 10:25:42 +02:00
Michel de Graaf 9b0199fbbc chore: bump version to 5.0.0 and drop the WIP warnings from the README 2026-07-20 09:10:36 +02:00
michel 6821fd1907 fix: two medium bugs from the final adversarial review
- Retry was broken for the common case: retry_selected_download removed
  the failed download only from the UI list, not the client's
  DownloadStore. Since Client::download re-derives the same md5 token
  from the unchanged filename, retrying a still-Failed download left a
  duplicate-token entry; the token-migration path then deleted both,
  misrouting completion so the retried file downloaded to disk but the
  UI showed 'Queued' forever. Add Client::remove_download /
  DownloadStore::remove_by_file (status-agnostic) and clear the stale
  entry before re-issuing. Regression-tested.

- Chat-room member selection could point out of range: RoomEvent::UserLeft
  shrank the active room's member list without clamping user_selected, so
  the highlight silently shifted to the wrong user (or vanished, making
  'b'/'m' no-ops, and blanking the member pane). Clamp user_selected after
  every applied room event. Regression-tested.
2026-07-20 07:26:03 +02:00
michel 2d5fc26db7 test(net): e2e-test the UPnP path + add a portmap diagnostic
Address the gap that the port mapper's UPnP path and gateway discovery
had no automated coverage (only NAT-PMP did):

- UPnP e2e: split map_via_gateway out of discovery and drive it through
  igd-next against an in-process mock IGD (a std HTTP server returning a
  canned SOAP envelope). Verifies the real add_port SOAP round-trip and
  that we request the exact external/internal port + LAN client.
- default_gateway parsing is now pure, testable functions with unit
  tests for the macOS and Linux formats (including the 0.0.0.0 skip).
- New 'portmap' CLI subcommand (needs no login): synchronously probes
  UPnP/NAT-PMP against the real router and reports whether the port was
  opened + the external address, then removes the test mapping — so a
  user can verify reachability on their own network.

9 port_mapping tests now cover the NAT-PMP protocol, the UPnP SOAP
round-trip, gateway parsing, and the map/renew/cleanup lifecycle.
2026-07-19 21:46:46 +02:00
michel 84c571767a fix(net): harden port mapper from adversarial review
- Shutdown no longer freezes: Drop signalled stop but then blocked on a
  thread join, and nothing observed stop during the blocking UPnP/NAT-PMP
  discovery (up to ~9s). Drop now waits on a done-channel with a 1s cap
  and detaches otherwise, so quitting is always prompt; the finite lease
  cleans up if we detach mid-discovery. Regression-tested against an
  unresponsive gateway.
- Linux default_gateway() no longer returns 0.0.0.0 for a gateway-less
  (directly-connected) default route — it keeps scanning, matching the
  macOS branch, instead of firing pointless NAT-PMP at the unspecified
  address.
- UPnP now retries AddPortMapping with a permanent (lease 0) mapping when
  the router rejects a timed lease (IGDs that only support permanent
  leases), and renews/removes with the accepted lease.
2026-07-19 21:26:51 +02:00
michel 9f74300c7a fix(tui): stop raw prints from corrupting the TUI
Log lines were painting over the alternate-screen TUI because some
messages bypassed the buffered logger and wrote straight to the
terminal:

- a live println! in GetPeerAddressHandler fired on every peer-address
  lookup (i.e. constantly while browsing/downloading) — routed to the
  buffered debug! logger.
- the TUI's error handlers used eprintln! in the render loop and in
  background search/download worker threads — routed to the buffered
  warn! logger so failures no longer smear the screen.

The logger already defers to a buffer while the TUI holds the screen; the
fix is simply to go through it instead of print!/eprintln!.
2026-07-19 21:22:38 +02:00
michel 34babf5808 docs: document automatic UPnP/NAT-PMP port mapping and reachability 2026-07-19 21:10:56 +02:00
michel 5ba2f9e181 feat(net): auto-map the listen port via UPnP-IGD and NAT-PMP
Behind a home router the client is unreachable for incoming peer
connections, so browsing/downloading firewalled peers (and the
server-brokered connect-back) times out. Add a best-effort port mapper
to the binary crate that, when listening is enabled, makes us reachable:

- NAT-PMP (RFC 6886) hand-rolled on std::net::UdpSocket, zero deps, with
  an injectable gateway address so it is fully testable.
- UPnP-IGD via the blocking igd-next crate as the primary path.
- A background PortMapper that discovers off the main thread (never
  blocks startup), renews at half the lease, and removes the mapping on
  drop. Any failure is non-fatal and logged.
- Maps the EXACT listen port only (we advertise it verbatim via
  SetWaitPort, so a different external port would be unreachable) and
  warns the user to forward manually otherwise.

Wired into the TUI, search, and CLI browse (the P2P modes); chat/rooms/
messaging are server-mediated and skip it. e2e-tested against an
in-process mock NAT-PMP gateway: the map request bytes, response parsing,
result-code/timeout handling, and the full spawn -> map -> drop -> unmap
service lifecycle.
2026-07-19 21:10:15 +02:00
michel e59c2bc199 fix(tui): make browse retry work and de-jargon the copy
- Retry actually retries: 'r' in the browse popup re-requests a
  timed-out user (previously the 'Press b to retry' hint did nothing —
  b wasn't handled inside the popup). BrowseTabs::retry_active is
  unit-tested.
- Friendlier timeout copy that explains the likely cause: "Couldn't
  reach <user>. They may be offline, or their connection can't be
  reached (both of you may be behind a router/firewall). Press r to try
  again."
- Replace protocol terminology in user-facing labels: 'Peer slots' ->
  'Free slots', 'Peer upload speed' -> 'Upload speed'.
2026-07-19 20:49:32 +02:00
michel 1b62d218dd test(e2e): browse a firewalled peer via the server broker
Add an e2e that forces the server-brokered (firewalled) browse path —
the path that matters on the real network. The sharer does NOT listen,
so the browser's direct dial fails and the server must broker the peer
connecting back to the browser's listener. This exercises
PeerConnectFailed -> ConnectToPeer -> inbound PierceFirewall ->
PeerConnected flush, which the localhost direct-dial test never hits.

Also soften user-facing download failure reasons from protocol jargon
("Peer …") to plain language ("The user went offline", etc.).
2026-07-19 20:49:26 +02:00
michel 60896619d7 feat(tui): tabbed browsing — browse several users at once
Replace the single browse view with BrowseTabs (one tab per user),
mirroring the chat-room tabs. Browsing a new user (from search results
or a chat room's member list) opens or focuses their tab; Tab/Shift-Tab
switch between open users, 'w' closes the active tab, Esc hides the
popup while keeping the tabs. A tab bar shows the open users when more
than one is open, and each tab polls/loads independently. Re-browsing a
timed-out user retries. BrowseTabs is unit-tested.
2026-07-19 20:38:34 +02:00
michel 5a3bc6fcd5 feat(tui): browse or PM a user from a chat room's member list
The member list in an open room is now selectable with the arrow keys.
Press 'b' to browse the highlighted user's shared files (opens the
browse tree) or 'm' to send them a private message (opens the compose
bar pre-filled with the recipient). Selection resets when switching
rooms; RoomsState navigation is unit-tested.
2026-07-19 20:22:33 +02:00
michel 01796f5d87 fix(lib): bound untrusted count loops in existing protocol parsers
The same unbounded-count DoS class fixed in the room parsers also exists
in three pre-existing parsers that read a length-prefixed vector count
straight off the wire: FileSearchResponse (SearchResult), the browse
SharedFileListResponse, and ExcludedSearchPhrases. A malicious/MITM
server or peer could send a tiny frame with a ~4-billion element count
and spin the actor thread into an OOM allocation loop.

Bound each loop (and the inner attribute loops, where read_int32 does
not advance past the buffer end) by the remaining payload. Regression
tests feed a hostile count and assert prompt, empty parses.
2026-07-19 20:19:37 +02:00
michel 9595afe9fb fix(rooms): harden chat-room parsing and TUI state from review findings
Adversarial review of the chat-rooms feature surfaced seven confirmed
issues; all fixed with regression tests:

lib (DoS hardening — a malicious/MITM server could send a tiny frame
with a ~4-billion element count):
- room_list: bound the name/count vector loops by remaining payload.
- join_room: bound the member-list loop the same way.

TUI (soulseek-rs/src/models/rooms.rs, ui):
- a message for a room we already left no longer resurrects its tab
  (route via open_index, not ensure_open).
- closing a tab now clears the unread badge on the room we land on.
- a room-list refresh keeps the highlight on the same room across a
  re-sort (track selection by name, not index).
- Esc in the room list clears a lingering filter before closing, matching
  the title's 'Esc: clear' hint.
- chat log renders one row per line so the newest message is never
  clipped by wrapping.
2026-07-19 20:15:42 +02:00
michel ce75d0a101 docs: check off chatrooms and document the rooms CLI + TUI 2026-07-19 19:59:10 +02:00
michel 08272ac867 feat(tui): chat rooms — browsable list, multiple open rooms, unread badges
Add a chat-rooms popup (open with 'c') satisfying the requested UX:
- Browsable, /-filterable room list showing per-room user counts,
  busiest first; already-open rooms are marked.
- Multiple rooms open at once as tabs; Tab/Shift-Tab switch, 'x' leaves.
- Per-room chat log with a live member list and an inline compose line
  (Enter to type, Enter to send; the server echo renders the message).
- Unread indicators: bold tab name + 'room (n)' badge per room, plus
  'chat (n)' and 'inbox (n)' counters in the shortcut bar; viewing a
  room clears its badge.

RoomsState is unit-tested (routing, unread accounting, tab lifecycle,
filtering).
2026-07-19 19:58:32 +02:00
michel 7d94b2caa3 feat(cli): rooms and chat subcommands
Add `rooms` (list public rooms with user counts, busiest first) and
`chat <room> [message]` (join a room; say a message and exit, or listen
and print incoming messages/joins/leaves for a while). Factor a shared
connect_and_login helper.
2026-07-19 19:49:17 +02:00
michel 7bc0ee2c3d feat(lib): chat-room protocol (list, join, leave, say) with events
Implement the Soulseek chat-room server messages against soulfind's
wire format:
- MessageFactory builders: room-list request (64), join (14),
  leave (15), say (13).
- Handlers parsing the server side: RoomList (64) names+counts,
  JoinRoom (14) membership, SayChatroom (13), UserJoinedRoom (16),
  UserLeftRoom (17), LeaveRoom (15).
- Surface everything to the client as a drained RoomEvent stream plus
  a room-list snapshot; new Client API: request_room_list, join_room,
  leave_room, say_in_room, room_list, take_room_events.

Unit tests cover each builder and handler; server-gated e2e tests
exercise message delivery and room listing against soulfind.
2026-07-19 19:47:58 +02:00
michel ae28f4236a feat(tui): surface the failure reason on failed downloads
Carry a human-readable reason on DownloadStatus::Failed so it flows
through the existing status channel to the TUI. The download Info pane
now shows an Error line for a failed download (connect failure, peer
disconnect, truncated transfer, peer-reported upload failure, or the
underlying transfer error). Failures without a known reason render as
before.
2026-07-19 19:36:15 +02:00
michel 4809dd7059 feat(tui): retry failed downloads and clear finished ones
In the Downloads pane: 'r' re-queues the selected failed/timed-out download,
and 'c' clears all completed/failed/timed-out entries from the list. Shortcuts
bar updated.
2026-07-19 19:29:43 +02:00
michel aa8f4182d3 feat(tui): hierarchical browse tree with file/folder downloads
Wire browsing a user's shared files into the TUI as a modal tree popup:
- press 'b' on a highlighted search result to browse that result's owner, or
  'b' elsewhere to type a username (new CommandBarMode::Browse);
- a new models/browse.rs synthesizes the flat SharedDirectory list into a
  nested, collapsible tree (intermediate folders reconstructed from the
  backslash paths; file download paths kept byte-exact from the original names),
  with expand/collapse state and a flatten-to-rows renderer — fully unit-tested;
- the popup polls take_browse_result each tick (with a timeout), navigates with
  arrows/hjkl, expands/collapses folders, and queues downloads: Enter on a file,
  'd' on a file or an entire folder subtree (recursively) — all funneled into
  the existing Downloads pane;
- shortcuts bar advertises browse keys and surfaces our own sharing status.
2026-07-19 19:27:05 +02:00
michel 95bd6359c1 docs: check off Browse user files in the README TODO list 2026-07-19 18:57:39 +02:00
michel 7d8a435d25 feat(cli): add 'browse <username>' to list a user's shared files
soulseek-rs browse <username> connects, logs in, requests the peer's file
list and prints the directories and files it shares.
2026-07-19 18:57:14 +02:00
michel f3218475dc feat: browse another user's shared files
Add Client::browse_user (sends GetShareFileList, code 4, connecting first if
needed via the same queue-and-flush path as search replies) and
take_browse_result. A new SharedFileListResponseHandler parses the peer's
code-5 reply into SharedDirectory entries, forwarded as
ClientOperation::BrowseResult and stored per user. Verified end to end: a
second real client browses a sharer and receives its file listing.
2026-07-19 18:56:19 +02:00
michel 9e050b59d9 docs: check off Share files in the README TODO list 2026-07-19 18:52:06 +02:00
michel f3b24f34c0 feat(shares): reply to browse requests with a SharedFileListResponse (code 5)
GetShareFileList (peer code 4) previously replied with a malformed message (a
server SharedFolders stats message over a peer connection). It now emits
ShareListRequested, and the client builds a real SharedFileListResponse
(code 5) from its shares grouped by directory. Adds a std-only code-5
encoder/decoder (build_shared_file_list / parse_shared_file_list) with a
roundtrip test, and Shares::directories() to group files. The decoder is the
foundation for the Browse feature.
2026-07-19 18:51:55 +02:00
michel 54d7d8f8a3 test(shares): two real clients search and download a shared file e2e
End-to-end proof of the whole sharing stack with no mock peer: a sharer indexes
a temp file, the leecher's search is distributed by soulfind, the sharer replies
with a FileSearchResponse, and the leecher downloads the real bytes that the
sharer serves over a peer-to-peer transfer. Asserts the search result and that
the downloaded bytes match the source. Server-gated (SOULSEEK_E2E_REQUIRED).
2026-07-19 18:48:33 +02:00
michel 393f69e478 feat(shares): serve queued files to peers over F connections
Implement the upload path so a peer can download our shared files:
- fix the mis-coded PlaceInQueueResponse (43 -> 44) and add a real QueueUpload
  handler (code 43) -> PeerMessage::IncomingQueueUpload;
- the peer actor forwards it as ClientOperation::QueueUpload; the client looks
  the file up in its shares, mints an upload token (high half of the token
  space, disjoint from md5 download tokens), and offers it via ServeUpload ->
  build_upload_transfer_request (peer code 40, direction=upload);
- a TransferResponse for one of our serving tokens is handled by a surgical
  early-return that emits StartUpload, leaving the download path untouched;
- StartUpload resolves the downloader's address (GetPeerAddress, cached) and
  streams the file via a new peer::upload_peer::serve_file over an F connection.

Tested: build_upload_transfer_request decodes via the production Transfer
parser; serve_file streams a file correctly over a loopback F connection.
2026-07-19 18:46:06 +02:00
michel 9b6e533bed feat(shares): reply to distributed searches with our matching files
Wire the incoming server FileSearch (code 26): the handler now forwards
ServerMessage::FileSearchRequest -> ClientOperation::IncomingSearch instead of
just logging. The client matches the query against its shares and, if anything
matches, sends a FileSearchResponse to the searcher — connecting to them first
(GetPeerAddress -> outbound P) and flushing the response on PeerConnected when
not already connected. GetPeerAddressResponse now also caches peer addresses
for the serve path. Adds build_search_response + a unit test.
2026-07-19 18:39:05 +02:00
michel bcfe56e74f feat(shares): encode FileSearchResponse (peer code 9)
Add build_file_search_response + a borrowed FileEntry view: the zlib-compressed
reply advertising our matching shared files to a searcher. The payload is the
exact inverse of SearchResult::new_from_message (username, token, per-file
name/size/ext/attributes, slots, speed), compressed with the stored-block
encoder. Round-trip tested through the production decoder.
2026-07-19 18:35:11 +02:00
michel 7ff6ab9425 feat(shares): scan shared dir on connect and send a real login handshake
The post-login handshake (SharedFolders / HaveNoParent / SetStatus /
SetWaitPort) lived only in ServerActor::login(), which nothing ever called —
so a live client never advertised its listen port or share counts. Move it to
a testable post_login_messages() fired from the LoginStatus(true) arm (once, on
success, on the live path), and delete the dead login().

Client::connect now scans ClientSettings.shared_directory once into
ClientContext.shares and passes the real folder/file counts to the server.
This is the prerequisite for two live clients to resolve each other's
addresses (GetPeerAddress needs the peer to have sent SetWaitPort).

Unit-tested post_login_messages; the login/listener e2e still passes, proving
soulfind accepts the handshake.
2026-07-19 18:34:05 +02:00
michel 33c3338144 feat(shares): scan a shared directory into an in-memory index
Add a std-only shares module: Shares::scan walks the shared directory (skipping
symlinks and unreadable entries) into a read-only snapshot keyed by peer-facing
virtual path (root name + backslash-separated relative path). Exposes
search (case-insensitive multi-term AND), get (O(1) by virtual path), and
file/folder counts for the login handshake. Unit-tested against a temp tree.
2026-07-19 18:30:34 +02:00
michel 9794fe0786 feat(shares): add a std-only zlib stored-block encoder
Soulseek peer messages (SharedFileListResponse, FileSearchResponse) are
zlib-compressed on the wire, but the library only had a decoder. Add
compress_stored(): a valid zlib stream built entirely from uncompressed
DEFLATE 'stored' blocks (0x78 0x01 header, blocks of <= 0xFFFF bytes, adler32),
which needs no real compressor and is accepted by standard decoders and by the
existing inflate path. Roundtrip-tested through the decoder at block
boundaries, plus header/adler32 vectors.
2026-07-19 18:28:56 +02:00
michel 3908dceb2f fix: identity-aware peer eviction so a replaced actor can't evict its successor
When a healthy peer actor was replaced under the same username (both sides
connecting at once, or two overlapping downloads to the same not-yet-connected
peer), its graceful shutdown still emitted PeerDisconnected, and the client
evicted by username — removing and stopping the NEWER actor now occupying the
slot, tearing down the live connection and stalling that peer's downloads.

Give each peer actor a unique id (assigned by the registry) and echo it in
PeerDisconnected/PeerConnectFailed. The client now evicts via
remove_peer_if(username, id), which removes the entry only when the reporting
actor still owns the slot, so a stale actor's shutdown is a no-op. The brokered
timeout no longer removes an actor by username either, avoiding the same
aliasing. Add a registry unit test for the identity guard.
2026-07-19 17:54:50 +02:00
michel a186c9d07d fix: pierce the firewall on server-brokered reverse connects
When the server asks us to connect to a peer (a ConnectToPeer we receive,
carrying the server's correlation token), we dial that peer and must announce
ourselves with a PierceFirewall quoting the token — not a PeerInit — so the
remote peer can correlate its own indirect request (search-result / upload
delivery). The outbound handshake now sends PierceFirewall when the peer
carries a server token and PeerInit only for direct GetPeerAddress dials
(token None). Direct and firewalled e2e paths are unchanged.
2026-07-19 17:48:50 +02:00
michel ea25325e68 fix: reap failed outbound peer actors and bound the brokered connect
Two related lifecycle defects in the direct/firewalled download path:

- On a failed direct connect, ClientOperation::PeerConnectFailed left the dead
  outbound actor registered and running. The actor pinned a thread-pool worker
  forever (its loop only exits on Stop), so a handful of unreachable-peer
  downloads could exhaust the fixed pool and wedge the whole client; and a
  later download() to the same peer saw registry.contains()==true and queued
  into the dead, streamless actor, hanging silently. The arm now removes and
  stops the dead actor before brokering, mirroring PeerDisconnected.

- The server-brokered fallback had no timeout, so a peer that never connects
  back (offline / double-firewalled / unroutable address) left the download
  Queued forever and its Receiver blocked. Arm a bounded timeout: if no
  PierceFirewall consumes the correlation token within BROKER_CONNECT_TIMEOUT,
  fail the peer's queued downloads (on the channel and in the store) and
  reclaim the token. A successful pierce consumes the token first, so the
  timeout is a no-op on the happy path.

Add a unit test for the queued-download failure helper; the direct and
firewalled e2e tests still pass (pierce arrives well within the timeout).
2026-07-19 17:47:45 +02:00
michel 7c3bcc0598 fix: don't panic on a peer's obfuscated port (u16) — widen the field
GetPeerAddressResponse narrowed obfuscated_port (u16) into a u8 via
try_into().unwrap(), panicking for any real obfuscated port (>255) and
unwinding the client's single ops-loop thread — hanging all downloads,
searches and peer events. Since download() now auto-resolves peer addresses
via GetPeerAddress, a download to any obfuscation-enabled peer could crash the
whole client. Widen Peer::obfuscated_port to u16 and stop truncating.
2026-07-19 17:45:30 +02:00
michel 51c5397d8f style: use Duration::from_secs for whole-second sleeps in e2e
Satisfies clippy::duration_suboptimal_units (a stable-toolchain lint the
local nightly did not surface), unblocking the CI Lint job.
2026-07-19 17:35:24 +02:00