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.
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.
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.
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.
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.
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().
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.
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.
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.
* chore: release v6.0.0
* docs: document the 6.0.0 breaking changes
cargo-semver-checks flagged four major-breaking changes in soulseek-rs-lib,
none of which carried a `!` or BREAKING CHANGE footer, so the generated
changelog had no migration note for a major bump.
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Michel de Graaf <michel@re-invention.nl>
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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)
- 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.
- 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.
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.
- 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.
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!.
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.
- 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'.
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.).
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.
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.
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.
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.
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).
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.
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.