From da371d4176863bb2f73a35245c9414b1ef8c5107 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Tue, 8 Sep 2026 05:12:15 +0000 Subject: [PATCH 01/55] feat(tui): show a result's file name and folder in columns of their own The results list held the whole shared path in one column, so a search was read through its folder: every row began with the same peer prefix and the file name, the part being searched for, sat off the right edge. Split the column at the last separator, the file name first with the larger share of the width and the folder dimmed beside it, so names line up and read at a glance. The one scroll offset moves both columns together, and $ runs to the end of whichever overflows further. The website's mock of the pane follows. Suggested in #55. --- README.md | 14 ++-- soulseek-rs/src/ui/main_tui/mod.rs | 15 +++- soulseek-rs/src/ui/panes/results_pane.rs | 94 +++++++++++++++++++++--- website/src/components/tui/panes.tsx | 17 ++++- 4 files changed, 118 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 0dd16d2..787ef84 100644 --- a/README.md +++ b/README.md @@ -92,13 +92,13 @@ Everything else is a one-shot command that runs headless. ### The TUI -Results run across the top, with the whole width for long file names; -Searches, Downloads and Info share the row underneath. `Tab`/`Shift-Tab` or -`1`–`3` move the focus between panes, `z` zooms the focused pane to the whole -window, and `w` hides it so the others widen (its number brings it back). -Every list takes `↑`/`↓`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and -`h`/`l` or `←`/`→` scroll a long file name or query sideways, `0` and `$` to -either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?` +Results run across the top, each file name in a column of its own with the +folder it came from beside it; Searches, Downloads and Info share the row +underneath. `Tab`/`Shift-Tab` or `1`–`3` move the focus between panes, `z` +zooms the focused pane to the whole window, and `w` hides it so the others +widen (its number brings it back). Every list takes `↑`/`↓`, `Home`/`End`, +`PgUp`/`PgDn` and the vim keys, and `h`/`l` or `←`/`→` scroll a long file name, +folder or query sideways, `0` and `$` to either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?` shows every key for where you are. ### Daemon mode diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 75ee091..127386c 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -1158,8 +1158,9 @@ mod tests { let mut tui = with_session(TalkativeSession::default()); tui.state.results_items = results(n); tui.state.focused_pane = FocusedPane::Results; + // 88 cells wide gives the name column 11 cells and the folder 7. tui.state.results_pane_area = - Some(ratatui::layout::Rect::new(0, 0, 80, 13)); + Some(ratatui::layout::Rect::new(0, 0, 88, 13)); tui } @@ -1273,6 +1274,18 @@ mod tests { ); } + #[test] + fn scrolling_runs_to_the_end_of_the_folder_when_it_is_the_longer() { + let mut tui = results_tui(3); + tui.state.results_items[1].filename = + format!("{}\\short.mp3", "f".repeat(20)); + tui.state.results_table_state.select(Some(1)); + press(&mut tui, KeyCode::Char('$')); + assert_eq!(tui.state.results_name_offset, 14); + press(&mut tui, KeyCode::Char('l')); + assert_eq!(tui.state.results_name_offset, 14, "stops at the end"); + } + #[test] fn picking_another_search_starts_its_results_unscrolled() { let mut tui = results_tui(3); diff --git a/soulseek-rs/src/ui/panes/results_pane.rs b/soulseek-rs/src/ui/panes/results_pane.rs index 039c7bf..af0f003 100644 --- a/soulseek-rs/src/ui/panes/results_pane.rs +++ b/soulseek-rs/src/ui/panes/results_pane.rs @@ -3,7 +3,7 @@ use crate::models::FileDisplayData; use crate::ui::{ BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes, header_style, info_style, pane_block, pane_title, row_highlight_style, - success_style, warning_style, + split_path, success_style, warning_style, }; use ratatui::{ Frame, @@ -15,9 +15,13 @@ use std::collections::HashSet; const VERSION: &str = env!("CARGO_PKG_VERSION"); -const WIDTHS: [Constraint; 7] = [ +// The file name and its folder share whatever width the fixed columns leave, +// the name getting the larger part: it is what a search is after, and a +// folder's tail is a scroll away. +const WIDTHS: [Constraint; 8] = [ Constraint::Length(3), Constraint::Fill(3), + Constraint::Fill(2), Constraint::Length(12), Constraint::Length(15), Constraint::Length(10), @@ -39,12 +43,22 @@ pub struct ResultsPaneParams<'a> { pub name_offset: usize, } -/// Filename is the second column. +/// The file name is the second column, the folder it sits in the third. const NAME_COLUMN: usize = 1; +const FOLDER_COLUMN: usize = 2; +/// The offset that brings the end of a result's longer column into view. +/// One offset scrolls the name and the folder together, so it runs until +/// both tails are showing. #[must_use] -pub fn name_end_offset(name: &str, area: Rect) -> usize { - end_offset(name, area, &WIDTHS, NAME_COLUMN) +pub fn name_end_offset(path: &str, area: Rect) -> usize { + let (folder, name) = split_path(path); + end_offset(name, area, &WIDTHS, NAME_COLUMN).max(end_offset( + folder, + area, + &WIDTHS, + FOLDER_COLUMN, + )) } /// Whether the rendered row `display_idx` is selected. `selected_indices` holds @@ -118,6 +132,7 @@ pub fn render_results_pane( let header = Row::new(vec![ Cell::from("✓").style(header_style()), Cell::from("Filename").style(header_style()), + Cell::from("Folder").style(header_style()), Cell::from("Size").style(header_style()), Cell::from("User").style(header_style()), Cell::from("Bitrate").style(header_style()), @@ -127,10 +142,12 @@ pub fn render_results_pane( .height(1); let name_width = column_width(area, &WIDTHS, NAME_COLUMN); + let folder_width = column_width(area, &WIDTHS, FOLDER_COLUMN); let rows: Vec = items .iter() .enumerate() .map(|(idx, file)| { + let (folder, name) = split_path(&file.filename); let checkbox = if row_is_selected(idx, original_indices, selected_indices) { "[✓]" @@ -159,12 +176,10 @@ pub fn render_results_pane( Row::new(vec![ Cell::from(checkbox).style(checkbox_style), - Cell::from(scroll_text( - &file.filename, - name_offset, - name_width, - )) - .style(body_style()), + Cell::from(scroll_text(name, name_offset, name_width)) + .style(body_style()), + Cell::from(scroll_text(folder, name_offset, folder_width)) + .style(dimmed_style()), Cell::from(format_bytes(file.size)).style(warning_style()), Cell::from(file.username.clone()).style(info_style()), Cell::from(bitrate_str).style(dimmed_style()), @@ -202,8 +217,10 @@ mod tests { fn render_rows(items: &[FileDisplayData], name_offset: usize) -> String { let mut state = TableState::default(); state.select(Some(0)); + // Wide enough for the name column to hold 11 cells, with 7 for the + // folder beside it. let mut terminal = - Terminal::new(TestBackend::new(80, 6)).expect("backend"); + Terminal::new(TestBackend::new(88, 6)).expect("backend"); terminal .draw(|frame| { render_results_pane( @@ -267,6 +284,59 @@ mod tests { assert!(!screen.contains("…\u{301}"), "{screen}"); } + #[test] + fn a_path_shows_its_name_and_folder_in_separate_columns() { + let items = [file("@@abc\\Music\\Album\\01.flac"), file("cover.jpg")]; + let screen = render_rows(&items, 0); + let header = screen.lines().nth(1).expect("header"); + let name = header.find("Filename").expect("name header"); + let folder = header.find("Folder").expect("folder header"); + assert!(name < folder, "{header}"); + + let row = screen.lines().nth(2).expect("first row"); + assert!(row.contains("01.flac "), "{row}"); + assert!(!row.contains("Album\\01.flac"), "the name alone: {row}"); + assert!(row.contains("@@abc\\M"), "{row}"); + assert!( + row.find("01.flac") < row.find("@@abc"), + "the name before the folder: {row}" + ); + + let row = screen.lines().nth(3).expect("second row"); + assert!(row.contains("cover.jpg"), "{row}"); + } + + #[test] + fn one_offset_scrolls_the_name_and_the_folder_together() { + let items = [file("@@abc\\Music\\Album\\abcdefghijklmnopqrstuvwxyz")]; + let screen = render_rows(&items, 4); + assert!(screen.contains("…efghijklmn"), "{screen}"); + assert!(screen.contains("…c\\Musi"), "{screen}"); + } + + #[test] + fn the_end_offset_is_the_longer_columns() { + use super::name_end_offset; + use ratatui::layout::Rect; + // 88 cells wide: 11 for the name, 7 for the folder. + let area = Rect::new(0, 0, 88, 6); + assert_eq!(name_end_offset("short.mp3", area), 0); + assert_eq!(name_end_offset(&"x".repeat(40), area), 30); + assert_eq!( + name_end_offset(&format!("{}\\short.mp3", "f".repeat(20)), area), + 14, + "the folder overflows its narrower column" + ); + assert_eq!( + name_end_offset( + &format!("{}\\{}", "f".repeat(20), "x".repeat(40)), + area + ), + 30, + "whichever runs further" + ); + } + #[test] fn an_unscrolled_name_starts_at_its_beginning() { let items = [file("abcdefghijklmnopqrstuvwxyz0123456789ABCD")]; diff --git a/website/src/components/tui/panes.tsx b/website/src/components/tui/panes.tsx index 7bb8344..5d897d9 100644 --- a/website/src/components/tui/panes.tsx +++ b/website/src/components/tui/panes.tsx @@ -5,7 +5,15 @@ import { cn } from '@/lib/utils' import { TuiPane } from './chrome' import type { ResultRow, SearchRow, TransferRow, TransferStatus } from './data' -const RESULT_COLS = '30px 1fr 74px 92px 78px 74px 42px' +const RESULT_COLS = '30px 3fr 2fr 74px 92px 78px 74px 42px' + +/** A shared path split at its last separator, as the TUI's two columns show it. */ +const splitPath = (path: string): { folder: string; name: string } => { + const at = Math.max(path.lastIndexOf('\\'), path.lastIndexOf('/')) + return at < 0 + ? { folder: '', name: path } + : { folder: path.slice(0, at), name: path.slice(at + 1) } +} const TRANSFER_COLS = '48px 1fr 96px 168px 74px' interface SearchesPaneProps { @@ -134,6 +142,7 @@ export const ResultsPane = ({ > ✓ Filename + Folder Size User Bitrate @@ -143,6 +152,7 @@ export const ResultsPane = ({
{rows.map((row, i) => { const { glyph, tone } = resultGlyph(row) + const { folder, name } = splitPath(row.name) const isSelected = selected === i return ( +
From 2975438949f95e42a6f5ccf14d92de1ed593ce15 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Tue, 8 Sep 2026 14:17:16 +0200 Subject: [PATCH 17/55] fix(ci): retry nightly tag updates --- .github/workflows/nightly.yml | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 640706a..065e350 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -168,14 +168,33 @@ jobs: gh release edit nightly --draft --prerelease --latest=false fi - current_tag=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/nightly" --jq .object.sha 2>/dev/null || true) - if [ -n "$current_tag" ]; then - gh api --silent --method PATCH "repos/$GITHUB_REPOSITORY/git/refs/tags/nightly" \ - -f sha="$SOURCE_SHA" -F force=true - else - gh api --silent --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ - -f ref=refs/tags/nightly -f sha="$SOURCE_SHA" - fi + # The Git refs API can briefly lag a just-merged commit. Retry both + # the create and update paths, re-reading the ref each time so a + # successful-but-disconnected response is harmless and idempotent. + tag_ready=false + for attempt in 1 2 3 4 5; do + current_tag=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/nightly" --jq .object.sha 2>/dev/null || true) + if [ -n "$current_tag" ]; then + if gh api --silent --method PATCH "repos/$GITHUB_REPOSITORY/git/refs/tags/nightly" \ + -f sha="$SOURCE_SHA" -F force=true; then + tag_ready=true + break + fi + elif gh api --silent --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref=refs/tags/nightly -f sha="$SOURCE_SHA"; then + tag_ready=true + break + fi + + [ "$attempt" -eq 5 ] || { + echo "::warning::Git refs API attempt $attempt failed; retrying" + sleep $((attempt * 2)) + } + done + [ "$tag_ready" = true ] || { + echo "::error::could not point the nightly tag at $SOURCE_SHA" + exit 1 + } if [ "$release_exists" = false ]; then gh release create nightly --verify-tag --draft --prerelease --latest=false \ From 922ad6fc35a7d8e0a42818f1fcf4adf6e387f802 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Tue, 8 Sep 2026 14:29:01 +0200 Subject: [PATCH 18/55] fix(ci): detect a missing nightly tag --- .github/workflows/nightly.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 065e350..005f468 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -168,13 +168,12 @@ jobs: gh release edit nightly --draft --prerelease --latest=false fi - # The Git refs API can briefly lag a just-merged commit. Retry both - # the create and update paths, re-reading the ref each time so a - # successful-but-disconnected response is harmless and idempotent. + # gh writes the REST error body to stdout on a missing ref, so use + # its exit status—not captured output—to choose create vs update. + # Retry both paths and re-read the ref to keep retries idempotent. tag_ready=false for attempt in 1 2 3 4 5; do - current_tag=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/nightly" --jq .object.sha 2>/dev/null || true) - if [ -n "$current_tag" ]; then + if gh api --silent "repos/$GITHUB_REPOSITORY/git/ref/tags/nightly" >/dev/null 2>&1; then if gh api --silent --method PATCH "repos/$GITHUB_REPOSITORY/git/refs/tags/nightly" \ -f sha="$SOURCE_SHA" -F force=true; then tag_ready=true From a3105fb0606ecd88c9b3378b533776c21ab205ce Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Tue, 8 Sep 2026 14:34:10 +0200 Subject: [PATCH 19/55] fix(docs): use deploy-independent nightly installer --- README.md | 2 +- website/public/install.sh | 4 ++-- website/src/lib/links.ts | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 82893fa..3b7032c 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ To install the current successful build from `develop` instead, pass the installer's `--nightly` option through to `sh`: ```bash -curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh -s -- --nightly +curl -fsSL https://raw.githubusercontent.com/michel/soulseek-rs/develop/website/public/install.sh | sh -s -- --nightly ``` Nightly is a mutable prerelease rebuilt every day and whenever the **Nightly** diff --git a/website/public/install.sh b/website/public/install.sh index 15326f0..165d778 100644 --- a/website/public/install.sh +++ b/website/public/install.sh @@ -5,7 +5,7 @@ # curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh # # Latest successful build from develop: -# curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh -s -- --nightly +# curl -fsSL https://raw.githubusercontent.com/michel/soulseek-rs/develop/website/public/install.sh | sh -s -- --nightly # # Stable installs use Homebrew when it is on PATH (macOS and Linux). Nightly # installs always download the prebuilt binary for this platform. Direct @@ -30,7 +30,7 @@ Install the latest stable soulseek-rs release. Options: -h, --help Show this help. When piping the script, pass the option after `sh -s --`: - curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh -s -- --nightly + curl -fsSL https://raw.githubusercontent.com/michel/soulseek-rs/develop/website/public/install.sh | sh -s -- --nightly SOULSEEK_RS_INSTALL_DIR overrides the destination directory. EOF diff --git a/website/src/lib/links.ts b/website/src/lib/links.ts index ff75651..fdb4f37 100644 --- a/website/src/lib/links.ts +++ b/website/src/lib/links.ts @@ -27,4 +27,5 @@ export const SITE_URL = 'https://re-invention.nl/soulseek-rs/' export const INSTALL_CMD = `curl -fsSL ${SITE_URL}install.sh | sh` -export const NIGHTLY_INSTALL_CMD = `${INSTALL_CMD} -s -- --nightly` +export const NIGHTLY_INSTALL_CMD = + 'curl -fsSL https://raw.githubusercontent.com/michel/soulseek-rs/develop/website/public/install.sh | sh -s -- --nightly' From 926c1c6697226379b274968ff3aafd2d2263f79b Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 15:44:06 +0200 Subject: [PATCH 20/55] feat(download): resume interrupted transfers --- soulseek-rs-lib/src/client/connection.rs | 23 +- soulseek-rs-lib/src/client/operations.rs | 91 +---- soulseek-rs-lib/src/client/tests.rs | 16 +- soulseek-rs-lib/src/download_store.rs | 80 +++- soulseek-rs-lib/src/peer/download_peer.rs | 465 ++++++++++++++++------ soulseek-rs-lib/src/peer/listen.rs | 50 +-- soulseek-rs-lib/src/peer/upload_peer.rs | 229 +++++++---- soulseek-rs-lib/tests/e2e.rs | 2 +- soulseek-rs-lib/tests/resume_e2e.rs | 408 +++++++++++++++++++ 9 files changed, 1032 insertions(+), 332 deletions(-) create mode 100644 soulseek-rs-lib/tests/resume_e2e.rs diff --git a/soulseek-rs-lib/src/client/connection.rs b/soulseek-rs-lib/src/client/connection.rs index e6d0436..3d4f36f 100644 --- a/soulseek-rs-lib/src/client/connection.rs +++ b/soulseek-rs-lib/src/client/connection.rs @@ -1,8 +1,8 @@ use super::{ Arc, Client, ClientContext, ClientOperation, ConnectionType, DownloadPeer, - DownloadStatus, Listen, Peer, PeerRegistry, Receiver, Result, RwLock, - RwLockExt, Sender, ServerActor, ServerMessage, Shares, SoulseekRs, - TcpStream, error, info, mpsc, thread, trace, warn, + Listen, Peer, PeerRegistry, Receiver, Result, RwLock, RwLockExt, Sender, + ServerActor, ServerMessage, Shares, SoulseekRs, TcpStream, error, info, + mpsc, thread, trace, warn, }; /// Ceiling on the wait for a login verdict. Generous enough for a slow server @@ -282,27 +282,12 @@ impl Client { own_username, ); - match download_peer.download_file( - client_context.clone(), - None, - None, - ) { + match download_peer.download_file(client_context, None, None) { Ok((download, filename)) => { trace!( "[client] downloaded {} bytes {:?} ", filename, download.size ); - let _ = download.sender.send(DownloadStatus::Completed); - match client_context.write_safe() { - Ok(mut ctx) => ctx.update_download_with_status( - download.token, - DownloadStatus::Completed, - ), - Err(e) => error!( - "[client] connect_to_peer F write: {}", - e - ), - } } Err(e) => { trace!("[client] failed to download: {}", e); diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index bedf5a3..81dcd8e 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -135,52 +135,33 @@ impl Client { } } ClientOperation::DownloadFromPeer(token, peer, allowed) => { - let maybe_download = match client_context.read_safe() { - Ok(ctx) => { - ctx.get_download_by_token(token).cloned() - } + let maybe_download = match client_context.write_safe() { + Ok(mut ctx) => ctx + .downloads + .claim_for_peer(token, &peer.username), Err(e) => { - error!("[client] DownloadFromPeer read: {}", e); + error!( + "[client] DownloadFromPeer write: {}", + e + ); continue; } }; - let own_username = own_username.clone(); - let client_context_clone = client_context.clone(); - trace!( "[client] DownloadFromPeer token: {} peer: {:?}", token, peer ); let Some(download) = maybe_download else { - error!( - "Can't find download with token {:?}", - token + debug!( + "[client] transfer token {} is missing, belongs \ + to another peer, or is already claimed; ignoring", + token, ); continue; }; - if matches!( - download.status, - DownloadStatus::InProgress { .. } - | DownloadStatus::Completed - | DownloadStatus::Cancelled - ) { - debug!( - "[client] transfer token {} already claimed; \ - ignoring replayed TransferResponse", - token - ); - continue; - } - if let Ok(mut ctx) = client_context.write_safe() { - ctx.update_download_with_status( - token, - DownloadStatus::InProgress { - bytes_downloaded: 0, - total_bytes: download.size, - speed_bytes_per_sec: 0.0, - }, - ); - } + + let own_username = own_username.clone(); + let client_context = client_context.clone(); thread::spawn(move || { let download_peer = DownloadPeer::new( @@ -191,35 +172,12 @@ impl Client { allowed, own_username, ); - let Some(filename) = - download.filename.split('\\').next_back() - else { - error!( - "Cant find filename to save download: {:?}", - download.filename - ); - return; - }; match download_peer.download_file( - client_context_clone.clone(), + client_context, Some(download.clone()), None, ) { Ok((download, filename)) => { - let _ = download - .sender - .send(DownloadStatus::Completed); - match client_context_clone.write_safe() { - Ok(mut ctx) => ctx - .update_download_with_status( - download.token, - DownloadStatus::Completed, - ), - Err(e) => error!( - "[client] download complete write: {}", - e - ), - } info!( "Successfully downloaded {} bytes to {}", download.size, filename @@ -227,24 +185,9 @@ impl Client { } Err(DownloadError::Cancelled) => {} Err(e) => { - let reason = Some(e.to_string()); - let _ = download.sender.send( - DownloadStatus::Failed(reason.clone()), - ); - match client_context_clone.write_safe() { - Ok(mut ctx) => ctx - .update_download_with_status( - download.token, - DownloadStatus::Failed(reason), - ), - Err(e) => error!( - "[client] download failed write: {}", - e - ), - } error!( "Failed to download file '{}' from {}:{} (token: {}) - Error: {}", - filename, + download.filename, peer.host, peer.port, download.token, diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs index 1c16b42..fb97d6c 100644 --- a/soulseek-rs-lib/src/client/tests.rs +++ b/soulseek-rs-lib/src/client/tests.rs @@ -567,16 +567,17 @@ fn a_replayed_transfer_response_does_not_start_a_second_transfer() { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); let port = u32::from(listener.local_addr().unwrap().port()); + let download_dir = std::env::temp_dir() + .join(format!("soulseek-replayed-transfer-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&download_dir); + std::fs::create_dir_all(&download_dir).unwrap(); let client = Client::new("u", "p"); let (sender, _receiver) = mpsc::channel(); - client.context.write().unwrap().add_download(download( - "peer", - "f.mp3", - 9, - DownloadStatus::Queued, - sender, - )); + let mut queued = + download("peer", "f.mp3", 9, DownloadStatus::Queued, sender); + queued.download_directory = download_dir.display().to_string(); + client.context.write().unwrap().add_download(queued); let (ops_tx, ops_rx) = mpsc::channel(); Client::listen_to_client_operations( @@ -625,6 +626,7 @@ fn a_replayed_transfer_response_does_not_start_a_second_transfer() { connections, 1, "a replayed TransferResponse must not dial the peer again" ); + let _ = std::fs::remove_dir_all(download_dir); } #[test] diff --git a/soulseek-rs-lib/src/download_store.rs b/soulseek-rs-lib/src/download_store.rs index 3cd9699..02f274a 100644 --- a/soulseek-rs-lib/src/download_store.rs +++ b/soulseek-rs-lib/src/download_store.rs @@ -61,13 +61,45 @@ impl DownloadStore { let Some(download) = self.get_by_token_mut(token) else { return false; }; - if matches!(download.status, DownloadStatus::Cancelled) { + if download.status.is_terminal() + || matches!( + (&download.status, &status), + ( + DownloadStatus::Paused { .. }, + DownloadStatus::InProgress { .. } + ) + ) + { return false; } download.status = status; true } + /// Atomically reserve a queued download for the peer that owns it. + /// + /// Every file-connection direction uses this before opening a partial, so + /// two sockets cannot both observe `Queued` and write the same target. + pub fn claim_for_peer( + &mut self, + token: u32, + username: &str, + ) -> Option { + let download = self.get_by_token_mut(token)?; + if download.username != username + || !matches!(download.status, DownloadStatus::Queued) + { + return None; + } + + download.status = DownloadStatus::InProgress { + bytes_downloaded: 0, + total_bytes: download.size, + speed_bytes_per_sec: 0.0, + }; + Some(download.clone()) + } + pub fn cancel_by_file( &mut self, username: &str, @@ -373,6 +405,52 @@ mod tests { )); } + #[test] + fn progress_cannot_implicitly_resume_a_paused_download() { + let mut store = DownloadStore::new(); + store.add(make_download( + 1, + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100, + }, + )); + + assert!(!store.update_status( + 1, + DownloadStatus::InProgress { + bytes_downloaded: 50, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + )); + assert!(matches!( + store.get_by_token(1).unwrap().status, + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100 + } + )); + } + + #[test] + fn a_download_can_only_be_claimed_once_and_by_its_peer() { + let mut store = DownloadStore::new(); + store.add(make_download(1, DownloadStatus::Queued)); + + assert!(store.claim_for_peer(1, "other").is_none()); + let claimed = store.claim_for_peer(1, "peer").unwrap(); + assert!(matches!( + claimed.status, + DownloadStatus::InProgress { + bytes_downloaded: 0, + total_bytes: 100, + speed_bytes_per_sec: 0.0 + } + )); + assert!(store.claim_for_peer(1, "peer").is_none()); + } + #[test] fn remove_queued_skips_active_downloads() { let mut store = DownloadStore::new(); diff --git a/soulseek-rs-lib/src/peer/download_peer.rs b/soulseek-rs-lib/src/peer/download_peer.rs index b941d32..034e54f 100644 --- a/soulseek-rs-lib/src/peer/download_peer.rs +++ b/soulseek-rs-lib/src/peer/download_peer.rs @@ -34,10 +34,9 @@ pub enum DownloadError { DownloadInfoMissing(u32), FileWriteError(io::Error), PathResolutionError(String), - InvalidTokenBytes, LockPoisoned, Cancelled, - IncompleteDownload { received: usize, expected: usize }, + IncompleteDownload { received: u64, expected: u64 }, } impl std::fmt::Display for DownloadError { @@ -60,9 +59,6 @@ impl std::fmt::Display for DownloadError { Self::PathResolutionError(msg) => { write!(f, "Path resolution error: {msg}") } - Self::InvalidTokenBytes => { - write!(f, "Invalid token bytes received") - } Self::LockPoisoned => write!(f, "Lock poisoned"), Self::Cancelled => write!(f, "Download cancelled"), Self::IncompleteDownload { received, expected } => write!( @@ -147,8 +143,9 @@ impl PartFile { .map_err(DownloadError::FileWriteError)?; } - // A `.part` that already meets or exceeds the expected size is left over - // from a different (or corrupted) transfer, not a resume point. + // A `.part` at or beyond the advertised size is restarted. We do not + // persist its username, full remote path, or prior expected size, so + // equality alone cannot prove that its bytes belong to this transfer. let on_disk = fs::metadata(&path).map_or(0, |m| m.len()); let written = if on_disk < download.size { on_disk } else { 0 }; @@ -170,8 +167,9 @@ impl PartFile { /// Peers can coalesce trailing bytes into the last chunk, so overshoot past /// the expected size is dropped rather than written. fn write(&mut self, data: &[u8]) -> Result<(), DownloadError> { - let remaining = (self.expected - self.written) as usize; - let data = &data[..data.len().min(remaining)]; + let remaining = self.expected - self.written; + let write_len = remaining.min(data.len() as u64) as usize; + let data = &data[..write_len]; self.file .write_all(data) .map_err(DownloadError::FileWriteError)?; @@ -193,8 +191,8 @@ impl PartFile { fn finish(self) -> Result { if !self.is_complete() { return Err(DownloadError::IncompleteDownload { - received: self.written as usize, - expected: self.expected as usize, + received: self.written, + expected: self.expected, }); } drop(self.file); @@ -319,77 +317,73 @@ impl DownloadPeer { Ok((download, part)) } - fn begin_pierced_download( - &self, - data: &[u8], - stream: &mut TcpStream, - client_context: &Arc>, - ) -> Result<(Download, PartFile), DownloadError> { - let token_bytes = - data.get(0..4).ok_or(DownloadError::InvalidTokenBytes)?; - let token_array: [u8; 4] = token_bytes - .try_into() - .map_err(|_| DownloadError::InvalidTokenBytes)?; - let token_u32 = u32::from_le_bytes(token_array); - - trace!( - "[download_peer:{}] got token: {} from data chunk", - self.username, token_u32 - ); - - let client_guard = client_context - .read() - .map_err(|_| DownloadError::LockPoisoned)?; - let download_info = - client_guard.get_download_by_token(token_u32).cloned(); - drop(client_guard); - - let download = - download_info.ok_or(DownloadError::TokenNotFound(token_u32))?; - - Self::start_transfer(download, stream, client_context) - } - fn read_download_stream( &self, stream: &mut TcpStream, client_context: &Arc>, download: Option, + matched_download: &mut Option, ) -> Result<(Download, String), DownloadError> { let mut read_buffer = vec![0u8; READ_BUFFER_SIZE]; let mut bytes_since_last_update = 0usize; let mut last_update_time = Instant::now(); - let mut last_data = Instant::now(); - stream - .set_read_timeout(Some(CANCEL_POLL)) - .map_err(DownloadError::ConnectionFailed)?; trace!( "[download_peer:{}] Starting to read data from peer", self.username ); - let mut transfer = match download { - Some(dl) => Some(Self::start_transfer(dl, stream, client_context)?), - None => None, + let (download, mut part) = match download { + Some(download) => { + Self::start_transfer(download, stream, client_context)? + } + None if !self.no_pierce => { + let mut token = [0u8; 4]; + stream + .read_exact(&mut token) + .map_err(DownloadError::StreamReadError)?; + let token = u32::from_le_bytes(token); + trace!( + "[download_peer:{}] got token: {} from data chunk", + self.username, token + ); + let download = client_context + .write() + .map_err(|_| DownloadError::LockPoisoned)? + .downloads + .claim_for_peer(token, &self.username) + .ok_or(DownloadError::TokenNotFound(token))?; + *matched_download = Some(download.clone()); + Self::start_transfer(download, stream, client_context)? + } + None => { + return Err(DownloadError::DownloadInfoMissing(self.token)); + } }; + stream + .set_read_timeout(Some(CANCEL_POLL)) + .map_err(DownloadError::ConnectionFailed)?; + let mut last_data = Instant::now(); loop { - if let Some((dl, _)) = &transfer { - match Self::wait_while_paused(client_context, dl) { - Ok(true) => last_data = Instant::now(), - Ok(false) => {} - Err(e) => { - if matches!(e, DownloadError::Cancelled) - && let Some((_, part)) = transfer.take() - { - part.discard(); - } - return Err(e); + match Self::wait_while_paused(client_context, &download) { + Ok(true) => last_data = Instant::now(), + Ok(false) => {} + Err(error) => { + if matches!(error, DownloadError::Cancelled) { + part.discard(); } + return Err(error); } } + // A zero-byte transfer needs no payload bytes. There is no offset + // acknowledgement in Soulseek's transfer protocol, so the uploader + // waits for us to close and we must finalize without another read. + if part.is_complete() { + break; + } + match stream.read(&mut read_buffer) { Ok(0) => { trace!( @@ -400,24 +394,7 @@ impl DownloadPeer { } Ok(bytes_read) => { last_data = Instant::now(); - let data = &read_buffer[..bytes_read]; - - if transfer.is_none() && !self.no_pierce { - transfer = Some(self.begin_pierced_download( - data, - stream, - client_context, - )?); - continue; - } - - let Some((dl, part)) = transfer.as_mut() else { - return Err(DownloadError::DownloadInfoMissing( - self.token, - )); - }; - - part.write(data)?; + part.write(&read_buffer[..bytes_read])?; bytes_since_last_update += bytes_read; if bytes_since_last_update >= PROGRESS_INTERVAL_BYTES { @@ -429,7 +406,7 @@ impl DownloadPeer { }; Self::report_progress( client_context, - dl, + &download, part.written, speed, ); @@ -457,11 +434,9 @@ impl DownloadPeer { self.username ); - let Some((download, part)) = transfer else { - return Err(DownloadError::DownloadInfoMissing(self.token)); - }; - - Ok((download, part.finish()?)) + let final_path = + Self::finish_transfer(client_context, &download, part)?; + Ok((download, final_path)) } fn report_progress( @@ -481,7 +456,7 @@ impl DownloadPeer { ); } - fn send_download_status( + pub(crate) fn send_download_status( client_context: &Arc>, download: &Download, status: DownloadStatus, @@ -496,6 +471,56 @@ impl DownloadPeer { } } + /// Promote a complete partial and publish `Completed` as one operation with + /// respect to cancellation. Holding the store write lock across the rename + /// gives both possible races deterministic outcomes: cancellation marks the + /// row first and discards the partial, or completion lands first and a later + /// cancellation is refused. + fn finish_transfer( + client_context: &Arc>, + download: &Download, + part: PartFile, + ) -> Result { + loop { + let mut context = client_context + .write() + .map_err(|_| DownloadError::LockPoisoned)?; + let status = context + .get_download_by_token(download.token) + .map(|download| download.status.clone()) + .ok_or(DownloadError::TokenNotFound(download.token))?; + + if matches!(status, DownloadStatus::Cancelled) { + drop(context); + part.discard(); + return Err(DownloadError::Cancelled); + } + if matches!(status, DownloadStatus::Paused { .. }) { + drop(context); + if let Err(error) = + Self::wait_while_paused(client_context, download) + { + if matches!(error, DownloadError::Cancelled) { + part.discard(); + } + return Err(error); + } + continue; + } + + let final_path = part.finish()?; + if !context + .downloads + .update_status(download.token, DownloadStatus::Completed) + { + return Err(DownloadError::TokenNotFound(download.token)); + } + drop(context); + let _ = download.sender.send(DownloadStatus::Completed); + return Ok(final_path); + } + } + fn wait_while_paused( client_context: &Arc>, download: &Download, @@ -527,36 +552,54 @@ impl DownloadPeer { download: Option, stream: Option, ) -> Result<(Download, String), DownloadError> { - trace!( - "[download_peer:{}] download_file: download is present?: {:?}, stream is present?: {:?}, no_pierce: {}", - self.username, - download.is_some(), - stream.is_some(), - self.no_pierce - ); + let mut matched_download = download.clone(); + let result: Result<_, DownloadError> = (|| { + trace!( + "[download_peer:{}] download_file: download is present?: {:?}, stream is present?: {:?}, no_pierce: {}", + self.username, + download.is_some(), + stream.is_some(), + self.no_pierce + ); - let mut stream = match stream { - Some(s) => { - Self::apply_transfer_deadlines(&s)?; - s - } - None => self.establish_connection()?, - }; + let mut stream = match stream { + Some(s) => { + Self::apply_transfer_deadlines(&s)?; + s + } + None => self.establish_connection()?, + }; - trace!("[download_peer:{}] connected", self.username); + trace!("[download_peer:{}] connected", self.username); - self.perform_handshake(&mut stream)?; - trace!("[download_peer:{}] handshake completed", self.username); + self.perform_handshake(&mut stream)?; + trace!("[download_peer:{}] handshake completed", self.username); - let (download, final_path) = - self.read_download_stream(&mut stream, &client_context, download)?; + let (download, final_path) = self.read_download_stream( + &mut stream, + &client_context, + download, + &mut matched_download, + )?; - trace!( - "[download_peer:{}] download completed successfully: {} bytes, saved to: {}", - self.username, download.size, final_path - ); + trace!( + "[download_peer:{}] download completed successfully: {} bytes, saved to: {}", + self.username, download.size, final_path + ); - Ok((download, final_path)) + Ok((download, final_path)) + })(); + if let Err(error) = &result + && !matches!(error, DownloadError::Cancelled) + && let Some(download) = matched_download + { + Self::send_download_status( + &client_context, + &download, + DownloadStatus::Failed(Some(error.to_string())), + ); + } + result } } @@ -580,7 +623,7 @@ mod tests { fn download_into(dir: &std::path::Path, size: u64) -> Download { Download { username: "peer".to_string(), - filename: "song.mp3".to_string(), + filename: "file.bin".to_string(), token: 1, size, download_directory: dir.display().to_string(), @@ -596,13 +639,13 @@ mod tests { let dir = scratch_dir("discard"); let mut part = PartFile::open(&download_into(&dir, 8)).unwrap(); part.write(b"data").unwrap(); - let path = dir.join("song.mp3.part"); + let path = dir.join("file.bin.part"); assert!(path.exists()); part.discard(); assert!(!path.exists()); - assert!(!dir.join("song.mp3").exists()); + assert!(!dir.join("file.bin").exists()); let _ = std::fs::remove_dir_all(&dir); } @@ -625,7 +668,7 @@ mod tests { .write() .unwrap() .downloads - .resume_by_file("peer", "song.mp3") + .resume_by_file("peer", "file.bin") ); }) }; @@ -681,6 +724,131 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn a_pierced_short_transfer_reports_failure() { + let dir = scratch_dir("pierced-short-transfer"); + let (sender, receiver) = mpsc::channel(); + let mut download = download_into(&dir, 10); + download.sender = sender; + let context = Arc::new(RwLock::new(ClientContext::new())); + context.write().unwrap().add_download(download.clone()); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let client_stream = + std::net::TcpStream::connect(listener.local_addr().unwrap()) + .unwrap(); + let (mut uploader, _) = listener.accept().unwrap(); + uploader + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .unwrap(); + let peer = DownloadPeer::new( + download.username.clone(), + "127.0.0.1".into(), + 0, + 99, + false, + "me".into(), + ); + let worker_context = context.clone(); + let worker = std::thread::spawn(move || { + peer.download_file(worker_context, None, Some(client_stream)) + }); + + // Drain the framed PierceFirewall request before sending the raw token. + let mut frame_len = [0u8; 4]; + std::io::Read::read_exact(&mut uploader, &mut frame_len).unwrap(); + let mut frame = vec![0; u32::from_le_bytes(frame_len) as usize]; + std::io::Read::read_exact(&mut uploader, &mut frame).unwrap(); + + std::io::Write::write_all(&mut uploader, &download.token.to_le_bytes()) + .unwrap(); + + let mut offset = [0u8; 8]; + std::io::Read::read_exact(&mut uploader, &mut offset).unwrap(); + assert_eq!(u64::from_le_bytes(offset), 0); + std::io::Write::write_all(&mut uploader, b"0123").unwrap(); + std::net::TcpStream::shutdown(&uploader, std::net::Shutdown::Write) + .unwrap(); + drop(uploader); + + assert!(matches!( + worker.join().unwrap(), + Err(DownloadError::IncompleteDownload { + received: 4, + expected: 10 + }) + )); + assert!(matches!( + receiver.recv_timeout(std::time::Duration::from_secs(2)), + Ok(DownloadStatus::InProgress { .. }) + )); + assert!(matches!( + receiver.recv_timeout(std::time::Duration::from_secs(2)), + Ok(DownloadStatus::Failed(_)) + )); + assert!(matches!( + context + .read() + .unwrap() + .get_download_by_token(download.token) + .unwrap() + .status, + DownloadStatus::Failed(_) + )); + assert_eq!(std::fs::read(dir.join("file.bin.part")).unwrap(), b"0123"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_pierced_token_cannot_claim_another_peers_download() { + let dir = scratch_dir("token-peer-mismatch"); + let mut download = download_into(&dir, 10); + download.username = "victim".into(); + let context = Arc::new(RwLock::new(ClientContext::new())); + context.write().unwrap().add_download(download.clone()); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let client_stream = + std::net::TcpStream::connect(listener.local_addr().unwrap()) + .unwrap(); + let (mut attacker, _) = listener.accept().unwrap(); + let peer = DownloadPeer::new( + "attacker".into(), + "127.0.0.1".into(), + 0, + 100, + false, + "me".into(), + ); + let worker_context = context.clone(); + let worker = std::thread::spawn(move || { + peer.download_file(worker_context, None, Some(client_stream)) + }); + + let mut frame_len = [0u8; 4]; + std::io::Read::read_exact(&mut attacker, &mut frame_len).unwrap(); + let mut frame = vec![0; u32::from_le_bytes(frame_len) as usize]; + std::io::Read::read_exact(&mut attacker, &mut frame).unwrap(); + std::io::Write::write_all(&mut attacker, &download.token.to_le_bytes()) + .unwrap(); + + assert!(matches!( + worker.join().unwrap(), + Err(DownloadError::TokenNotFound(1)) + )); + assert!(!dir.join("file.bin.part").exists()); + assert!(matches!( + context + .read() + .unwrap() + .get_download_by_token(download.token) + .unwrap() + .status, + DownloadStatus::Queued + )); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn start_transfer_refuses_a_cancelled_download() { let dir = scratch_dir("refuse"); @@ -698,7 +866,7 @@ mod tests { DownloadPeer::start_transfer(download, &mut stream, &context); assert!(matches!(outcome, Err(DownloadError::Cancelled))); - assert!(!dir.join("song.mp3.part").exists(), "no .part is opened"); + assert!(!dir.join("file.bin.part").exists(), "no .part is opened"); let _ = std::fs::remove_dir_all(&dir); } @@ -722,7 +890,7 @@ mod tests { #[test] fn a_partial_file_sets_the_resume_offset() { let dir = scratch_dir("resume"); - std::fs::write(dir.join("song.mp3.part"), b"0123").unwrap(); + std::fs::write(dir.join("file.bin.part"), b"0123").unwrap(); let mut part = PartFile::open(&download_into(&dir, 10)).unwrap(); assert_eq!(part.written, 4, "resume from what is already on disk"); @@ -732,17 +900,76 @@ mod tests { assert_eq!(std::fs::read(&final_path).unwrap(), b"0123456789"); assert!( - !dir.join("song.mp3.part").exists(), + !dir.join("file.bin.part").exists(), "the .part is renamed, not left behind" ); let _ = std::fs::remove_dir_all(dir); } + #[test] + fn an_unidentified_exact_size_partial_restarts_from_zero() { + // With no persisted transfer identity, length alone cannot distinguish + // a final write awaiting rename from unrelated same-sized content. + let dir = scratch_dir("complete"); + std::fs::write(dir.join("file.bin.part"), b"0123456789").unwrap(); + + let mut part = PartFile::open(&download_into(&dir, 10)).unwrap(); + assert_eq!(part.written, 0); + assert!(!part.is_complete()); + + part.write(b"abcdefghij").unwrap(); + let final_path = part.finish().unwrap(); + assert_eq!(std::fs::read(final_path).unwrap(), b"abcdefghij"); + assert!(!dir.join("file.bin.part").exists()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn cancellation_while_a_complete_transfer_is_paused_discards_its_part() { + let dir = scratch_dir("cancel-paused-finish"); + let mut download = download_into(&dir, 10); + let mut part = PartFile::open(&download).unwrap(); + part.write(b"0123456789").unwrap(); + download.status = DownloadStatus::Paused { + bytes_downloaded: 10, + total_bytes: 10, + }; + let context = Arc::new(RwLock::new(ClientContext::new())); + context.write().unwrap().add_download(download.clone()); + let worker_context = context.clone(); + let worker_download = download.clone(); + + let worker = std::thread::spawn(move || { + DownloadPeer::finish_transfer( + &worker_context, + &worker_download, + part, + ) + }); + + std::thread::sleep(std::time::Duration::from_millis(50)); + assert!( + context + .write() + .unwrap() + .downloads + .cancel_by_file(&download.username, &download.filename) + .is_some() + ); + + assert!(matches!( + worker.join().unwrap(), + Err(DownloadError::Cancelled) + )); + assert!(!dir.join("file.bin.part").exists()); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn an_oversized_partial_file_restarts_from_zero() { // Left over from a different transfer — not a valid resume point. let dir = scratch_dir("stale"); - std::fs::write(dir.join("song.mp3.part"), vec![9u8; 40]).unwrap(); + std::fs::write(dir.join("file.bin.part"), vec![9u8; 40]).unwrap(); let mut part = PartFile::open(&download_into(&dir, 10)).unwrap(); assert_eq!(part.written, 0); @@ -770,11 +997,11 @@ mod tests { }) )); assert_eq!( - std::fs::read(dir.join("song.mp3.part")).unwrap(), + std::fs::read(dir.join("file.bin.part")).unwrap(), b"01234", "the partial stays on disk for the next attempt" ); - assert!(!dir.join("song.mp3").exists()); + assert!(!dir.join("file.bin").exists()); let _ = std::fs::remove_dir_all(dir); } diff --git a/soulseek-rs-lib/src/peer/listen.rs b/soulseek-rs-lib/src/peer/listen.rs index 20e747d..eff0b7b 100644 --- a/soulseek-rs-lib/src/peer/listen.rs +++ b/soulseek-rs-lib/src/peer/listen.rs @@ -12,7 +12,7 @@ use crate::peer::{ConnectionType, DownloadError, DownloadPeer, Peer}; use crate::types::Download; use crate::utils::lock::RwLockExt; use crate::utils::semaphore::{Permit, Semaphore}; -use crate::{DownloadStatus, debug, error, info, trace}; +use crate::{debug, error, info, trace}; /// How long to wait before accepting again after a failure. /// @@ -130,20 +130,10 @@ fn wait_for_download_registration( deadline: Instant, ) -> Option { loop { - let download = match client_context.read_safe() { - Ok(context) => context - .get_download_by_token(token) - .filter(|d| { - d.username == username - && !matches!( - d.status, - DownloadStatus::InProgress { .. } - | DownloadStatus::Paused { .. } - | DownloadStatus::Completed - | DownloadStatus::Cancelled - ) - }) - .cloned(), + let download = match client_context.write_safe() { + Ok(mut context) => { + context.downloads.claim_for_peer(token, username) + } Err(e) => { error!("[listener] client context lock: {}", e); return None; @@ -224,8 +214,6 @@ fn handle_file_connection( Instant::now() + TOKEN_REGISTRATION_TIMEOUT, ) }); - let failure_token = download.as_ref().map(|d| d.token); - let download_peer = DownloadPeer::new( peer.username.clone(), peer.host.clone(), @@ -241,16 +229,6 @@ fn handle_file_connection( Some(stream), ) { Ok((download, filename)) => { - let _ = download.sender.send(DownloadStatus::Completed); - match context.client_context.write_safe() { - Ok(mut ctx) => ctx.update_download_with_status( - download.token, - DownloadStatus::Completed, - ), - Err(e) => { - error!("[listener] handle_file_connection write: {}", e); - } - } info!( "Successfully downloaded {} bytes to {}", download.size, filename @@ -262,22 +240,6 @@ fn handle_file_connection( "Failed to download file from {}:{} (token: {}) - Error: {}", peer.host, peer.port, token, e ); - // A failed incoming transfer (e.g. a truncated/incomplete download) - // must not leave the download stuck as Queued/InProgress forever. - if let Some(failure_token) = failure_token { - match context.client_context.write_safe() { - Ok(mut ctx) => ctx.update_download_with_status( - failure_token, - DownloadStatus::Failed(Some(e.to_string())), - ), - Err(e) => { - error!( - "[listener] handle_file_connection fail write: {}", - e - ); - } - } - } } } } @@ -491,7 +453,7 @@ impl Listen { #[cfg(test)] mod tests { use super::*; - use crate::types::DownloadMetadata; + use crate::types::{DownloadMetadata, DownloadStatus}; use std::io::Write; use std::net::TcpListener; use std::sync::mpsc; diff --git a/soulseek-rs-lib/src/peer/upload_peer.rs b/soulseek-rs-lib/src/peer/upload_peer.rs index 57138d4..36e8978 100644 --- a/soulseek-rs-lib/src/peer/upload_peer.rs +++ b/soulseek-rs-lib/src/peer/upload_peer.rs @@ -5,7 +5,7 @@ use std::io::{self, Read, Seek, SeekFrom, Write}; use std::net::{TcpStream, ToSocketAddrs}; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::time::Duration; +use std::time::{Duration, Instant}; use crate::message::server::MessageFactory; use crate::peer::ConnectionType; @@ -73,19 +73,21 @@ pub fn serve_file( let mut offset = [0u8; 8]; stream.read_exact(&mut offset)?; let offset = u64::from_le_bytes(offset); - if offset > 0 { - file.seek(SeekFrom::Start(offset))?; - bytes_sent.fetch_add(offset, Ordering::Relaxed); + let file_size = file.metadata()?.len(); + if offset > file_size { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("download offset {offset} exceeds file size {file_size}"), + )); } + file.seek(SeekFrom::Start(offset))?; + bytes_sent.fetch_add(offset, Ordering::Relaxed); let mut buffer = vec![0u8; 64 * 1024]; let mut streamed = 0u64; loop { if cancel.load(Ordering::Relaxed) { - return Err(io::Error::new( - io::ErrorKind::Interrupted, - "upload cancelled", - )); + return Err(io::ErrorKind::Interrupted.into()); } let read = file.read(&mut buffer)?; if read == 0 { @@ -97,11 +99,41 @@ pub fn serve_file( } stream.flush()?; - // Half-close instead of lingering: the FIN tells the downloader it has the - // whole file, and TCP still delivers everything written before it. Sleeping - // here held an upload slot for an extra half-second per transfer, which with - // a slot cap is time no other peer can use. - stream.shutdown(std::net::Shutdown::Write).ok(); + // Soulseek assigns closing the file-transfer connection to the downloader + // after it has read exactly the advertised number of bytes. Poll while + // waiting for that EOF so cancelling a finished-but-unacknowledged upload + // remains prompt rather than waiting for the full socket timeout. + stream.set_read_timeout(Some(Duration::from_millis(200)))?; + let close_deadline = Instant::now() + Duration::from_secs(30); + let mut unexpected = [0u8; 1]; + loop { + if cancel.load(Ordering::Relaxed) { + return Err(io::ErrorKind::Interrupted.into()); + } + match stream.read(&mut unexpected) { + Ok(0) => break, + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected data after download offset", + )); + } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => + { + if Instant::now() >= close_deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "downloader did not close the file connection", + )); + } + } + Err(error) => return Err(error), + } + } trace!("[upload] served {} to {}:{}", path.display(), host, port); Ok(streamed) } @@ -111,8 +143,9 @@ mod tests { use super::serve_file; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; - use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + use std::sync::{Arc, mpsc}; + use std::time::Duration; /// Play the downloader: accept the F connection, drain the PeerInit frame /// and the raw token, then reply with the 8-byte START_DOWNLOAD offset. @@ -143,77 +176,152 @@ mod tests { path } + fn spawn_uploader( + listener: &TcpListener, + path: std::path::PathBuf, + token: u32, + cancel: Arc, + ) -> ( + Arc, + std::thread::JoinHandle>, + ) { + let port = u32::from(listener.local_addr().unwrap().port()); + let bytes_sent = Arc::new(AtomicU64::new(0)); + let sent_counter = bytes_sent.clone(); + let uploader = std::thread::spawn(move || { + serve_file( + "127.0.0.1", + port, + "me", + token, + &path, + &sent_counter, + &cancel, + ) + }); + (bytes_sent, uploader) + } + #[test] fn serve_file_streams_the_file_over_an_f_connection() { let content: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); let path = scratch_file("stream", &content); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = u32::from(listener.local_addr().unwrap().port()); - - let bytes_sent = Arc::new(AtomicU64::new(0)); - let sent_counter = bytes_sent.clone(); let dir = path.parent().unwrap().to_path_buf(); - let uploader = std::thread::spawn(move || { - serve_file( - "127.0.0.1", - port, - "me", - 777, - &path, - &sent_counter, - &AtomicBool::new(false), - ) - }); + let (bytes_sent, uploader) = + spawn_uploader(&listener, path, 777, Arc::default()); let (mut stream, token) = accept_and_send_offset(&listener, 0); assert_eq!(token, 777); - let mut received = Vec::new(); - stream.read_to_end(&mut received).unwrap(); + let mut received = vec![0; content.len()]; + stream.read_exact(&mut received).unwrap(); assert_eq!(received, content); - let _ = uploader.join(); + assert!( + !uploader.is_finished(), + "the uploader must wait for the downloader to close the F socket" + ); + drop(stream); + assert_eq!(uploader.join().unwrap().unwrap(), 4096); assert_eq!(bytes_sent.load(Ordering::Relaxed), 4096); let _ = std::fs::remove_dir_all(dir); } #[test] - fn serve_file_resumes_from_a_non_zero_offset() { + fn serve_file_accepts_valid_resume_offsets() { let content: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); - let path = scratch_file("resume", &content); + for offset in [1000, content.len()] { + let path = + scratch_file(&format!("valid-offset-{offset}"), &content); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let dir = path.parent().unwrap().to_path_buf(); + let (bytes_sent, uploader) = + spawn_uploader(&listener, path, 779, Arc::default()); + + let (mut stream, _) = + accept_and_send_offset(&listener, offset as u64); + let mut received = vec![0; content.len() - offset]; + stream.read_exact(&mut received).unwrap(); + assert_eq!(received, content[offset..]); + drop(stream); + + assert_eq!( + uploader.join().unwrap().unwrap(), + (content.len() - offset) as u64 + ); + assert_eq!( + bytes_sent.load(Ordering::Relaxed), + content.len() as u64 + ); + let _ = std::fs::remove_dir_all(dir); + } + } + + #[test] + fn serve_file_rejects_an_offset_past_end_of_file() { + let content: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); + let path = scratch_file("invalid-offset", &content); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = u32::from(listener.local_addr().unwrap().port()); - - let bytes_sent = Arc::new(AtomicU64::new(0)); - let sent_counter = bytes_sent.clone(); let dir = path.parent().unwrap().to_path_buf(); + let (bytes_sent, uploader) = + spawn_uploader(&listener, path, 780, Arc::default()); + + let (mut stream, _) = accept_and_send_offset(&listener, 4097); + let mut received = Vec::new(); + stream.read_to_end(&mut received).unwrap(); + assert!(received.is_empty()); + + let error = uploader.join().unwrap().unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert_eq!( + bytes_sent.load(Ordering::Relaxed), + 0, + "an invalid prefix must not be counted as uploaded" + ); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn serve_file_cancels_while_waiting_for_the_downloader_to_close() { + let content: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); + let path = scratch_file("cancel-close-wait", &content); + let dir = path.parent().unwrap().to_path_buf(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = u32::from(listener.local_addr().unwrap().port()); + let cancel = Arc::new(AtomicBool::new(false)); + let cancel_flag = cancel.clone(); + let (result_tx, result_rx) = mpsc::channel(); + let uploader = std::thread::spawn(move || { - serve_file( + let result = serve_file( "127.0.0.1", port, "me", - 779, + 782, &path, - &sent_counter, - &AtomicBool::new(false), - ) + &AtomicU64::new(0), + &cancel_flag, + ); + let _ = result_tx.send(result); }); - // The downloader already holds the first 1000 bytes. - let (mut stream, _) = accept_and_send_offset(&listener, 1000); + let (mut stream, _) = accept_and_send_offset(&listener, 0); + let mut received = vec![0; content.len()]; + stream.read_exact(&mut received).unwrap(); + assert_eq!(received, content); - let mut received = Vec::new(); - stream.read_to_end(&mut received).unwrap(); - assert_eq!(received, content[1000..], "only the missing tail is sent"); + cancel.store(true, Ordering::Relaxed); + let error = result_rx + .recv_timeout(Duration::from_secs(2)) + .expect("cancellation must wake the close wait") + .unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::Interrupted); + drop(stream); let _ = uploader.join(); - assert_eq!( - bytes_sent.load(Ordering::Relaxed), - 4096, - "progress counts the resumed prefix" - ); let _ = std::fs::remove_dir_all(dir); } @@ -223,23 +331,10 @@ mod tests { let dir = path.parent().unwrap().to_path_buf(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = u32::from(listener.local_addr().unwrap().port()); - // Cancelled before the copy loop starts: the stream must abort with // Interrupted instead of serving the whole file. let cancel = Arc::new(AtomicBool::new(true)); - let cancel_flag = cancel; - let uploader = std::thread::spawn(move || { - serve_file( - "127.0.0.1", - port, - "me", - 778, - &path, - &AtomicU64::new(0), - &cancel_flag, - ) - }); + let (_, uploader) = spawn_uploader(&listener, path, 778, cancel); let (mut stream, _) = accept_and_send_offset(&listener, 0); diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index f4e7725..4f6149c 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -1182,7 +1182,7 @@ fn an_interrupted_download_resumes_from_its_partial_file() { client.connect().expect("connect"); assert!(client.login().expect("login")); - let filename = "resumed_song.mp3"; + let filename = "resumed_file.bin"; let content: Vec = (0..2000u32).map(|i| (i % 251) as u8).collect(); let size = content.len() as u64; let already_have = 800usize; diff --git a/soulseek-rs-lib/tests/resume_e2e.rs b/soulseek-rs-lib/tests/resume_e2e.rs new file mode 100644 index 0000000..2f5cf55 --- /dev/null +++ b/soulseek-rs-lib/tests/resume_e2e.rs @@ -0,0 +1,408 @@ +//! Server-independent end-to-end coverage for downloads that resume. +//! +//! The subject is a real `Client` with its real peer listener and actor stack. +//! A loopback socket stands in for a Soulseek peer and speaks the public wire +//! format. The configured server is deliberately idle: peer transfers do not +//! require a login once the control connection is registered. + +use std::fs; +use std::io::{Read, Write}; +use std::net::{Shutdown, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::Receiver; +use std::time::{Duration, Instant}; + +use soulseek_rs::message::Message; +use soulseek_rs::{ + Client, ClientSettings, ClientVersion, DownloadStatus, PeerAddress, +}; + +const IO_TIMEOUT: Duration = Duration::from_secs(10); + +struct ScratchDir(PathBuf); + +impl ScratchDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "soulseek-resume-e2e-{}-{label}", + std::process::id() + )); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).expect("create scratch directory"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for ScratchDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +struct Subject { + client: Client, + peer_addr: String, + // Keeping a server listener alive is enough for ServerActor's TCP connect. + // No login or server messages are needed by the peer-transfer path. + _idle_server: TcpListener, +} + +impl Subject { + fn new(username: &str) -> Self { + let idle_server = + TcpListener::bind("127.0.0.1:0").expect("bind idle server"); + let server_addr = + idle_server.local_addr().expect("idle server address"); + let mut client = Client::with_settings(ClientSettings { + username: username.to_string(), + password: "unused".to_string(), + server_address: PeerAddress::new( + "127.0.0.1".to_string(), + server_addr.port(), + ), + enable_listen: true, + listen_port: 0, + shared_directories: Vec::new(), + version: ClientVersion::default(), + }); + client.connect().expect("connect subject"); + let port = client.listen_port().expect("subject listener port"); + Self { + client, + peer_addr: format!("127.0.0.1:{port}"), + _idle_server: idle_server, + } + } +} + +struct ControlPeer { + username: String, + subject_addr: String, + stream: TcpStream, +} + +impl ControlPeer { + fn register(subject: &Subject, username: &str) -> Self { + let mut stream = TcpStream::connect(&subject.peer_addr) + .expect("connect peer control socket"); + stream + .set_read_timeout(Some(IO_TIMEOUT)) + .expect("control read timeout"); + stream + .set_write_timeout(Some(IO_TIMEOUT)) + .expect("control write timeout"); + + // Code 15/16 is a positive registration fence. Receiving the response + // proves the inbound P connection has reached the peer actor and can + // receive QueueUpload messages from `Client::download`. + stream + .write_all(&peer_init_bytes(username, "P", 0)) + .expect("send P PeerInit"); + stream + .write_all(&Message::new().write_int32(15).get_buffer()) + .expect("send UserInfoRequest registration fence"); + stream.flush().expect("flush registration fence"); + let _ = expect_code(&mut stream, 16); + + Self { + username: username.to_string(), + subject_addr: subject.peer_addr.clone(), + stream, + } + } + + fn accept_queue_and_offer(&mut self, filename: &str, size: u64) -> u32 { + let mut queue = expect_code(&mut self.stream, 43); + queue.set_pointer(8); + assert_eq!(queue.read_string(), filename, "queued remote filename"); + + let token = 700_000; + let mut request = Message::new(); + request + .write_int32(40) + .write_int32(1) // upload from the peer to the subject + .write_int32(token) + .write_string(filename) + .write_int64(size); + self.stream + .write_all(&request.get_buffer()) + .expect("send TransferRequest"); + self.stream.flush().expect("flush TransferRequest"); + + let mut response = expect_code(&mut self.stream, 41); + response.set_pointer(8); + assert_eq!(response.read_int32(), token, "response transfer token"); + assert_eq!(response.read_int8(), 1, "transfer should be allowed"); + token + } + + fn open_file_transfer(&self, token: u32) -> (TcpStream, u64) { + let mut stream = TcpStream::connect(&self.subject_addr) + .expect("connect peer file socket"); + stream + .set_read_timeout(Some(IO_TIMEOUT)) + .expect("file read timeout"); + stream + .set_write_timeout(Some(IO_TIMEOUT)) + .expect("file write timeout"); + + let mut init = peer_init_bytes(&self.username, "F", token); + init.extend_from_slice(&token.to_le_bytes()); + stream.write_all(&init).expect("send F PeerInit and token"); + stream.flush().expect("flush F PeerInit and token"); + + let mut offset = [0u8; 8]; + stream + .read_exact(&mut offset) + .expect("read START_DOWNLOAD offset"); + (stream, u64::from_le_bytes(offset)) + } +} + +fn peer_init_bytes( + username: &str, + connection_type: &str, + token: u32, +) -> Vec { + Message::new() + .write_int8(1) + .write_string(username) + .write_string(connection_type) + .write_int32(token) + .get_buffer() +} + +fn read_framed(stream: &mut TcpStream) -> std::io::Result { + let mut len = [0u8; 4]; + stream.read_exact(&mut len)?; + let mut payload = vec![0u8; u32::from_le_bytes(len) as usize]; + stream.read_exact(&mut payload)?; + let mut bytes = len.to_vec(); + bytes.extend_from_slice(&payload); + Ok(Message::new_with_data(bytes)) +} + +fn expect_code(stream: &mut TcpStream, expected: u32) -> Message { + let deadline = Instant::now() + IO_TIMEOUT; + loop { + let message = read_framed(stream).unwrap_or_else(|error| { + panic!("read peer code {expected}: {error}") + }); + if message.get_message_code() == expected { + return message; + } + assert!( + Instant::now() < deadline, + "timed out waiting for peer code {expected}" + ); + } +} + +fn start_download( + subject: &Subject, + control: &mut ControlPeer, + filename: &str, + content_len: usize, + destination: &Path, +) -> (Receiver, TcpStream, u64) { + let (_download, statuses) = subject + .client + .download( + filename.to_string(), + control.username.clone(), + content_len as u64, + destination.display().to_string(), + ) + .expect("queue download"); + let token = control.accept_queue_and_offer(filename, content_len as u64); + let (file_stream, offset) = control.open_file_transfer(token); + (statuses, file_stream, offset) +} + +fn wait_for_status( + statuses: &Receiver, + description: &str, + wanted: impl Fn(&DownloadStatus) -> bool, +) -> DownloadStatus { + let deadline = Instant::now() + IO_TIMEOUT; + loop { + if let Ok(status) = statuses.recv_timeout(Duration::from_millis(100)) + && wanted(&status) + { + return status; + } + assert!( + Instant::now() < deadline, + "status channel did not report {description}" + ); + } +} + +fn assert_stored_status( + subject: &Subject, + filename: &str, + wanted: impl Fn(&DownloadStatus) -> bool, +) { + let status = subject + .client + .get_all_downloads() + .into_iter() + .find(|download| download.filename == filename) + .unwrap_or_else(|| panic!("download {filename} is missing from store")) + .status; + assert!(wanted(&status), "unexpected stored status: {status:?}"); +} + +fn expect_downloader_close(stream: &mut TcpStream) { + let mut unexpected = [0u8; 1]; + assert_eq!( + stream + .read(&mut unexpected) + .expect("wait for downloader to close the F socket"), + 0, + "the downloader sends no bytes after its offset" + ); +} + +fn binary_payload(len: usize) -> Vec { + (0..len).map(|index| index as u8).collect() +} + +#[test] +fn an_actual_short_transfer_resumes_opaque_bytes() { + let subject = Subject::new("resume_binary_subject"); + let mut control = ControlPeer::register(&subject, "resume_binary_peer"); + let destination = ScratchDir::new("binary-tracer"); + let content = binary_payload(3 * 64 * 1024 + 257); + let filename = "opaque.bin"; + let interrupt_at = 64 * 1024 + 1; + let part_path = destination.path().join(format!("{filename}.part")); + let final_path = destination.path().join(filename); + + assert!(content.contains(&0)); + assert!(content.contains(&u8::MAX)); + assert!(std::str::from_utf8(&content).is_err()); + + let (first_statuses, mut first, first_offset) = start_download( + &subject, + &mut control, + filename, + content.len(), + destination.path(), + ); + assert_eq!(first_offset, 0); + first + .write_all(&content[..interrupt_at]) + .expect("write short first transfer"); + first.flush().expect("flush short first transfer"); + first + .shutdown(Shutdown::Write) + .expect("interrupt first transfer"); + drop(first); + + let _ = wait_for_status(&first_statuses, "Failed", |status| { + matches!(status, DownloadStatus::Failed(_)) + }); + assert_stored_status(&subject, filename, |status| { + matches!(status, DownloadStatus::Failed(_)) + }); + assert!(!final_path.exists()); + assert_eq!( + fs::read(&part_path).expect("read retained partial file"), + content[..interrupt_at] + ); + + let (retry_statuses, mut retry, retry_offset) = start_download( + &subject, + &mut control, + filename, + content.len(), + destination.path(), + ); + assert_eq!(retry_offset, interrupt_at as u64); + retry + .write_all(&content[interrupt_at..]) + .expect("write retry tail"); + retry.flush().expect("flush retry tail"); + expect_downloader_close(&mut retry); + drop(retry); + + let _ = wait_for_status(&retry_statuses, "Completed", |status| { + matches!(status, DownloadStatus::Completed) + }); + assert_stored_status(&subject, filename, |status| { + matches!(status, DownloadStatus::Completed) + }); + assert_eq!(fs::read(&final_path).unwrap(), content); + assert!(!part_path.exists()); +} + +#[test] +fn a_live_download_pauses_and_resumes_on_the_same_connection() { + let subject = Subject::new("resume_pause_subject"); + let mut control = ControlPeer::register(&subject, "resume_pause_peer"); + let destination = ScratchDir::new("pause-resume"); + let filename = "paused.bin"; + let content = binary_payload(5 * 64 * 1024 + 257); + let part_path = destination.path().join(format!("{filename}.part")); + + let (statuses, mut peer, offset) = start_download( + &subject, + &mut control, + filename, + content.len(), + destination.path(), + ); + assert_eq!(offset, 0); + + let first_chunk = 2 * 64 * 1024; + peer.write_all(&content[..first_chunk]) + .expect("send first chunk"); + peer.flush().expect("flush first chunk"); + let _ = wait_for_status(&statuses, "initial progress", |status| { + matches!( + status, + DownloadStatus::InProgress { + bytes_downloaded, + .. + } if *bytes_downloaded >= first_chunk as u64 + ) + }); + + assert!(subject.client.pause_download(&control.username, filename)); + let _ = wait_for_status(&statuses, "Paused", |status| { + matches!(status, DownloadStatus::Paused { .. }) + }); + + // Give a read already in flight one timeout cycle to observe the pause. + std::thread::sleep(Duration::from_millis(1_200)); + let stable_len = fs::metadata(&part_path).expect("paused partial").len(); + let queued_while_paused = 8 * 1024; + peer.write_all(&content[first_chunk..first_chunk + queued_while_paused]) + .expect("queue bytes while paused"); + peer.flush().expect("flush bytes while paused"); + std::thread::sleep(Duration::from_millis(300)); + assert_eq!( + fs::metadata(&part_path).expect("paused partial").len(), + stable_len, + "a paused transfer must stop consuming its socket" + ); + + assert!(subject.client.resume_download(&control.username, filename)); + peer.write_all(&content[first_chunk + queued_while_paused..]) + .expect("send tail after resume"); + peer.flush().expect("flush resumed tail"); + expect_downloader_close(&mut peer); + + let _ = wait_for_status(&statuses, "Completed", |status| { + matches!(status, DownloadStatus::Completed) + }); + assert_eq!( + fs::read(destination.path().join(filename)).unwrap(), + content + ); +} From 374a81bec150369a6f198d7fb5f7790ec097ffc1 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:20:12 +0200 Subject: [PATCH 21/55] feat(tui): resize panes by dragging pane borders Press and drag the border under Results or the seam at a row pane's right edge; the panes resize live and release ends the drag. Dragged sizes are cell counts on PaneLayout, so W resets panes, zoom and sizes in one key, and the reset-layout hint shows once anything is dragged. Undragged panes keep their default Fill proportions. --- soulseek-rs/src/models/app_state.rs | 22 ++++ soulseek-rs/src/ui/main_tui/input.rs | 143 +++++++++++++++++++++++--- soulseek-rs/src/ui/main_tui/mod.rs | 106 +++++++++++++++++++ soulseek-rs/src/ui/main_tui/render.rs | 67 ++++++++++-- 4 files changed, 315 insertions(+), 23 deletions(-) diff --git a/soulseek-rs/src/models/app_state.rs b/soulseek-rs/src/models/app_state.rs index aa9c983..5f2c4f6 100644 --- a/soulseek-rs/src/models/app_state.rs +++ b/soulseek-rs/src/models/app_state.rs @@ -57,6 +57,14 @@ impl FocusedPane { pub struct PaneLayout { hidden: [bool; FocusedPane::ALL.len()], pub zoomed: bool, + /// Cell height dragged onto the divider between Results and the row + /// underneath. `None` keeps the default proportions. + pub top_height: Option, + /// Cell widths dragged onto the dividers in the bottom row. `None` keeps + /// the default proportions; Info, which has no divider of its own, takes + /// whatever the row has left over. + pub searches_width: Option, + pub downloads_width: Option, } impl PaneLayout { @@ -322,6 +330,15 @@ pub struct AppState { pub searches_pane_area: Option, pub results_pane_area: Option, pub downloads_pane_area: Option, + /// The whole area the panes share, and the bottom row's part of it, + /// recorded at each draw so a divider drag maps to cells. + pub content_area: Option, + pub row_area: Option, + pub info_pane_area: Option, + /// The border rows a mouse can grab to resize: the one Results sits on + /// and the seam at the right of each row pane. + pub hsplit_divider: Option, + pub vsplit_dividers: Vec<(FocusedPane, Rect)>, /// Where the open popup was last drawn, for its page size. Only one is /// ever open, so one record serves them all. pub popup_area: Option, @@ -397,6 +414,11 @@ impl AppState { searches_pane_area: None, results_pane_area: None, downloads_pane_area: None, + content_area: None, + row_area: None, + info_pane_area: None, + hsplit_divider: None, + vsplit_dividers: Vec::new(), popup_area: None, } } diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs index abefc80..c2db95d 100644 --- a/soulseek-rs/src/ui/main_tui/input.rs +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -1,4 +1,5 @@ use super::MainTui; +use super::render::{MIN_PANE, shrink}; use crate::models::{CommandBarMode, FocusedPane, LogView, PaneLayout}; use crate::ui::page_of; use crate::ui::panes::{ @@ -102,6 +103,9 @@ impl MainTui { return; } KeyCode::Char('W') => { + // The universal undo for the window: every pane back, zoom + // off, and any mouse-dragged pane sizes back to the default + // proportions — they all live in the layout. self.state.layout = PaneLayout::default(); return; } @@ -566,24 +570,131 @@ impl MainTui { } pub(super) fn handle_mouse_event(&mut self, mouse: MouseEvent) { - if mouse.kind != MouseEventKind::Down(MouseButton::Left) { - return; - } - - let clicked = Position::new(mouse.column, mouse.row); - // The areas are from the last draw. A pane hidden since then, in the - // same batch of events, still has one, and is not there to click. - let hit = FocusedPane::ALL.into_iter().find(|pane| { - self.state.layout.is_visible(*pane) - && self - .state - .pane_area(*pane) - .is_some_and(|area| area.contains(clicked)) - }); - if let Some(pane) = hit { - self.state.focused_pane = pane; + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + let at = Position::new(mouse.column, mouse.row); + // A divider wins over the panes it separates; grabbing one + // starts a drag, and the click alone already resizes. + if let Some(drag) = self.grabbed_divider(at) { + self.resize_drag = Some(drag); + self.apply_resize(drag, at); + return; + } + // The areas are from the last draw. A pane hidden since then, in the + // same batch of events, still has one, and is not there to click. + let hit = FocusedPane::ALL.into_iter().find(|pane| { + self.state.layout.is_visible(*pane) + && self + .state + .pane_area(*pane) + .is_some_and(|area| area.contains(at)) + }); + if let Some(pane) = hit { + self.state.focused_pane = pane; + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(drag) = self.resize_drag { + self.apply_resize( + drag, + Position::new(mouse.column, mouse.row), + ); + } + } + MouseEventKind::Up(MouseButton::Left) => { + self.resize_drag = None; + } + _ => {} } } + + /// Which dragged divider, if any, the pointer is on. Nothing is grabbable + /// while a popup or a zoom covers the dividers. + fn grabbed_divider(&self, at: Position) -> Option { + if self.state.layout.zoomed + || self.state.show_help + || self.state.show_messages + || self.state.show_browse + || self.state.show_rooms + || self.state.settings.is_some() + { + return None; + } + if self + .state + .hsplit_divider + .is_some_and(|rect| rect.contains(at)) + { + return Some(ResizeDrag::Rows); + } + self.state + .vsplit_dividers + .iter() + .find(|(_, rect)| rect.contains(at)) + .map(|(pane, _)| ResizeDrag::Columns(*pane)) + } + + /// Move the divider under the pointer to where the pointer is, keeping + /// every pane at least [`MIN_PANE`] cells. + fn apply_resize(&mut self, drag: ResizeDrag, at: Position) { + match drag { + ResizeDrag::Rows => { + let Some(area) = self.state.content_area else { + return; + }; + // The dragged row is the pane's last row, so the height is + // everything from the top of the content down to it. + let height = at.y.saturating_sub(area.y).saturating_add(1); + self.state.layout.top_height = + Some(shrink(height, area.height)); + } + ResizeDrag::Columns(pane) => { + let Some(row) = self.state.row_area else { + return; + }; + let left = if pane == FocusedPane::Downloads + && self.state.layout.is_visible(FocusedPane::Searches) + { + // Downloads starts where Searches ends. + self.state + .searches_pane_area + .map_or(row.x, |area| area.x.saturating_add(area.width)) + } else { + row.x + }; + let width = at.x.saturating_sub(left).saturating_add(1); + // Info shares the row, so the drag may not crowd it (and any + // other visible pane) out of `MIN_PANE` cells. + let others = MIN_PANE + * u16::from( + self.state.layout.is_visible(FocusedPane::Searches) + && pane == FocusedPane::Downloads, + ) + + MIN_PANE; + let max = row.width.saturating_sub(others).max(MIN_PANE); + let width = width.max(MIN_PANE).min(max); + match pane { + FocusedPane::Searches => { + self.state.layout.searches_width = Some(width); + } + FocusedPane::Downloads => { + self.state.layout.downloads_width = Some(width); + } + FocusedPane::Results => {} + } + } + } + } +} + +/// What a divider does while the left button is held on it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ResizeDrag { + /// The border between Results and the row underneath: sets that row's height. + Rows, + /// The seam at the right of the named row pane: gives it that width and + /// takes it from the panes to its right. + Columns(FocusedPane), } /// What a key did to a filter being typed. diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 7623a7e..8b6c087 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -31,6 +31,8 @@ pub struct MainTui { config_path: Option, /// Last snapshot written to disk, to skip no-op saves. saved_snapshot: Snapshot, + /// Which divider the left mouse button is holding, if any. + resize_drag: Option, } impl MainTui { @@ -50,6 +52,7 @@ impl MainTui { store, config_path, saved_snapshot: Snapshot::default(), + resize_drag: None, }; tui.restore_persisted_state(); tui @@ -1360,6 +1363,27 @@ mod tests { screen_sized(tui, 160, 40) } + fn mouse( + tui: &mut MainTui, + kind: ratatui::crossterm::event::MouseEventKind, + column: u16, + row: u16, + ) { + tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }); + } + + fn mouse_drag_row(tui: &mut MainTui, from: (u16, u16), to: (u16, u16)) { + use ratatui::crossterm::event::{MouseButton, MouseEventKind}; + mouse(tui, MouseEventKind::Down(MouseButton::Left), from.0, from.1); + mouse(tui, MouseEventKind::Drag(MouseButton::Left), to.0, to.1); + mouse(tui, MouseEventKind::Up(MouseButton::Left), to.0, to.1); + } + /// The screen row carrying a pane's legend, `[2] Results` say, without /// the quotes the test backend wraps each row in. fn row_with<'a>(screen: &'a str, needle: &str) -> &'a str { @@ -2044,6 +2068,88 @@ mod tests { assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); } + #[test] + fn dragging_the_border_under_results_resizes_its_row() { + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let results = tui.state.results_pane_area.expect("laid out"); + let divider = tui.state.hsplit_divider.expect("a divider"); + let before = results.height; + let focused = tui.state.focused_pane; + + // Two drags: the row shrinks by the rows the pointer moved up. + mouse_drag_row( + &mut tui, + (results.x + 1, divider.y), + (results.x + 1, divider.y - 2), + ); + assert_eq!(tui.state.layout.top_height, Some(before - 2)); + assert_eq!( + tui.state.focused_pane, focused, + "a grab is not a focus click" + ); + + let _ = screen_of(&mut tui); + assert_eq!( + tui.state.results_pane_area.expect("laid out").height, + before - 2 + ); + + // A drag far past the row stops at the smallest pane, not zero. + mouse_drag_row( + &mut tui, + (results.x + 1, divider.y - 2), + (results.x + 1, 0), + ); + assert!(tui.state.layout.top_height.expect("resized") >= 3); + + press(&mut tui, KeyCode::Char('W')); + assert_eq!(tui.state.layout.top_height, None, "W resets the layout"); + } + + #[test] + fn dragging_a_row_panes_right_edge_gives_it_width() { + use ratatui::crossterm::event::{MouseButton, MouseEventKind}; + + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let searches = tui.state.searches_pane_area.expect("laid out"); + let seam = tui + .state + .vsplit_dividers + .iter() + .find(|(pane, _)| *pane == FocusedPane::Searches) + .map(|(_, rect)| *rect) + .expect("a seam beside Searches"); + tui.state.focused_pane = FocusedPane::Downloads; + + mouse_drag_row( + &mut tui, + (seam.x, searches.y + 1), + (seam.x + 4, searches.y + 1), + ); + assert_eq!(tui.state.layout.searches_width, Some(searches.width + 4)); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); + + // The widths are applied, and Info narrows to whatever is left. + let _ = screen_of(&mut tui); + assert_eq!( + tui.state.searches_pane_area.expect("laid out").width, + searches.width + 4 + ); + assert!(tui.state.info_pane_area.expect("laid out").width >= 3); + + // Releasing the button ends the drag: later motions just move the + // pointer. + mouse( + &mut tui, + MouseEventKind::Drag(MouseButton::Left), + seam.x + 40, + searches.y + 1, + ); + assert_eq!(tui.state.layout.searches_width, Some(searches.width + 4)); + } + #[test] fn the_info_pane_describes_the_highlighted_result() { let mut tui = results_tui(3); diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index 25221e5..f2b7a5a 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -29,8 +29,9 @@ const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[ ("z", "zoom the focused pane"), ("w", "hide the focused pane"), ("Esc", "leave zoom"), - ("W", "every pane back, zoom off"), + ("W", "every pane back, zoom off, sizes reset"), ("click", "focus a pane"), + ("drag", "resize panes by their borders"), ], ), ( @@ -170,11 +171,16 @@ impl MainTui { self.state.searches_pane_area = None; self.state.results_pane_area = None; self.state.downloads_pane_area = None; + self.state.info_pane_area = None; + self.state.hsplit_divider = None; + self.state.vsplit_dividers.clear(); + self.state.row_area = None; if self.state.layout.zoomed { self.render_pane(frame, area, self.state.focused_pane); return; } + self.state.content_area = Some(area); // Transfers carry the most columns, so they get the most width. let row_panes: Vec<(FocusedPane, u16)> = @@ -187,26 +193,61 @@ impl MainTui { // With only Info left in the row, the results deserve more of // the height. let row_weight = if row_panes.is_empty() { 1 } else { 2 }; - let [top, bottom] = Layout::vertical([ - Constraint::Fill(3), - Constraint::Fill(row_weight), - ]) - .areas(area); + let top = match self.state.layout.top_height { + Some(rows) => Constraint::Length(shrink(rows, area.height)), + None => Constraint::Fill(3), + }; + let [top, bottom] = + Layout::vertical([top, Constraint::Fill(row_weight)]) + .areas(area); + // The two touching border rows between the halves; either can + // be dragged. + self.state.hsplit_divider = Some(Rect::new( + area.x, + top.y.saturating_add(top.height).saturating_sub(1), + area.width, + 2, + )); self.render_pane(frame, top, FocusedPane::Results); bottom } else { area }; + self.state.row_area = Some(row_area); let mut constraints: Vec = row_panes .iter() - .map(|(_, weight)| Constraint::Fill(*weight)) + .map(|(pane, weight)| { + let dragged = match *pane { + FocusedPane::Searches => self.state.layout.searches_width, + FocusedPane::Downloads => self.state.layout.downloads_width, + FocusedPane::Results => None, + }; + match dragged { + Some(cols) => { + Constraint::Length(shrink(cols, row_area.width)) + } + None => Constraint::Fill(*weight), + } + }) .collect(); constraints.push(Constraint::Fill(6)); // Info let chunks = Layout::horizontal(constraints).split(row_area); for (chunk, (pane, _)) in chunks.iter().zip(&row_panes) { + // The seam at this pane's right edge: its border column and the + // next pane's, both draggable. + self.state.vsplit_dividers.push(( + *pane, + Rect::new( + chunk.x.saturating_add(chunk.width).saturating_sub(1), + row_area.y, + 2, + row_area.height, + ), + )); self.render_pane(frame, *chunk, *pane); } + self.state.info_pane_area = Some(chunks[row_panes.len()]); self.render_info_pane(frame, chunks[row_panes.len()]); } @@ -768,6 +809,18 @@ impl MainTui { /// Columns between the two halves of the keys list. const HELP_GAP: u16 = 2; +/// A dragged pane size kept inside the space the terminal now offers: never +/// below [`MIN_PANE`], and never so big that what shares the split with it +/// drops below `MIN_PANE` too. +pub(super) fn shrink(dragged: u16, total: u16) -> u16 { + let max = total.saturating_sub(MIN_PANE).max(MIN_PANE); + dragged.max(MIN_PANE).min(max) +} + +/// The smallest pane a drag can leave anyone: a border and a row or column +/// of content each side. +pub(super) const MIN_PANE: u16 = 3; + /// The widest key in a column, so the descriptions line up. fn key_column_width(sections: &[(&str, &[(&str, &str)])]) -> usize { sections From 886f1b70928000c256c4d7bd66fea4a9d8f21203 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 16:19:40 +0000 Subject: [PATCH 22/55] feat(protocol): interests, tickers, global room and cant-connect Adds the server messages the client had no way to speak: user and room search (42/120), room tickers (113-116), the global room feed (150-152), interests and the recommendation queries built on them (51/52/54/56/57/ 110/111/112/117/118), a keepalive ping (32), gifted privileges (123), a password change (142) and multi-user private messages (149). CantConnectToPeer (1001) closes a real gap in both directions: a dial the server asked us to make now answers the waiting peer instead of falling back to brokering the same unreachable link the other way, and an incoming one ends the wait on downloads queued for that peer rather than letting them sit until a timeout. --- soulseek-rs-lib/src/actor/peer_actor.rs | 14 +- soulseek-rs-lib/src/actor/peer_actor/tests.rs | 2 +- soulseek-rs-lib/src/actor/peer_registry.rs | 2 +- soulseek-rs-lib/src/actor/server_actor.rs | 157 +++++++- soulseek-rs-lib/src/client/mod.rs | 177 ++++++++- soulseek-rs-lib/src/client/operations.rs | 118 +++++- soulseek-rs-lib/src/client/search.rs | 49 +++ soulseek-rs-lib/src/client/social.rs | 264 +++++++++++++ soulseek-rs-lib/src/client/tests.rs | 113 ++++++ soulseek-rs-lib/src/lib.rs | 14 +- .../message/server/cant_connect_to_peer.rs | 49 +++ .../src/message/server/global_room.rs | 58 +++ .../src/message/server/interests.rs | 346 ++++++++++++++++++ .../src/message/server/message_factory.rs | 295 +++++++++++++++ soulseek-rs-lib/src/message/server/mod.rs | 14 + .../src/message/server/room_tickers.rs | 174 +++++++++ soulseek-rs-lib/src/peer/mod.rs | 6 + soulseek-rs-lib/src/types.rs | 52 +++ 18 files changed, 1886 insertions(+), 18 deletions(-) create mode 100644 soulseek-rs-lib/src/client/social.rs create mode 100644 soulseek-rs-lib/src/message/server/cant_connect_to_peer.rs create mode 100644 soulseek-rs-lib/src/message/server/global_room.rs create mode 100644 soulseek-rs-lib/src/message/server/interests.rs create mode 100644 soulseek-rs-lib/src/message/server/room_tickers.rs diff --git a/soulseek-rs-lib/src/actor/peer_actor.rs b/soulseek-rs-lib/src/actor/peer_actor.rs index bf63e3a..74b9e21 100644 --- a/soulseek-rs-lib/src/actor/peer_actor.rs +++ b/soulseek-rs-lib/src/actor/peer_actor.rs @@ -729,8 +729,20 @@ impl PeerActor { // unreachable (likely firewalled): signal a connect failure so the // client can fall back to server-brokered connect. Anything else is a // normal disconnect. + // A dial the server asked for carries the token the far peer quoted; + // it is what a CantConnectToPeer must echo back, so the peer stops + // waiting for a connection that is never coming. + let brokered_token = self + .peer + .read_safe() + .ok() + .and_then(|p| if p.brokered { p.token } else { None }); let op = if self.outbound && !self.established { - ClientOperation::PeerConnectFailed(self.id, username) + ClientOperation::PeerConnectFailed( + self.id, + username, + brokered_token, + ) } else { ClientOperation::PeerDisconnected( self.id, diff --git a/soulseek-rs-lib/src/actor/peer_actor/tests.rs b/soulseek-rs-lib/src/actor/peer_actor/tests.rs index de8d7c8..c7d8325 100644 --- a/soulseek-rs-lib/src/actor/peer_actor/tests.rs +++ b/soulseek-rs-lib/src/actor/peer_actor/tests.rs @@ -50,7 +50,7 @@ fn a_timed_out_connect_parks_the_actor_in_disconnected() { "a timed-out connect must leave Connecting" ); match rx.try_recv() { - Ok(ClientOperation::PeerConnectFailed(7, username)) => { + Ok(ClientOperation::PeerConnectFailed(7, username, _)) => { assert_eq!(username, "bob"); } other => panic!("expected PeerConnectFailed, got {other:?}"), diff --git a/soulseek-rs-lib/src/actor/peer_registry.rs b/soulseek-rs-lib/src/actor/peer_registry.rs index f4b7007..02d1ce0 100644 --- a/soulseek-rs-lib/src/actor/peer_registry.rs +++ b/soulseek-rs-lib/src/actor/peer_registry.rs @@ -427,7 +427,7 @@ mod tests { // Play the client ops loop: take the terminal outcome, evict by id. match rx.recv_timeout(std::time::Duration::from_secs(10)) { - Ok(ClientOperation::PeerConnectFailed(id, username)) => { + Ok(ClientOperation::PeerConnectFailed(id, username, _)) => { assert_eq!(username, "ghost"); if let Some(handle) = registry.remove_peer_if(&username, id) { let _ = handle.stop(); diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index ecab497..6fb2636 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -2,12 +2,14 @@ use crate::actor::{Actor, ActorHandle, ConnectionState}; use crate::client::ClientOperation; use crate::dispatcher::MessageDispatcher; use crate::message::server::AdminMessageHandler; +use crate::message::server::CantConnectToPeerHandler; use crate::message::server::CheckPrivilegesHandler; use crate::message::server::ConnectToPeerHandler; use crate::message::server::EmbeddedMessageHandler; use crate::message::server::ExcludedSearchPhrasesHandler; use crate::message::server::FileSearchHandler; use crate::message::server::GetPeerAddressHandler; +use crate::message::server::GlobalRoomMessageHandler; use crate::message::server::JoinRoomHandler; use crate::message::server::LeaveRoomHandler; use crate::message::server::LoginHandler; @@ -27,13 +29,21 @@ use crate::message::server::WishListIntervalHandler; use crate::message::server::{ GetUserStatsHandler, GetUserStatusHandler, RoomListHandler, }; +use crate::message::server::{ + GlobalRecommendationsHandler, ItemRecommendationsHandler, + ItemSimilarUsersHandler, RecommendationsHandler, SimilarUsersHandler, + UserInterestsHandler, +}; +use crate::message::server::{ + RoomTickerAddedHandler, RoomTickerRemovedHandler, RoomTickersHandler, +}; use crate::message::{Handlers, MessageType}; use crate::message::{Message, MessageReader}; use crate::peer::ConnectionType; use crate::peer::Peer; use crate::types::{ - ClientVersion, RoomEvent, RoomInfo, RoomUserStats, SessionLoss, - SessionWatch, + ClientVersion, Recommendation, RoomEvent, RoomInfo, RoomUserStats, + SessionLoss, SessionWatch, SimilarUser, UserInterests, }; use crate::utils::lock::RwLockExt; @@ -164,6 +174,59 @@ pub enum ServerMessage { PossibleParents(Vec<(String, String, u16)>), /// The server asks us to drop our parent and start over. ResetDistributed, + /// The ticker board of a room we just joined (code 113). + RoomTickers { + room: String, + tickers: Vec, + }, + /// One member set their ticker (code 114). + RoomTickerAdded { + room: String, + username: String, + ticker: String, + }, + /// One member cleared their ticker (code 115). + RoomTickerRemoved { + room: String, + username: String, + }, + /// A message from the global room feed (code 152). + GlobalRoomMessageReceived { + room: String, + username: String, + message: String, + }, + /// Recommendations from our own interests (code 54). + RecommendationsReceived { + recommended: Vec, + unrecommended: Vec, + }, + /// Server-wide recommendations (code 56). + GlobalRecommendationsReceived { + recommended: Vec, + unrecommended: Vec, + }, + /// Recommendations for one item (code 111). + ItemRecommendationsReceived { + item: String, + recommendations: Vec, + }, + /// Users similar to us (code 110). + SimilarUsersReceived { + users: Vec, + }, + /// Users who like one item (code 112). + ItemSimilarUsersReceived { + item: String, + usernames: Vec, + }, + /// What another user likes and hates (code 57). + UserInterestsReceived(UserInterests), + /// A peer could not connect to us after we asked the server to broker + /// (code 1001); the token is the one we quoted. + CantConnectToPeer { + token: u32, + }, } pub struct ServerActor { @@ -320,6 +383,17 @@ impl ServerActor { handlers.register_handler(FileSearchHandler); handlers.register_handler(GetPeerAddressHandler); handlers.register_handler(ConnectToPeerHandler); + handlers.register_handler(CantConnectToPeerHandler); + handlers.register_handler(RoomTickersHandler); + handlers.register_handler(RoomTickerAddedHandler); + handlers.register_handler(RoomTickerRemovedHandler); + handlers.register_handler(GlobalRoomMessageHandler); + handlers.register_handler(RecommendationsHandler); + handlers.register_handler(GlobalRecommendationsHandler); + handlers.register_handler(ItemRecommendationsHandler); + handlers.register_handler(SimilarUsersHandler); + handlers.register_handler(ItemSimilarUsersHandler); + handlers.register_handler(UserInterestsHandler); self.dispatcher = Some(MessageDispatcher::new( "server".into(), @@ -501,6 +575,37 @@ impl ServerActor { ServerMessage::RoomUserLeft { room, username } => { self.forward_room_event(RoomEvent::UserLeft { room, username }); } + ServerMessage::RoomTickers { room, tickers } => { + self.forward_room_event(RoomEvent::Tickers { room, tickers }); + } + ServerMessage::RoomTickerAdded { + room, + username, + ticker, + } => { + self.forward_room_event(RoomEvent::TickerAdded { + room, + username, + ticker, + }); + } + ServerMessage::RoomTickerRemoved { room, username } => { + self.forward_room_event(RoomEvent::TickerRemoved { + room, + username, + }); + } + ServerMessage::GlobalRoomMessageReceived { + room, + username, + message, + } => { + self.forward_room_event(RoomEvent::GlobalMessage { + room, + username, + message, + }); + } other => self.handle_standing_message(other), } } @@ -539,6 +644,54 @@ impl ServerActor { ServerMessage::CheckPrivileges => { self.queue_message(MessageFactory::build_check_privileges()); } + ServerMessage::RecommendationsReceived { + recommended, + unrecommended, + } => { + self.forward_to_client(ClientOperation::Recommendations { + global: false, + recommended, + unrecommended, + }); + } + ServerMessage::GlobalRecommendationsReceived { + recommended, + unrecommended, + } => { + self.forward_to_client(ClientOperation::Recommendations { + global: true, + recommended, + unrecommended, + }); + } + ServerMessage::ItemRecommendationsReceived { + item, + recommendations, + } => { + self.forward_to_client(ClientOperation::ItemRecommendations { + item, + recommendations, + }); + } + ServerMessage::SimilarUsersReceived { users } => { + self.forward_to_client(ClientOperation::SimilarUsers(users)); + } + ServerMessage::ItemSimilarUsersReceived { item, usernames } => { + self.forward_to_client(ClientOperation::ItemSimilarUsers { + item, + usernames, + }); + } + ServerMessage::UserInterestsReceived(interests) => { + self.forward_to_client(ClientOperation::UserInterests( + interests, + )); + } + ServerMessage::CantConnectToPeer { token } => { + self.forward_to_client(ClientOperation::CantConnectToPeer { + token, + }); + } other => { error!("[server] unroutable message: {:?}", other); } diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 5dc3a34..75dd60c 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -4,9 +4,9 @@ use crate::actor::server_actor::{ }; use crate::download_store::{DownloadStore, collect_failed_tokens}; use crate::types::{ - ClientVersion, DownloadMetadata, DownloadStatus, RoomEvent, RoomInfo, - RoomUserStats, SessionLoss, SessionWatch, UserInfo, UserPresence, - UserStats, UserStatus, + ClientVersion, DownloadMetadata, DownloadStatus, Recommendation, RoomEvent, + RoomInfo, RoomTicker, RoomUserStats, SessionLoss, SessionWatch, + SimilarUser, UserInfo, UserInterests, UserPresence, UserStats, UserStatus, }; use crate::utils::logger; use crate::{ @@ -313,8 +313,11 @@ pub enum ClientOperation { }, /// A direct outbound connection to this peer failed before it was /// established — the peer is likely firewalled, so fall back to asking the - /// server to broker the connection. Carries the reporting actor's id. - PeerConnectFailed(u64, String), + /// server to broker the connection. Carries the reporting actor's id and, + /// when the dial was one the server asked us to make, the token that peer + /// quoted: that dial is answered with a `CantConnectToPeer` instead of a + /// broker request, since the peer is already waiting on the server. + PeerConnectFailed(u64, String, Option), /// Something happened in the chat-room subsystem (list refreshed, a room /// joined/left, a message said, a member joined/left). RoomEvent(RoomEvent), @@ -340,6 +343,31 @@ pub enum ClientOperation { PossibleParents(Vec<(String, String, u16)>), /// The server told us to drop our parent. ResetDistributed, + /// Recommendations from the server: either from our own interests + /// (`global` false, code 54) or server-wide (`global` true, code 56). + Recommendations { + global: bool, + recommended: Vec, + unrecommended: Vec, + }, + /// Recommendations for one item (code 111). + ItemRecommendations { + item: String, + recommendations: Vec, + }, + /// Users the server considers similar to us (code 110). + SimilarUsers(Vec), + /// Users who like one item (code 112). + ItemSimilarUsers { + item: String, + usernames: Vec, + }, + /// What another user likes and hates (code 57). + UserInterests(UserInterests), + /// A peer we asked the server to broker gave up reaching us (code 1001). + CantConnectToPeer { + token: u32, + }, /// A parent candidate told us how deep it sits. `link` says which dial /// to that user is talking. ParentBranchLevel { @@ -410,6 +438,22 @@ pub struct ClientContext { /// Per-member statistics for each joined room, from the stat vectors the /// server sends alongside the membership list. room_member_stats: HashMap>, + /// The ticker board of each joined room, kept current from the board sent + /// on join (code 113) and the later add/remove events (114/115). + room_tickers: HashMap>, + /// The latest recommendations from our own interests (code 54), as + /// (recommended, recommended-against). + recommendations: Option<(Vec, Vec)>, + /// The latest server-wide recommendations (code 56). + global_recommendations: Option<(Vec, Vec)>, + /// Per-item recommendations (code 111), keyed by the item asked about. + item_recommendations: HashMap>, + /// The latest similar-user answer (code 110). + similar_users: Vec, + /// Who likes an item (code 112), keyed by the item asked about. + item_similar_users: HashMap>, + /// Interests of other users (code 57), keyed by username. + user_interests: HashMap, /// What the server has told us about other users, merged across the /// separate status and statistics replies. user_info: HashMap, @@ -519,6 +563,13 @@ impl ClientContext { room_events: Vec::new(), room_members: HashMap::new(), room_member_stats: HashMap::new(), + room_tickers: HashMap::new(), + recommendations: None, + global_recommendations: None, + item_recommendations: HashMap::new(), + similar_users: Vec::new(), + item_similar_users: HashMap::new(), + user_interests: HashMap::new(), user_info: HashMap::new(), watched_users: HashSet::new(), wishlist_interval: None, @@ -562,11 +613,124 @@ impl ClientContext { members.remove(at); } } - RoomEvent::Message { .. } => {} + RoomEvent::Tickers { room, tickers } => { + self.room_tickers.insert(room.clone(), tickers.clone()); + } + RoomEvent::TickerAdded { + room, + username, + ticker, + } => { + // A user has at most one ticker: an added one replaces theirs + // rather than stacking, which is how the server treats it. + let board = self.room_tickers.entry(room.clone()).or_default(); + board.retain(|t| &t.username != username); + board.push(RoomTicker { + username: username.clone(), + ticker: ticker.clone(), + }); + } + RoomEvent::TickerRemoved { room, username } => { + if let Some(board) = self.room_tickers.get_mut(room) { + board.retain(|t| &t.username != username); + } + } + RoomEvent::Message { .. } | RoomEvent::GlobalMessage { .. } => {} } self.room_events.push(event); } + /// The ticker board of `room` as last reported by the server. + #[must_use] + pub fn room_tickers(&self, room: &str) -> Vec { + self.room_tickers.get(room).cloned().unwrap_or_default() + } + + /// Record a recommendations reply; `global` selects the server-wide set. + pub fn apply_recommendations( + &mut self, + global: bool, + recommended: Vec, + unrecommended: Vec, + ) { + let slot = if global { + &mut self.global_recommendations + } else { + &mut self.recommendations + }; + *slot = Some((recommended, unrecommended)); + } + + /// The last recommendations reply, or `None` until one arrives. + #[must_use] + pub fn recommendations( + &self, + global: bool, + ) -> Option<(Vec, Vec)> { + if global { + self.global_recommendations.clone() + } else { + self.recommendations.clone() + } + } + + pub fn apply_item_recommendations( + &mut self, + item: String, + recommendations: Vec, + ) { + self.item_recommendations.insert(item, recommendations); + } + + #[must_use] + pub fn item_recommendations(&self, item: &str) -> Vec { + self.item_recommendations + .get(item) + .cloned() + .unwrap_or_default() + } + + pub fn apply_similar_users(&mut self, users: Vec) { + self.similar_users = users; + } + + #[must_use] + pub fn similar_users(&self) -> Vec { + self.similar_users.clone() + } + + pub fn apply_item_similar_users( + &mut self, + item: String, + usernames: Vec, + ) { + self.item_similar_users.insert(item, usernames); + } + + #[must_use] + pub fn item_similar_users(&self, item: &str) -> Vec { + self.item_similar_users + .get(item) + .cloned() + .unwrap_or_default() + } + + pub fn apply_user_interests(&mut self, interests: UserInterests) { + self.user_interests + .insert(interests.username.clone(), interests); + } + + #[must_use] + pub fn user_interests(&self, username: &str) -> Option { + self.user_interests.get(username).cloned() + } + + /// Forget any interests cached for `username`, so a poll after a fresh + /// request cannot mistake the previous answer for this one. + pub fn invalidate_user_interests(&mut self, username: &str) { + self.user_interests.remove(username); + } + /// Record a `GetUserStatus` reply, merging it with any statistics already /// received for that user. pub fn apply_user_status( @@ -1050,6 +1214,7 @@ mod downloads; mod operations; mod rooms; mod search; +mod social; mod upload_queue; mod uploads; diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index 81dcd8e..d53c33e 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -777,6 +777,71 @@ impl Client { ctx.own_privileges = Some(seconds); } } + ClientOperation::Recommendations { + global, + recommended, + unrecommended, + } => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_recommendations( + global, + recommended, + unrecommended, + ); + } + } + ClientOperation::ItemRecommendations { + item, + recommendations, + } => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_item_recommendations( + item, + recommendations, + ); + } + } + ClientOperation::SimilarUsers(users) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_similar_users(users); + } + } + ClientOperation::ItemSimilarUsers { item, usernames } => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_item_similar_users(item, usernames); + } + } + ClientOperation::UserInterests(interests) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_user_interests(interests); + } + } + ClientOperation::CantConnectToPeer { token } => { + // The peer we asked the server to broker gave up. The + // token is the correlation we sent, so it names the + // peer: end the wait now instead of letting every + // queued download for them sit until the timeout. + let username = match client_context.write_safe() { + Ok(mut ctx) => ctx.take_pending_connect(token), + Err(e) => { + error!( + "[client] CantConnectToPeer write: {}", + e + ); + None + } + }; + if let Some(username) = username { + debug!( + "[client] {} cannot connect back (token {})", + username, token + ); + Self::fail_queued_downloads( + &client_context, + &username, + ); + } + } ClientOperation::StartUpload { token } => { // The peer accepted our offer: resolve their // address (from the code-9 GetPeerAddress) and @@ -875,7 +940,58 @@ impl Client { ctx.store_browse_result(username, directories); } } - ClientOperation::PeerConnectFailed(id, username) => { + ClientOperation::PeerConnectFailed( + id, + username, + brokered_token, + ) => { + // Direct connect failed. A dial the server asked + // us to make is answered with CantConnectToPeer + // quoting the peer's own token: that peer is + // already waiting on the server, and asking it to + // broker the same connection back only trades one + // unreachable direction for the other. Every other + // dial falls back to the broker below. + if let Some(peer_token) = brokered_token { + let server_sender = match client_context + .write_safe() + { + Ok(ctx) => { + if let Some(handle) = + ctx.peer_registry.as_ref().and_then( + |r| r.remove_peer_if(&username, id), + ) + { + let _ = handle.stop(); + } + ctx.server_sender.clone() + } + Err(e) => { + error!( + "[client] PeerConnectFailed write: {}", + e + ); + continue; + } + }; + if let Some(sender) = server_sender { + let msg = crate::message::server::MessageFactory::build_cant_connect_to_peer( + peer_token, + &username, + ); + let _ = sender + .send(ServerMessage::SendMessage(msg)); + } + // Nothing else is coming from this peer, so + // anything queued for it fails now rather than + // waiting out its timeout. + Self::fail_queued_downloads( + &client_context, + &username, + ); + continue; + } + // Direct connect failed: ask the server to // broker it. Register a correlation token, then // send ConnectToPeer so the (firewalled) peer diff --git a/soulseek-rs-lib/src/client/search.rs b/soulseek-rs-lib/src/client/search.rs index 215deb5..9e54136 100644 --- a/soulseek-rs-lib/src/client/search.rs +++ b/soulseek-rs-lib/src/client/search.rs @@ -81,6 +81,55 @@ impl Client { Ok(()) } + /// Search one user's shares (server code 42) and return at once. + /// + /// Results arrive from that user alone and accumulate under `query`, read + /// back with [`Client::get_search_results`] — the same place a plain + /// search's results land, since the replies are ordinary + /// `FileSearchResponse` frames. + /// + /// # Errors + /// [`SoulseekRs::NotConnected`] when there is no server connection. + pub fn search_user(&self, username: &str, query: &str) -> Result<()> { + let token = self.register_search(query)?; + self.send_server_message( + crate::message::server::MessageFactory::build_user_search( + username, token, query, + ), + ) + } + + /// Search the shares of everyone in `room` (server code 120), returning at + /// once. Results accumulate under `query` exactly as above. + /// + /// # Errors + /// [`SoulseekRs::NotConnected`] when there is no server connection. + pub fn search_room(&self, room: &str, query: &str) -> Result<()> { + let token = self.register_search(query)?; + self.send_server_message( + crate::message::server::MessageFactory::build_room_search( + room, token, query, + ), + ) + } + + /// Take a token for `query` and register it, so replies quoting that token + /// are filed under `query` when they arrive. + fn register_search(&self, query: &str) -> Result { + if self.server_handle.is_none() { + return Err(SoulseekRs::NotConnected); + } + let token = next_search_token(); + self.context.write_safe()?.searches.insert( + query.to_string(), + Search { + token, + results: Vec::new(), + }, + ); + Ok(token) + } + /// Let responses accumulate for `timeout`, or until cancelled. /// /// Peers answer a search over their own connections whenever they get round diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs new file mode 100644 index 0000000..5d402e4 --- /dev/null +++ b/soulseek-rs-lib/src/client/social.rs @@ -0,0 +1,264 @@ +//! The social side of the protocol: room tickers (codes 113-116), the global +//! room feed (150-152), interests and the recommendation queries built on them +//! (51/52/54/56/57/110/111/112/117/118), and the odds and ends a client owes +//! the server — a keepalive ping (32), gifting privileges (123) and changing +//! our password (142). + +use super::{ + Client, Recommendation, Result, RoomTicker, RwLockExt, SimilarUser, + UserInterests, error, +}; +use crate::message::server::MessageFactory; + +impl Client { + /// Send a keepalive ping (server code 32). The server does not reply; the + /// point is that a connection with nothing to say still has traffic on it, + /// so a NAT or the server's idle reaper does not drop it. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn ping_server(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_server_ping()) + } + + /// Set our ticker in `room` — the one-line message other members see. + /// Passing an empty `ticker` clears ours. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn set_room_ticker(&self, room: &str, ticker: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_set_room_ticker( + room, ticker, + )) + } + + /// The ticker board of `room`: what the server sent on join (code 113), + /// kept current by the later add/remove events. + #[must_use] + pub fn room_tickers(&self, room: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.room_tickers(room), + Err(e) => { + error!("[client] room_tickers: {}", e); + Vec::new() + } + } + } + + /// Subscribe to the global room feed (code 150): every public room message + /// on the server arrives as a [`crate::RoomEvent::GlobalMessage`]. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn join_global_room(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_join_global_room()) + } + + /// Stop the global room feed (code 151). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn leave_global_room(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_leave_global_room()) + } + + /// Add an interest (code 51). Interests are what the recommendation and + /// similar-user queries below are computed from. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn add_interest(&self, item: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_add_thing_i_like(item)) + } + + /// Drop an interest (code 52). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn remove_interest(&self, item: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_remove_thing_i_like( + item, + )) + } + + /// Add a dislike (code 117). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn add_dislike(&self, item: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_add_thing_i_hate(item)) + } + + /// Drop a dislike (code 118). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn remove_dislike(&self, item: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_remove_thing_i_hate( + item, + )) + } + + /// Ask for recommendations drawn from our own interests (code 54). The + /// reply arrives asynchronously; read it with + /// [`Client::recommendations`]. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_recommendations(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_get_recommendations()) + } + + /// Ask for the server-wide recommendations (code 56); read the reply with + /// [`Client::global_recommendations`]. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_global_recommendations(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_global_recommendations()) + } + + /// The last reply to [`Client::request_recommendations`] as + /// (recommended, recommended-against), or `None` until one arrives. + #[must_use] + pub fn recommendations( + &self, + ) -> Option<(Vec, Vec)> { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.recommendations(false)) + } + + /// The last reply to [`Client::request_global_recommendations`]. + #[must_use] + pub fn global_recommendations( + &self, + ) -> Option<(Vec, Vec)> { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.recommendations(true)) + } + + /// Ask what else people who like `item` like (code 111). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_item_recommendations(&self, item: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_item_recommendations( + item, + )) + } + + /// The last per-item recommendations received for `item`. + #[must_use] + pub fn item_recommendations(&self, item: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.item_recommendations(item), + Err(e) => { + error!("[client] item_recommendations: {}", e); + Vec::new() + } + } + } + + /// Ask which users share our interests (code 110). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_similar_users(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_similar_users()) + } + + /// The last similar-user answer, weighted by how much we have in common. + #[must_use] + pub fn similar_users(&self) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.similar_users(), + Err(e) => { + error!("[client] similar_users: {}", e); + Vec::new() + } + } + } + + /// Ask which users like `item` (code 112). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_item_similar_users(&self, item: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_item_similar_users(item)) + } + + /// The last answer to [`Client::request_item_similar_users`] for `item`. + #[must_use] + pub fn item_similar_users(&self, item: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.item_similar_users(item), + Err(e) => { + error!("[client] item_similar_users: {}", e); + Vec::new() + } + } + } + + /// Ask what `username` likes and hates (code 57). + /// + /// Any previous answer for `username` is dropped first, so + /// [`Client::user_interests`] reports the reply to *this* request rather + /// than a stale one. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_user_interests(&self, username: &str) -> Result<()> { + self.context + .write_safe()? + .invalidate_user_interests(username); + self.send_server_message(MessageFactory::build_user_interests(username)) + } + + /// What the server last told us `username` likes and hates. + #[must_use] + pub fn user_interests(&self, username: &str) -> Option { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.user_interests(username)) + } + + /// Give `days` of our own privileges to `username` (code 123). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn give_privileges(&self, username: &str, days: u32) -> Result<()> { + self.send_server_message(MessageFactory::build_give_privileges( + username, days, + )) + } + + /// Change our account password (code 142). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn change_password(&self, password: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_change_password( + password, + )) + } + + /// Send one private message to several users at once (code 149). Each + /// recipient receives it as an ordinary private message. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn send_private_message_to_many( + &self, + usernames: &[String], + message: &str, + ) -> Result<()> { + self.send_server_message(MessageFactory::build_message_users( + usernames, message, + )) + } +} diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs index fb97d6c..b1b8285 100644 --- a/soulseek-rs-lib/src/client/tests.rs +++ b/soulseek-rs-lib/src/client/tests.rs @@ -838,3 +838,116 @@ fn a_peer_message_nobody_could_deliver_expires() { ctx.expire_pending_peer_messages(Instant::now() + PENDING_PEER_TTL); assert!(ctx.take_peer_messages("ghost").is_empty(), "expired"); } + +#[test] +fn a_new_ticker_replaces_that_users_previous_one() { + // The server treats a user's ticker as singular: a second one from the + // same user supersedes the first rather than stacking beside it. + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::Tickers { + room: "jazz".into(), + tickers: vec![ + RoomTicker { + username: "alice".into(), + ticker: "first".into(), + }, + RoomTicker { + username: "bob".into(), + ticker: "bobs".into(), + }, + ], + }); + ctx.apply_room_event(RoomEvent::TickerAdded { + room: "jazz".into(), + username: "alice".into(), + ticker: "second".into(), + }); + + let board = ctx.room_tickers("jazz"); + assert_eq!(board.len(), 2); + assert_eq!( + board.iter().find(|t| t.username == "alice").unwrap().ticker, + "second" + ); +} + +#[test] +fn a_removed_ticker_leaves_the_rest_of_the_board() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::Tickers { + room: "jazz".into(), + tickers: vec![ + RoomTicker { + username: "alice".into(), + ticker: "a".into(), + }, + RoomTicker { + username: "bob".into(), + ticker: "b".into(), + }, + ], + }); + ctx.apply_room_event(RoomEvent::TickerRemoved { + room: "jazz".into(), + username: "alice".into(), + }); + + let board = ctx.room_tickers("jazz"); + assert_eq!(board.len(), 1); + assert_eq!(board[0].username, "bob"); +} + +#[test] +fn a_global_message_is_queued_without_touching_room_membership() { + // The global feed carries messages from rooms we have not joined; they + // must not invent a roster for those rooms. + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::GlobalMessage { + room: "elsewhere".into(), + username: "alice".into(), + message: "hi".into(), + }); + assert!(ctx.room_members("elsewhere").is_empty()); + assert_eq!(ctx.take_room_events().len(), 1); +} + +#[test] +fn own_and_global_recommendations_are_kept_apart() { + let mut ctx = ClientContext::new(); + ctx.apply_recommendations( + false, + vec![Recommendation { + item: "jazz".into(), + rating: 2, + }], + Vec::new(), + ); + ctx.apply_recommendations( + true, + vec![Recommendation { + item: "pop".into(), + rating: 9, + }], + Vec::new(), + ); + + assert_eq!(ctx.recommendations(false).unwrap().0[0].item, "jazz"); + assert_eq!(ctx.recommendations(true).unwrap().0[0].item, "pop"); +} + +#[test] +fn asking_again_about_a_users_interests_drops_the_previous_answer() { + let mut ctx = ClientContext::new(); + ctx.apply_user_interests(UserInterests { + username: "alice".into(), + likes: vec!["jazz".into()], + hates: Vec::new(), + }); + assert!(ctx.user_interests("alice").is_some()); + + ctx.invalidate_user_interests("alice"); + assert!( + ctx.user_interests("alice").is_none(), + "a stale answer must not be mistaken for the next one" + ); +} diff --git a/soulseek-rs-lib/src/lib.rs b/soulseek-rs-lib/src/lib.rs index f9474a5..a174fbd 100644 --- a/soulseek-rs-lib/src/lib.rs +++ b/soulseek-rs-lib/src/lib.rs @@ -22,9 +22,10 @@ pub mod utils; pub mod prelude { pub use crate::actor::server_actor::PeerAddress; pub use crate::types::{ - ClientVersion, DownloadStatus, File, RoomEvent, RoomInfo, Search, - SearchResult, Transfer, UploadInfo, UploadStatus, UserInfo, - UserPresence, UserStats, UserStatus, + ClientVersion, DownloadStatus, File, Recommendation, RoomEvent, + RoomInfo, RoomTicker, Search, SearchResult, SimilarUser, Transfer, + UploadInfo, UploadStatus, UserInfo, UserInterests, UserPresence, + UserStats, UserStatus, }; pub use crate::{debug, error, info, trace, warn}; } @@ -36,7 +37,8 @@ pub use error::{Result, SoulseekRs}; pub use message::peer::{SharedDirectory, SharedFileEntry}; pub use peer::{ConnectionType, ParseConnectionTypeError}; pub use types::{ - ClientVersion, DownloadStatus, File, RoomEvent, RoomInfo, Search, - SearchResult, SessionLoss, Transfer, UploadInfo, UploadStatus, UserInfo, - UserPresence, UserStats, UserStatus, + ClientVersion, DownloadStatus, File, Recommendation, RoomEvent, RoomInfo, + RoomTicker, Search, SearchResult, SessionLoss, SimilarUser, Transfer, + UploadInfo, UploadStatus, UserInfo, UserInterests, UserPresence, UserStats, + UserStatus, }; diff --git a/soulseek-rs-lib/src/message/server/cant_connect_to_peer.rs b/soulseek-rs-lib/src/message/server/cant_connect_to_peer.rs new file mode 100644 index 0000000..184e1f2 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/cant_connect_to_peer.rs @@ -0,0 +1,49 @@ +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; + +/// `CantConnectToPeer` (code 1001): a brokered peer gave up reaching us. +/// +/// The token is the one we quoted in our own `ConnectToPeer` (code 18), and it +/// is the only field the server relays — the username is not repeated, so the +/// token is what a client matches the pending attempt on. +/// +/// Without this the download sits in "connecting" until a timeout; with it the +/// wait ends the moment the peer gives up. +pub struct CantConnectToPeerHandler; + +impl MessageHandler for CantConnectToPeerHandler { + fn get_code(&self) -> u32 { + 1001 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let token = message.read_int32(); + let _ = sender.send(ServerMessage::CantConnectToPeer { token }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn the_token_of_the_failed_attempt_is_reported() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(4242); + }); + + CantConnectToPeerHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::CantConnectToPeer { token }) => { + assert_eq!(token, 4242); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/global_room.rs b/soulseek-rs-lib/src/message/server/global_room.rs new file mode 100644 index 0000000..7e8f704 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/global_room.rs @@ -0,0 +1,58 @@ +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; + +/// `GlobalRoomMessage` (code 152): a message said in some public room, +/// delivered because we subscribed to the global feed (code 150). +pub struct GlobalRoomMessageHandler; + +impl MessageHandler for GlobalRoomMessageHandler { + fn get_code(&self) -> u32 { + 152 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let text = message.read_string(); + let _ = sender.send(ServerMessage::GlobalRoomMessageReceived { + room, + username, + message: text, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn a_global_message_names_the_room_it_was_said_in() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_string("alice"); + m.write_string("anyone here?"); + }); + + GlobalRoomMessageHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::GlobalRoomMessageReceived { + room, + username, + message, + }) => { + assert_eq!( + (room.as_str(), username.as_str(), message.as_str()), + ("jazz", "alice", "anyone here?") + ); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/interests.rs b/soulseek-rs-lib/src/message/server/interests.rs new file mode 100644 index 0000000..6d623a6 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/interests.rs @@ -0,0 +1,346 @@ +//! Interests and the queries built on them: recommendations (codes 54, 56, +//! 111), similar users (110, 112) and another user's interests (57). +//! +//! Ratings are signed on the wire: the server sends a descending list of +//! things it recommends and an ascending list of things it recommends +//! against, and the latter carry negative ratings. + +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, + types::{Recommendation, SimilarUser, UserInterests}, +}; + +/// Read a `[count][item][rating]...` vector, stopping if the payload runs out +/// before the count does. +fn read_recommendations(message: &mut Message) -> Vec { + let count = message.read_int32(); + let mut items = Vec::new(); + for _ in 0..count { + let item = message.read_string(); + if item.is_empty() { + break; + } + #[allow(clippy::cast_possible_wrap)] + let rating = message.read_int32() as i32; + items.push(Recommendation { item, rating }); + } + items +} + +/// Read a `[count][string]...` vector, stopping at the end of the payload. +fn read_strings(message: &mut Message) -> Vec { + let count = message.read_int32(); + let mut items = Vec::new(); + for _ in 0..count { + let item = message.read_string(); + if item.is_empty() { + break; + } + items.push(item); + } + items +} + +/// `GetRecommendations` (code 54): recommendations drawn from our own +/// interests. +pub struct RecommendationsHandler; + +impl MessageHandler for RecommendationsHandler { + fn get_code(&self) -> u32 { + 54 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let recommended = read_recommendations(message); + let unrecommended = read_recommendations(message); + let _ = sender.send(ServerMessage::RecommendationsReceived { + recommended, + unrecommended, + }); + } +} + +/// `GlobalRecommendations` (code 56): the same shape, server-wide. +pub struct GlobalRecommendationsHandler; + +impl MessageHandler for GlobalRecommendationsHandler { + fn get_code(&self) -> u32 { + 56 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let recommended = read_recommendations(message); + let unrecommended = read_recommendations(message); + let _ = sender.send(ServerMessage::GlobalRecommendationsReceived { + recommended, + unrecommended, + }); + } +} + +/// `ItemRecommendations` (code 111): what else people who like one item like. +pub struct ItemRecommendationsHandler; + +impl MessageHandler for ItemRecommendationsHandler { + fn get_code(&self) -> u32 { + 111 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let item = message.read_string(); + let recommendations = read_recommendations(message); + let _ = sender.send(ServerMessage::ItemRecommendationsReceived { + item, + recommendations, + }); + } +} + +/// `SimilarUsers` (code 110): users whose interests overlap ours. +pub struct SimilarUsersHandler; + +impl MessageHandler for SimilarUsersHandler { + fn get_code(&self) -> u32 { + 110 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let count = message.read_int32(); + let mut users = Vec::new(); + for _ in 0..count { + let username = message.read_string(); + if username.is_empty() { + break; + } + let weight = message.read_int32(); + users.push(SimilarUser { username, weight }); + } + let _ = sender.send(ServerMessage::SimilarUsersReceived { users }); + } +} + +/// `ItemSimilarUsers` (code 112): who likes one item. The server sends only +/// names here, so every weight reads as zero. +pub struct ItemSimilarUsersHandler; + +impl MessageHandler for ItemSimilarUsersHandler { + fn get_code(&self) -> u32 { + 112 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let item = message.read_string(); + let usernames = read_strings(message); + let _ = sender + .send(ServerMessage::ItemSimilarUsersReceived { item, usernames }); + } +} + +/// `UserInterests` (code 57): what one user likes and hates. +pub struct UserInterestsHandler; + +impl MessageHandler for UserInterestsHandler { + fn get_code(&self) -> u32 { + 57 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let username = message.read_string(); + let likes = read_strings(message); + let hates = read_strings(message); + let _ = + sender.send(ServerMessage::UserInterestsReceived(UserInterests { + username, + likes, + hates, + })); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn recommendations_split_into_recommended_and_not() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(1); + m.write_string("jazz"); + m.write_int32(3); + m.write_int32(1); + m.write_string("polka"); + #[allow(clippy::cast_sign_loss)] + m.write_int32(-2i32 as u32); + }); + + RecommendationsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RecommendationsReceived { + recommended, + unrecommended, + }) => { + assert_eq!( + recommended, + vec![Recommendation { + item: "jazz".into(), + rating: 3 + }] + ); + assert_eq!( + unrecommended, + vec![Recommendation { + item: "polka".into(), + rating: -2 + }] + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn global_recommendations_parse_the_same_shape() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(1); + m.write_string("dub"); + m.write_int32(9); + m.write_int32(0); + }); + + GlobalRecommendationsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::GlobalRecommendationsReceived { + recommended, + unrecommended, + }) => { + assert_eq!(recommended.len(), 1); + assert_eq!(recommended[0].item, "dub"); + assert!(unrecommended.is_empty()); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn similar_users_carry_their_weight() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(2); + m.write_string("alice"); + m.write_int32(4); + m.write_string("bob"); + m.write_int32(1); + }); + + SimilarUsersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::SimilarUsersReceived { users }) => { + assert_eq!( + users, + vec![ + SimilarUser { + username: "alice".into(), + weight: 4 + }, + SimilarUser { + username: "bob".into(), + weight: 1 + }, + ] + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn item_similar_users_report_the_item_they_answer_for() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_int32(1); + m.write_string("alice"); + }); + + ItemSimilarUsersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ItemSimilarUsersReceived { item, usernames }) => { + assert_eq!(item, "jazz"); + assert_eq!(usernames, vec!["alice".to_string()]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn item_recommendations_report_their_item() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_int32(1); + m.write_string("blues"); + m.write_int32(2); + }); + + ItemRecommendationsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ItemRecommendationsReceived { + item, + recommendations, + }) => { + assert_eq!(item, "jazz"); + assert_eq!(recommendations[0].item, "blues"); + assert_eq!(recommendations[0].rating, 2); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_users_likes_and_hates_arrive_as_two_lists() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("alice"); + m.write_int32(2); + m.write_string("jazz"); + m.write_string("dub"); + m.write_int32(1); + m.write_string("polka"); + }); + + UserInterestsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::UserInterestsReceived(interests)) => { + assert_eq!(interests.username, "alice"); + assert_eq!(interests.likes, ["jazz", "dub"]); + assert_eq!(interests.hates, ["polka"]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_user_with_no_interests_parses_to_empty_lists() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("ghost"); + m.write_int32(0); + m.write_int32(0); + }); + + UserInterestsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::UserInterestsReceived(interests)) => { + assert!(interests.likes.is_empty()); + assert!(interests.hates.is_empty()); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/message_factory.rs b/soulseek-rs-lib/src/message/server/message_factory.rs index 620508a..0f7386b 100644 --- a/soulseek-rs-lib/src/message/server/message_factory.rs +++ b/soulseek-rs-lib/src/message/server/message_factory.rs @@ -301,6 +301,175 @@ impl MessageFactory { .clone() } + /// A keepalive ping (server code 32, no body). The server sends no reply; + /// its only job is to keep a quiet connection from being reaped by a NAT + /// or the server's own idle timeout, the way other clients ping. + #[must_use] + pub fn build_server_ping() -> Message { + Message::new().write_int32(32).clone() + } + + /// Search one user's shares (server code 42). Replies come back over a + /// peer connection as ordinary `FileSearchResponse` frames, so the token + /// is drawn from the same space as a plain `FileSearch`. + #[must_use] + pub fn build_user_search( + username: &str, + token: u32, + query: &str, + ) -> Message { + Message::new() + .write_int32(42) + .write_string(username) + .write_int32(token) + .write_string(query) + .clone() + } + + /// Search the shares of everyone in `room` (server code 120). + #[must_use] + pub fn build_room_search(room: &str, token: u32, query: &str) -> Message { + Message::new() + .write_int32(120) + .write_string(room) + .write_int32(token) + .write_string(query) + .clone() + } + + /// Tell the server (code 1001) that the peer it asked us to connect to, + /// quoting `token`, could not be reached. The server relays that to the + /// peer so it stops waiting on a connection that is never coming. + #[must_use] + pub fn build_cant_connect_to_peer(token: u32, username: &str) -> Message { + Message::new() + .write_int32(1001) + .write_int32(token) + .write_string(username) + .clone() + } + + /// Set our ticker (the scrolling one-line message) in `room`, server code + /// 116. An empty ticker removes ours. + #[must_use] + pub fn build_set_room_ticker(room: &str, ticker: &str) -> Message { + Message::new() + .write_int32(116) + .write_string(room) + .write_string(ticker) + .clone() + } + + /// Subscribe to the global room feed (server code 150): every public room + /// message on the server, tagged with the room it came from. + #[must_use] + pub fn build_join_global_room() -> Message { + Message::new().write_int32(150).clone() + } + + /// Stop the global room feed (server code 151). + #[must_use] + pub fn build_leave_global_room() -> Message { + Message::new().write_int32(151).clone() + } + + /// Add an interest (server code 51). Interests drive the recommendation + /// and similar-user queries below. + #[must_use] + pub fn build_add_thing_i_like(item: &str) -> Message { + Message::new().write_int32(51).write_string(item).clone() + } + + /// Drop an interest (server code 52). + #[must_use] + pub fn build_remove_thing_i_like(item: &str) -> Message { + Message::new().write_int32(52).write_string(item).clone() + } + + /// Add a dislike (server code 117). + #[must_use] + pub fn build_add_thing_i_hate(item: &str) -> Message { + Message::new().write_int32(117).write_string(item).clone() + } + + /// Drop a dislike (server code 118). + #[must_use] + pub fn build_remove_thing_i_hate(item: &str) -> Message { + Message::new().write_int32(118).write_string(item).clone() + } + + /// Ask for recommendations based on our own interests (server code 54). + #[must_use] + pub fn build_get_recommendations() -> Message { + Message::new().write_int32(54).clone() + } + + /// Ask for the server-wide recommendations (server code 56). + #[must_use] + pub fn build_global_recommendations() -> Message { + Message::new().write_int32(56).clone() + } + + /// Ask what `username` likes and hates (server code 57). + #[must_use] + pub fn build_user_interests(username: &str) -> Message { + Message::new() + .write_int32(57) + .write_string(username) + .clone() + } + + /// Ask for users whose interests overlap ours (server code 110). + #[must_use] + pub fn build_similar_users() -> Message { + Message::new().write_int32(110).clone() + } + + /// Ask what else people who like `item` like (server code 111). + #[must_use] + pub fn build_item_recommendations(item: &str) -> Message { + Message::new().write_int32(111).write_string(item).clone() + } + + /// Ask which users like `item` (server code 112). + #[must_use] + pub fn build_item_similar_users(item: &str) -> Message { + Message::new().write_int32(112).write_string(item).clone() + } + + /// Hand `days` of our own privileges to `username` (server code 123). + #[must_use] + pub fn build_give_privileges(username: &str, days: u32) -> Message { + Message::new() + .write_int32(123) + .write_string(username) + .write_int32(days) + .clone() + } + + /// Change our account password (server code 142). The server echoes the + /// accepted password back under the same code. + #[must_use] + pub fn build_change_password(password: &str) -> Message { + Message::new() + .write_int32(142) + .write_string(password) + .clone() + } + + /// Send one private message to several users at once (server code 149). + #[must_use] + pub fn build_message_users(usernames: &[String], message: &str) -> Message { + let mut msg = Message::new(); + msg.write_int32(149); + msg.write_int32(usernames.len() as u32); + for username in usernames { + msg.write_string(username); + } + msg.write_string(message); + msg + } + #[must_use] pub fn build_peer_init_message( own_username: &str, @@ -517,3 +686,129 @@ fn an_upload_speed_report_carries_the_rate() { decoded.set_pointer(8); assert_eq!(decoded.read_int32(), 900); } + +#[test] +fn a_ping_is_just_its_code() { + let message = MessageFactory::build_server_ping(); + assert_eq!(message.get_data(), [32, 0, 0, 0]); +} + +#[test] +fn a_user_search_names_the_user_before_its_token() { + let message = MessageFactory::build_user_search("bob", 99, "jazz"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 42); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "bob"); + assert_eq!(decoded.read_int32(), 99); + assert_eq!(decoded.read_string(), "jazz"); +} + +#[test] +fn a_room_search_names_the_room_before_its_token() { + let message = MessageFactory::build_room_search("nicotine", 7, "dub"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 120); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "nicotine"); + assert_eq!(decoded.read_int32(), 7); + assert_eq!(decoded.read_string(), "dub"); +} + +#[test] +fn a_cant_connect_carries_the_token_then_the_user() { + let message = MessageFactory::build_cant_connect_to_peer(4242, "bob"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 1001); + decoded.set_pointer(8); + assert_eq!(decoded.read_int32(), 4242); + assert_eq!(decoded.read_string(), "bob"); +} + +#[test] +fn a_room_ticker_carries_its_room_and_text() { + let message = MessageFactory::build_set_room_ticker("jazz", "hello"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 116); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "jazz"); + assert_eq!(decoded.read_string(), "hello"); +} + +#[test] +fn the_global_room_is_joined_and_left_by_code_alone() { + assert_eq!( + MessageFactory::build_join_global_room().get_data(), + [150, 0, 0, 0] + ); + assert_eq!( + MessageFactory::build_leave_global_room().get_data(), + [151, 0, 0, 0] + ); +} + +#[test] +fn each_interest_message_uses_its_own_code() { + for (message, code) in [ + (MessageFactory::build_add_thing_i_like("jazz"), 51), + (MessageFactory::build_remove_thing_i_like("jazz"), 52), + (MessageFactory::build_add_thing_i_hate("polka"), 117), + (MessageFactory::build_remove_thing_i_hate("polka"), 118), + (MessageFactory::build_user_interests("bob"), 57), + (MessageFactory::build_item_recommendations("jazz"), 111), + (MessageFactory::build_item_similar_users("jazz"), 112), + ] { + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), code); + decoded.set_pointer(8); + assert!(!decoded.read_string().is_empty()); + } +} + +#[test] +fn the_bodyless_queries_are_just_their_codes() { + assert_eq!( + MessageFactory::build_get_recommendations().get_data(), + [54, 0, 0, 0] + ); + assert_eq!( + MessageFactory::build_global_recommendations().get_data(), + [56, 0, 0, 0] + ); + assert_eq!( + MessageFactory::build_similar_users().get_data(), + [110, 0, 0, 0] + ); +} + +#[test] +fn a_privilege_gift_carries_the_recipient_and_days() { + let message = MessageFactory::build_give_privileges("bob", 3); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 123); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "bob"); + assert_eq!(decoded.read_int32(), 3); +} + +#[test] +fn a_password_change_carries_the_new_password() { + let message = MessageFactory::build_change_password("hunter2"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 142); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "hunter2"); +} + +#[test] +fn a_multi_user_message_counts_its_recipients_first() { + let recipients = vec!["alice".to_string(), "bob".to_string()]; + let message = MessageFactory::build_message_users(&recipients, "hi all"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 149); + decoded.set_pointer(8); + assert_eq!(decoded.read_int32(), 2); + assert_eq!(decoded.read_string(), "alice"); + assert_eq!(decoded.read_string(), "bob"); + assert_eq!(decoded.read_string(), "hi all"); +} diff --git a/soulseek-rs-lib/src/message/server/mod.rs b/soulseek-rs-lib/src/message/server/mod.rs index 35fcc3b..76eb9e4 100644 --- a/soulseek-rs-lib/src/message/server/mod.rs +++ b/soulseek-rs-lib/src/message/server/mod.rs @@ -1,9 +1,12 @@ mod admin_message; +mod cant_connect_to_peer; mod connect_to_peer; mod distributed; mod excluded_search_phrases; mod file_search; mod get_peer_address; +mod global_room; +mod interests; mod join_room; mod leave_room; mod login; @@ -14,6 +17,7 @@ mod parent_speed_ratio; mod privileged_users; mod relogged; mod room_list; +mod room_tickers; mod say_chatroom; mod user_info; mod user_joined_room; @@ -22,6 +26,7 @@ mod watch_user; mod wish_list_interval; pub use admin_message::AdminMessageHandler; +pub use cant_connect_to_peer::CantConnectToPeerHandler; pub use connect_to_peer::ConnectToPeerHandler; pub use distributed::{ EmbeddedMessageHandler, PossibleParentsHandler, ResetDistributedHandler, @@ -29,6 +34,12 @@ pub use distributed::{ pub use excluded_search_phrases::ExcludedSearchPhrasesHandler; pub use file_search::FileSearchHandler; pub use get_peer_address::GetPeerAddressHandler; +pub use global_room::GlobalRoomMessageHandler; +pub use interests::{ + GlobalRecommendationsHandler, ItemRecommendationsHandler, + ItemSimilarUsersHandler, RecommendationsHandler, SimilarUsersHandler, + UserInterestsHandler, +}; pub use join_room::JoinRoomHandler; pub use leave_room::LeaveRoomHandler; pub use login::LoginHandler; @@ -39,6 +50,9 @@ pub use parent_speed_ratio::ParentSpeedRatioHandler; pub use privileged_users::{CheckPrivilegesHandler, PrivilegedUsersHandler}; pub use relogged::ReloggedHandler; pub use room_list::RoomListHandler; +pub use room_tickers::{ + RoomTickerAddedHandler, RoomTickerRemovedHandler, RoomTickersHandler, +}; pub use say_chatroom::SayChatroomHandler; pub use user_info::{GetUserStatsHandler, GetUserStatusHandler}; pub use user_joined_room::UserJoinedRoomHandler; diff --git a/soulseek-rs-lib/src/message/server/room_tickers.rs b/soulseek-rs-lib/src/message/server/room_tickers.rs new file mode 100644 index 0000000..d9ab1c9 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/room_tickers.rs @@ -0,0 +1,174 @@ +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, + types::RoomTicker, +}; + +/// `RoomTickers` (code 113): the whole ticker board of a room, sent when we +/// join it. +pub struct RoomTickersHandler; + +impl MessageHandler for RoomTickersHandler { + fn get_code(&self) -> u32 { + 113 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let count = message.read_int32(); + // A truncated or hostile count must not preallocate: grow as entries + // actually parse, and stop at the first one that runs off the end. + let mut tickers = Vec::new(); + for _ in 0..count { + let username = message.read_string(); + let ticker = message.read_string(); + if username.is_empty() { + break; + } + tickers.push(RoomTicker { username, ticker }); + } + let _ = sender.send(ServerMessage::RoomTickers { room, tickers }); + } +} + +/// `RoomTickerAdded` (code 114): one user set or replaced their ticker. +pub struct RoomTickerAddedHandler; + +impl MessageHandler for RoomTickerAddedHandler { + fn get_code(&self) -> u32 { + 114 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let ticker = message.read_string(); + let _ = sender.send(ServerMessage::RoomTickerAdded { + room, + username, + ticker, + }); + } +} + +/// `RoomTickerRemoved` (code 115): one user cleared their ticker. +pub struct RoomTickerRemovedHandler; + +impl MessageHandler for RoomTickerRemovedHandler { + fn get_code(&self) -> u32 { + 115 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let _ = + sender.send(ServerMessage::RoomTickerRemoved { room, username }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn a_ticker_board_carries_every_entry_in_order() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_int32(2); + m.write_string("alice"); + m.write_string("hello there"); + m.write_string("bob"); + m.write_string("back later"); + }); + + RoomTickersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomTickers { room, tickers }) => { + assert_eq!(room, "jazz"); + assert_eq!( + tickers, + vec![ + RoomTicker { + username: "alice".into(), + ticker: "hello there".into() + }, + RoomTicker { + username: "bob".into(), + ticker: "back later".into() + }, + ] + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_count_larger_than_the_payload_stops_at_the_data() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_int32(1_000_000); + m.write_string("alice"); + m.write_string("hi"); + }); + + RoomTickersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomTickers { tickers, .. }) => { + assert_eq!(tickers.len(), 1); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn an_added_ticker_reports_its_room_user_and_text() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_string("alice"); + m.write_string("new ticker"); + }); + + RoomTickerAddedHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomTickerAdded { + room, + username, + ticker, + }) => { + assert_eq!( + (room.as_str(), username.as_str(), ticker.as_str()), + ("jazz", "alice", "new ticker") + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_removed_ticker_reports_its_room_and_user() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_string("alice"); + }); + + RoomTickerRemovedHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomTickerRemoved { room, username }) => { + assert_eq!( + (room.as_str(), username.as_str()), + ("jazz", "alice") + ); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/peer/mod.rs b/soulseek-rs-lib/src/peer/mod.rs index 301603c..15494ba 100644 --- a/soulseek-rs-lib/src/peer/mod.rs +++ b/soulseek-rs-lib/src/peer/mod.rs @@ -64,6 +64,10 @@ pub struct Peer { pub privileged: Option, pub unknown: Option, pub obfuscated_port: Option, + /// True when the server asked us to make this connection (`ConnectToPeer`, + /// code 18). Then `token` is the far peer's correlation token, and a dial + /// that fails owes them a `CantConnectToPeer` quoting it. + pub brokered: bool, } impl Peer { #[allow(clippy::too_many_arguments)] @@ -87,6 +91,7 @@ impl Peer { privileged: Some(privileged), unknown: Some(unknown), obfuscated_port: Some(obfuscated_port), + brokered: false, } } pub fn new_from_message(message: &mut Message) -> Option { @@ -120,6 +125,7 @@ impl Peer { privileged: Some(privileged), unknown: Some(unknown), obfuscated_port: Some(u16::from(obfuscated_port)), + brokered: true, }) } } diff --git a/soulseek-rs-lib/src/types.rs b/soulseek-rs-lib/src/types.rs index 1f07ea5..c057acf 100644 --- a/soulseek-rs-lib/src/types.rs +++ b/soulseek-rs-lib/src/types.rs @@ -393,6 +393,58 @@ pub enum RoomEvent { UserJoined { room: String, username: String }, /// `username` left `room`. UserLeft { room: String, username: String }, + /// The full ticker board of `room`, sent when we join it (code 113). + Tickers { + room: String, + tickers: Vec, + }, + /// `username` set their ticker in `room` (code 114). It replaces any + /// previous ticker of theirs. + TickerAdded { + room: String, + username: String, + ticker: String, + }, + /// `username` cleared their ticker in `room` (code 115). + TickerRemoved { room: String, username: String }, + /// A message said in *any* public room, delivered because we joined the + /// global room feed (code 152). `room` names where it was said. + GlobalMessage { + room: String, + username: String, + message: String, + }, +} + +/// One user's ticker (the scrolling one-line message) in a room. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoomTicker { + pub username: String, + pub ticker: String, +} + +/// One recommended item and how strongly the server recommends it. The rating +/// is signed: a negative rating means the item is recommended *against*. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Recommendation { + pub item: String, + pub rating: i32, +} + +/// A user the server considers similar to us, with the weight (the number of +/// interests shared) it scored them by. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SimilarUser { + pub username: String, + pub weight: u32, +} + +/// What a user likes and hates (`UserInterests`, code 57). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UserInterests { + pub username: String, + pub likes: Vec, + pub hates: Vec, } impl Transfer { From 60a0f8fc91c6e9f1f144381c52bd856eef757f4a Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 16:22:32 +0000 Subject: [PATCH 23/55] test(e2e): drive the new protocol messages against soulfind Eleven scenarios over a real server: tickers reaching other members and the board handed to a late joiner, the global room feed starting and stopping, user and room searches answered by their targets, interests driving similar users and recommendations, a password change taking effect on the next login, one message reaching several users, and a brokered dial we cannot complete reporting CantConnectToPeer to the peer that is waiting on it. --- soulseek-rs-lib/tests/e2e.rs | 551 +++++++++++++++++++++++++++++++++++ 1 file changed, 551 insertions(+) diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 4f6149c..1f9d3ee 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -3768,3 +3768,554 @@ fn a_leaf_adopts_a_parent_and_answers_the_searches_it_passes_down() { let _ = std::fs::remove_dir_all(share_dir); } + +// --------------------------------------------------------------------------- +// Room tickers, the global room feed, interests and the targeted searches. +// +// Every one of these is a message the client can now speak; the point of +// driving them through soulfind is that the wire shapes are right in both +// directions, not just that they parse. +// --------------------------------------------------------------------------- + +/// Poll `client`'s room events until `pick` matches one, or time out. +fn await_room_event( + client: &Client, + timeout: Duration, + mut pick: impl FnMut(&soulseek_rs::types::RoomEvent) -> Option, +) -> Option { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + for event in client.take_room_events() { + if let Some(found) = pick(&event) { + return Some(found); + } + } + std::thread::sleep(Duration::from_millis(100)); + } + None +} + +#[test] +fn a_ticker_set_in_a_room_reaches_the_other_members() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_ticker_room"; + let mut alice = + Client::with_settings(server.settings("e2e_tick_alice", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_tick_bob", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + alice.join_room(room).expect("alice joins"); + bob.join_room(room).expect("bob joins"); + std::thread::sleep(Duration::from_millis(500)); + let _ = bob.take_room_events(); + + alice + .set_room_ticker(room, "alice was here") + .expect("set ticker"); + + let seen = + await_room_event(&bob, Duration::from_secs(5), |event| match event { + RoomEvent::TickerAdded { + room: r, + username, + ticker, + } if r == room => Some((username.clone(), ticker.clone())), + _ => None, + }) + .expect("bob should see alice's ticker"); + assert_eq!( + seen, + ("e2e_tick_alice".to_string(), "alice was here".into()) + ); + + // The board is kept, not just the event: a UI reads it back from state. + let board = bob.room_tickers(room); + assert_eq!( + board + .iter() + .find(|t| t.username == "e2e_tick_alice") + .map(|t| t.ticker.as_str()), + Some("alice was here") + ); +} + +#[test] +fn a_client_joining_later_receives_the_whole_ticker_board() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_ticker_board"; + let mut alice = + Client::with_settings(server.settings("e2e_board_alice", "pw")); + alice.connect().expect("alice connect"); + assert!(alice.login().expect("alice login")); + alice.join_room(room).expect("alice joins"); + std::thread::sleep(Duration::from_millis(300)); + alice + .set_room_ticker(room, "standing message") + .expect("set ticker"); + std::thread::sleep(Duration::from_millis(500)); + + // Carol joins afterwards and must be handed the board that already exists. + let mut carol = + Client::with_settings(server.settings("e2e_board_carol", "pw")); + carol.connect().expect("carol connect"); + assert!(carol.login().expect("carol login")); + carol.join_room(room).expect("carol joins"); + + let tickers = + await_room_event(&carol, Duration::from_secs(5), |event| match event { + RoomEvent::Tickers { room: r, tickers } if r == room => { + Some(tickers.clone()) + } + _ => None, + }) + .expect("carol should receive the ticker board on join"); + assert!( + tickers.iter().any(|t| t.username == "e2e_board_alice" + && t.ticker == "standing message"), + "the board should carry alice's standing ticker, got {tickers:?}" + ); +} + +#[test] +fn the_global_room_feed_carries_a_room_we_never_joined() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_global_source"; + let mut watcher = + Client::with_settings(server.settings("e2e_global_watch", "pw")); + let mut talker = + Client::with_settings(server.settings("e2e_global_talk", "pw")); + watcher.connect().expect("watcher connect"); + talker.connect().expect("talker connect"); + assert!(watcher.login().expect("watcher login")); + assert!(talker.login().expect("talker login")); + + watcher.join_global_room().expect("join global room"); + talker.join_room(room).expect("talker joins a room"); + std::thread::sleep(Duration::from_millis(500)); + let _ = watcher.take_room_events(); + + let body = "spoken where nobody is watching"; + talker.say_in_room(room, body).expect("say in room"); + + let got = + await_room_event( + &watcher, + Duration::from_secs(5), + |event| match event { + RoomEvent::GlobalMessage { + room: r, + username, + message, + } if message == body => Some((r.clone(), username.clone())), + _ => None, + }, + ) + .expect("the global feed should carry the message"); + assert_eq!(got, (room.to_string(), "e2e_global_talk".to_string())); + + // And leaving the feed stops it: the next message must not arrive. + watcher.leave_global_room().expect("leave global room"); + std::thread::sleep(Duration::from_millis(500)); + let _ = watcher.take_room_events(); + talker + .say_in_room(room, "after leaving") + .expect("say again"); + let after = await_room_event(&watcher, Duration::from_secs(2), |event| { + matches!(event, RoomEvent::GlobalMessage { message, .. } + if message == "after leaving") + .then_some(()) + }); + assert!(after.is_none(), "the feed should stop once left"); +} + +#[test] +fn a_user_search_is_answered_by_the_user_it_names() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + let content: Vec = (0..1024u32).map(|i| (i % 251) as u8).collect(); + std::fs::write(share_dir.join("e2e_usersearch_grail.bin"), &content) + .unwrap(); + + let (_sharer, searcher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_us_sharer", + "e2e_us_seeker", + ); + + let query = "grail"; + searcher + .search_user("e2e_us_sharer", query) + .expect("user search"); + + let reply = reply_from(&searcher, query, "e2e_us_sharer") + .expect("the named user should answer a user search"); + assert!( + reply + .files + .iter() + .any(|f| f.name.contains("e2e_usersearch_grail")), + "the reply should carry the matching file, got {:?}", + reply.files + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +#[test] +fn a_room_search_is_answered_by_the_rooms_members() { + let server = server_or_skip!(); + + let room = "e2e_search_room"; + let share_dir = unique_download_dir(); + let content: Vec = (0..1024u32).map(|i| (i % 251) as u8).collect(); + std::fs::write(share_dir.join("e2e_roomsearch_relic.bin"), &content) + .unwrap(); + + let (sharer, searcher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_rs_sharer", + "e2e_rs_seeker", + ); + sharer.join_room(room).expect("sharer joins"); + searcher.join_room(room).expect("searcher joins"); + std::thread::sleep(Duration::from_millis(750)); + + let query = "relic"; + searcher.search_room(room, query).expect("room search"); + + let reply = reply_from(&searcher, query, "e2e_rs_sharer") + .expect("a member of the room should answer a room search"); + assert!( + reply + .files + .iter() + .any(|f| f.name.contains("e2e_roomsearch_relic")), + "the reply should carry the matching file, got {:?}", + reply.files + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +#[test] +fn shared_interests_make_two_users_similar() { + let server = server_or_skip!(); + + let mut alice = + Client::with_settings(server.settings("e2e_like_alice", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_like_bob", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + let item = "e2e_interest_krautrock"; + alice.add_interest(item).expect("alice likes it"); + bob.add_interest(item).expect("bob likes it too"); + alice + .add_dislike("e2e_interest_muzak") + .expect("alice dislikes something"); + std::thread::sleep(Duration::from_millis(500)); + + // Who else likes this item (code 112). + let deadline = Instant::now() + Duration::from_secs(5); + let mut likers = Vec::new(); + while Instant::now() < deadline + && !likers.iter().any(|u| u == "e2e_like_bob") + { + alice + .request_item_similar_users(item) + .expect("ask who likes it"); + std::thread::sleep(Duration::from_millis(250)); + likers = alice.item_similar_users(item); + } + assert!( + likers.iter().any(|u| u == "e2e_like_bob"), + "bob likes the same item, got {likers:?}" + ); + + // The overlap also makes bob a similar user (code 110). + let deadline = Instant::now() + Duration::from_secs(5); + let mut similar = Vec::new(); + while Instant::now() < deadline + && !similar + .iter() + .any(|u: &soulseek_rs::SimilarUser| u.username == "e2e_like_bob") + { + alice + .request_similar_users() + .expect("ask for similar users"); + std::thread::sleep(Duration::from_millis(250)); + similar = alice.similar_users(); + } + assert!( + similar + .iter() + .any(|u| u.username == "e2e_like_bob" && u.weight > 0), + "bob should be similar with a non-zero weight, got {similar:?}" + ); + + // And alice's own likes and hates read back over code 57. + let deadline = Instant::now() + Duration::from_secs(5); + let mut interests = None; + while Instant::now() < deadline && interests.is_none() { + bob.request_user_interests("e2e_like_alice") + .expect("ask about alice"); + std::thread::sleep(Duration::from_millis(250)); + interests = bob.user_interests("e2e_like_alice"); + } + let interests = interests.expect("bob should learn alice's interests"); + assert!(interests.likes.iter().any(|i| i == item)); + assert!( + interests.hates.iter().any(|i| i == "e2e_interest_muzak"), + "hates should come back too, got {interests:?}" + ); + + // A dropped interest stops being reported. + alice.remove_interest(item).expect("alice drops it"); + let deadline = Instant::now() + Duration::from_secs(5); + let mut still_liked = true; + while Instant::now() < deadline && still_liked { + bob.request_user_interests("e2e_like_alice") + .expect("ask again"); + std::thread::sleep(Duration::from_millis(250)); + still_liked = bob + .user_interests("e2e_like_alice") + .is_none_or(|i| i.likes.iter().any(|l| l == item)); + } + assert!( + !still_liked, + "a removed interest should stop being reported" + ); +} + +#[test] +fn recommendations_are_returned_for_our_interests() { + let server = server_or_skip!(); + + // Recommendations come from what *other* users who share an interest also + // like: bob likes both items, so alice, who likes only the first, should + // be recommended the second. + let mut alice = + Client::with_settings(server.settings("e2e_rec_alice", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_rec_bob", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + let shared = "e2e_rec_shared"; + let other = "e2e_rec_other"; + alice.add_interest(shared).expect("alice likes shared"); + bob.add_interest(shared).expect("bob likes shared"); + bob.add_interest(other).expect("bob likes other"); + std::thread::sleep(Duration::from_millis(500)); + + let deadline = Instant::now() + Duration::from_secs(8); + let mut recommended = Vec::new(); + while Instant::now() < deadline + && !recommended + .iter() + .any(|r: &soulseek_rs::Recommendation| r.item == other) + { + alice + .request_recommendations() + .expect("ask for recommendations"); + std::thread::sleep(Duration::from_millis(250)); + recommended = + alice.recommendations().map(|(r, _)| r).unwrap_or_default(); + } + assert!( + recommended.iter().any(|r| r.item == other), + "the other item bob likes should be recommended, got {recommended:?}" + ); + + // The server-wide list (code 56) answers too, with the same shape. + let deadline = Instant::now() + Duration::from_secs(8); + let mut global = None; + while Instant::now() < deadline && global.is_none() { + alice + .request_global_recommendations() + .expect("ask for global recommendations"); + std::thread::sleep(Duration::from_millis(250)); + global = alice.global_recommendations(); + } + let (global_items, _) = global.expect("the server should answer code 56"); + assert!( + global_items.iter().any(|r| r.item == shared), + "an item two users like should show server-wide, got {global_items:?}" + ); + + // And per-item recommendations (code 111) answer for a named item. + let deadline = Instant::now() + Duration::from_secs(8); + let mut item_recs = Vec::new(); + while Instant::now() < deadline && item_recs.is_empty() { + alice + .request_item_recommendations(shared) + .expect("ask about one item"); + std::thread::sleep(Duration::from_millis(250)); + item_recs = alice.item_recommendations(shared); + } + assert!( + item_recs.iter().any(|r| r.item == other), + "people who like {shared} also like {other}, got {item_recs:?}" + ); +} + +#[test] +fn a_ping_leaves_the_session_usable() { + let server = server_or_skip!(); + + // A ping has no reply, so what it must not do is upset the session: the + // very next request has to still be answered. + let mut client = + Client::with_settings(server.settings("e2e_ping_user", "pw")); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + for _ in 0..3 { + client.ping_server().expect("ping"); + std::thread::sleep(Duration::from_millis(100)); + } + + let deadline = Instant::now() + Duration::from_secs(5); + let mut listed = false; + while Instant::now() < deadline && !listed { + client.request_room_list().expect("request room list"); + std::thread::sleep(Duration::from_millis(250)); + listed = !client.room_list().is_empty() + || client.session_loss().is_none() && Instant::now() > deadline; + } + assert!( + client.session_loss().is_none(), + "pings must not cost us the session" + ); +} + +#[test] +fn one_message_reaches_several_users_at_once() { + let server = server_or_skip!(); + + let mut sender = + Client::with_settings(server.settings("e2e_many_sender", "pw")); + let mut first = + Client::with_settings(server.settings("e2e_many_first", "pw")); + let mut second = + Client::with_settings(server.settings("e2e_many_second", "pw")); + for client in [&mut sender, &mut first, &mut second] { + client.connect().expect("connect"); + assert!(client.login().expect("login")); + } + + let body = "one message, two recipients"; + sender + .send_private_message_to_many( + &["e2e_many_first".to_string(), "e2e_many_second".to_string()], + body, + ) + .expect("send to many"); + + for client in [&first, &second] { + let deadline = Instant::now() + Duration::from_secs(5); + let mut got = false; + while Instant::now() < deadline && !got { + got = client + .take_private_messages() + .iter() + .any(|m| m.message() == body); + if !got { + std::thread::sleep(Duration::from_millis(100)); + } + } + assert!(got, "every named recipient should receive the message"); + } +} + +#[test] +fn a_changed_password_is_what_the_next_login_needs() { + let server = server_or_skip!(); + + let user = "e2e_pwchange_user"; + let mut client = Client::with_settings(server.settings(user, "first-pw")); + client.connect().expect("connect"); + assert!(client.login().expect("login registers the account")); + + client + .change_password("second-pw") + .expect("change password"); + std::thread::sleep(Duration::from_millis(500)); + drop(client); + + let mut stale = Client::with_settings(server.settings(user, "first-pw")); + stale.connect().expect("connect with the old password"); + assert!( + !matches!(stale.login(), Ok(true)), + "the old password must stop working" + ); + drop(stale); + + let mut fresh = Client::with_settings(server.settings(user, "second-pw")); + fresh.connect().expect("connect with the new password"); + assert!( + fresh.login().expect("login with the new password"), + "the new password must be accepted" + ); +} + +#[test] +fn a_brokered_dial_we_cannot_complete_tells_the_waiting_peer() { + // The peer asks the server to broker a connection to us but is itself + // unreachable — it advertises a port nothing listens on. Our dial fails, + // and the peer must be told (CantConnectToPeer, code 1001) rather than + // left waiting for a connection that is never coming. + let server = server_or_skip!(); + let addr = format!("{}:{}", server.host, server.port); + + let mut client = + Client::with_settings(server.settings("e2e_cantconn_us", "pw")); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + let mut peer = + login_raw(&addr, "e2e_cantconn_peer", "pw").expect("peer login"); + // A port that is free is a port nothing answers on: the dial is refused. + let dead_port = free_port().expect("free port"); + peer.write_all( + &MessageFactory::build_set_wait_port_message(dead_port).get_buffer(), + ) + .expect("set wait port"); + peer.flush().expect("flush wait port"); + + let token = 424_242_u32; + peer.write_all( + &MessageFactory::build_connect_to_peer( + token, + "e2e_cantconn_us", + ConnectionType::P, + ) + .get_buffer(), + ) + .expect("ask the server to broker"); + peer.flush().expect("flush broker request"); + + let mut reply = read_until_code(&mut peer, 1001, Duration::from_secs(20)) + .expect("the peer should be told the connection failed"); + reply.set_pointer(8); + assert_eq!( + reply.read_int32(), + token, + "the reply should quote the token the peer asked with" + ); +} From 65c44cc66b7f70f4d84c2767b83ca3210b7d3cff Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 16:27:20 +0000 Subject: [PATCH 24/55] feat(protocol): away status, excluded phrases, queue-place requests Three features the client half-had. SetStatus (28) was only ever sent as "online" at login, so there was no way to go away; excluded search phrases (160) were parsed and logged into nowhere, so every client kept spending searches on queries the server was never going to answer; and a queued download could answer a peer asking about its place but could never ask about its own. Each is now driven end to end: the away status seen by another user, a refused query on a stub server that announces phrases (soulfind keeps its filters in a database the suite cannot seed), and a peer's answer landing on the download as its queue position. --- soulseek-rs-lib/src/actor/server_actor.rs | 7 + soulseek-rs-lib/src/client/mod.rs | 31 ++ soulseek-rs-lib/src/client/operations.rs | 5 + soulseek-rs-lib/src/client/rooms.rs | 17 +- soulseek-rs-lib/src/client/search.rs | 26 ++ soulseek-rs-lib/src/client/social.rs | 33 +++ soulseek-rs-lib/src/error.rs | 6 + .../message/server/excluded_search_phrases.rs | 25 +- .../src/message/server/message_factory.rs | 18 ++ soulseek-rs-lib/tests/e2e.rs | 264 ++++++++++++++++++ 10 files changed, 429 insertions(+), 3 deletions(-) diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index 6fb2636..16bd473 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -227,6 +227,8 @@ pub enum ServerMessage { CantConnectToPeer { token: u32, }, + /// Phrases the server refuses to search for (code 160). + ExcludedSearchPhrases(Vec), } pub struct ServerActor { @@ -692,6 +694,11 @@ impl ServerActor { token, }); } + ServerMessage::ExcludedSearchPhrases(phrases) => { + self.forward_to_client(ClientOperation::ExcludedSearchPhrases( + phrases, + )); + } other => { error!("[server] unroutable message: {:?}", other); } diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 75dd60c..61ad87a 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -368,6 +368,8 @@ pub enum ClientOperation { CantConnectToPeer { token: u32, }, + /// Phrases the server refuses to search for (code 160). + ExcludedSearchPhrases(Vec), /// A parent candidate told us how deep it sits. `link` says which dial /// to that user is talking. ParentBranchLevel { @@ -469,6 +471,9 @@ pub struct ClientContext { privileged_users: HashSet, /// Seconds of our own privileges left (code 92), once we have asked. own_privileges: Option, + /// Phrases the server will not search for (code 160). A query carrying one + /// is refused locally rather than spent on the server. + excluded_search_phrases: Vec, /// Peers waiting for one of our upload slots. upload_queue: Vec, /// Arrival counter for the queue's first-come tie-break. @@ -575,6 +580,7 @@ impl ClientContext { wishlist_interval: None, privileged_users: HashSet::new(), own_privileges: None, + excluded_search_phrases: Vec::new(), upload_queue: Vec::new(), upload_seq: 0, upload_slots: DEFAULT_UPLOAD_SLOTS, @@ -725,6 +731,31 @@ impl ClientContext { self.user_interests.get(username).cloned() } + /// Record the phrases the server refuses to search for (code 160). + pub fn set_excluded_search_phrases(&mut self, phrases: Vec) { + self.excluded_search_phrases = phrases; + } + + /// The phrases the server refuses to search for. + #[must_use] + pub fn excluded_search_phrases(&self) -> Vec { + self.excluded_search_phrases.clone() + } + + /// The first excluded phrase `query` contains, if any. Matching is + /// case-insensitive and on substrings, which is how the server applies its + /// own filter. + #[must_use] + pub fn excluded_phrase_in(&self, query: &str) -> Option { + let haystack = query.to_lowercase(); + self.excluded_search_phrases + .iter() + .find(|phrase| { + !phrase.is_empty() && haystack.contains(&phrase.to_lowercase()) + }) + .cloned() + } + /// Forget any interests cached for `username`, so a poll after a fresh /// request cannot mistake the previous answer for this one. pub fn invalidate_user_interests(&mut self, username: &str) { diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index d53c33e..b2dee36 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -816,6 +816,11 @@ impl Client { ctx.apply_user_interests(interests); } } + ClientOperation::ExcludedSearchPhrases(phrases) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.set_excluded_search_phrases(phrases); + } + } ClientOperation::CantConnectToPeer { token } => { // The peer we asked the server to broker gave up. The // token is the correlation we sent, so it names the diff --git a/soulseek-rs-lib/src/client/rooms.rs b/soulseek-rs-lib/src/client/rooms.rs index 6ea95d9..8fee72c 100644 --- a/soulseek-rs-lib/src/client/rooms.rs +++ b/soulseek-rs-lib/src/client/rooms.rs @@ -234,9 +234,22 @@ impl Client { pub fn browse_user(&self, username: &str) -> Result<()> { let request = crate::message::server::MessageFactory::build_get_share_file_list(); + self.context.write_safe()?.mark_browse_pending(username); + self.send_to_peer_or_queue(username, request) + } + + /// Send `request` to `username` over their control connection, or hold it + /// until one exists — asking the server for their address so it does. + /// + /// # Errors + /// [`SoulseekRs::LockPoisoned`] if the client state is poisoned. + pub(super) fn send_to_peer_or_queue( + &self, + username: &str, + request: crate::message::Message, + ) -> Result<()> { let (connected, registry) = { - let mut ctx = self.context.write_safe()?; - ctx.mark_browse_pending(username); + let ctx = self.context.read_safe()?; ( ctx.peer_registry .as_ref() diff --git a/soulseek-rs-lib/src/client/search.rs b/soulseek-rs-lib/src/client/search.rs index 9e54136..cf9ed10 100644 --- a/soulseek-rs-lib/src/client/search.rs +++ b/soulseek-rs-lib/src/client/search.rs @@ -62,6 +62,9 @@ impl Client { let Some(handle) = &self.server_handle else { return Err(SoulseekRs::NotConnected); }; + if let Some(phrase) = self.excluded_phrase_in(query) { + return Err(SoulseekRs::SearchPhraseExcluded(phrase)); + } let token = next_search_token(); self.context.write_safe()?.searches.insert( @@ -119,6 +122,9 @@ impl Client { if self.server_handle.is_none() { return Err(SoulseekRs::NotConnected); } + if let Some(phrase) = self.excluded_phrase_in(query) { + return Err(SoulseekRs::SearchPhraseExcluded(phrase)); + } let token = next_search_token(); self.context.write_safe()?.searches.insert( query.to_string(), @@ -130,6 +136,26 @@ impl Client { Ok(token) } + /// The phrases the server refuses to search for (code 160), as last + /// announced. Empty until the server has said. + #[must_use] + pub fn excluded_search_phrases(&self) -> Vec { + self.context + .read_safe() + .map(|ctx| ctx.excluded_search_phrases()) + .unwrap_or_default() + } + + /// The excluded phrase `query` carries, if any — what a search would be + /// refused for. + #[must_use] + pub fn excluded_phrase_in(&self, query: &str) -> Option { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.excluded_phrase_in(query)) + } + /// Let responses accumulate for `timeout`, or until cancelled. /// /// Peers answer a search over their own connections whenever they get round diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs index 5d402e4..488deb8 100644 --- a/soulseek-rs-lib/src/client/social.rs +++ b/soulseek-rs-lib/src/client/social.rs @@ -21,6 +21,39 @@ impl Client { self.send_server_message(MessageFactory::build_server_ping()) } + /// Set our online status (server code 28): away, or back to online. + /// + /// Other clients see it in their `GetUserStatus` and watch replies, and + /// the server uses it when it decides who to hand a search to. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn set_away(&self, away: bool) -> Result<()> { + let status = u32::from(!away) + 1; // away = 1, online = 2 + self.send_server_message(MessageFactory::build_set_status_message( + status, + )) + } + + /// Ask `username` where the file we queued with them sits (peer code 51). + /// + /// The answer arrives asynchronously and lands on the download, readable + /// through [`Client::downloads`] as its queue position. A peer we have no + /// connection to is resolved and the request delivered once it is up, the + /// same way a browse request is. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection, + /// or [`crate::SoulseekRs::LockPoisoned`] if the client state is poisoned. + pub fn request_place_in_queue( + &self, + username: &str, + filename: &str, + ) -> Result<()> { + let request = MessageFactory::build_place_in_queue_request(filename); + self.send_to_peer_or_queue(username, request) + } + /// Set our ticker in `room` — the one-line message other members see. /// Passing an empty `ticker` clears ours. /// diff --git a/soulseek-rs-lib/src/error.rs b/soulseek-rs-lib/src/error.rs index b25df46..5b3b722 100644 --- a/soulseek-rs-lib/src/error.rs +++ b/soulseek-rs-lib/src/error.rs @@ -20,6 +20,9 @@ pub enum SoulseekRs { CompressionError(String), /// A lock was poisoned by a panic in another thread LockPoisoned, + /// The query carries a phrase the server refuses to search for (code 160), + /// so sending it would spend a search on a query nothing answers. + SearchPhraseExcluded(String), } impl fmt::Display for SoulseekRs { @@ -41,6 +44,9 @@ impl fmt::Display for SoulseekRs { Self::CompressionError(msg) => { write!(f, "Compression error: {msg}") } + Self::SearchPhraseExcluded(phrase) => { + write!(f, "The server excludes searches for \"{phrase}\"") + } Self::LockPoisoned => { write!(f, "Lock poisoned by panicking thread") } diff --git a/soulseek-rs-lib/src/message/server/excluded_search_phrases.rs b/soulseek-rs-lib/src/message/server/excluded_search_phrases.rs index 6779bc1..2233529 100644 --- a/soulseek-rs-lib/src/message/server/excluded_search_phrases.rs +++ b/soulseek-rs-lib/src/message/server/excluded_search_phrases.rs @@ -12,7 +12,7 @@ impl MessageHandler for ExcludedSearchPhrasesHandler { 160 } - fn handle(&self, message: &mut Message, _sender: Sender) { + fn handle(&self, message: &mut Message, sender: Sender) { let item_count = message.read_int32(); let mut exluded_phrases: Vec = Vec::new(); @@ -25,6 +25,11 @@ impl MessageHandler for ExcludedSearchPhrasesHandler { exluded_phrases.push(phrase); } debug!("Excluded search phrases: {:?}", exluded_phrases); + // Kept, not just logged: the server rejects a search carrying one of + // these, so a client that forgets them spends its search allowance on + // queries that were never going to be answered. + let _ = + sender.send(ServerMessage::ExcludedSearchPhrases(exluded_phrases)); } } @@ -43,4 +48,22 @@ mod tests { }); ExcludedSearchPhrasesHandler.handle(&mut message, tx); } + + #[test] + fn the_phrases_are_reported_not_only_logged() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(2); + m.write_string("banned"); + m.write_string("blocked"); + }); + + ExcludedSearchPhrasesHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ExcludedSearchPhrases(phrases)) => { + assert_eq!(phrases, ["banned", "blocked"]); + } + other => panic!("unexpected: {other:?}"), + } + } } diff --git a/soulseek-rs-lib/src/message/server/message_factory.rs b/soulseek-rs-lib/src/message/server/message_factory.rs index 0f7386b..cc3feb7 100644 --- a/soulseek-rs-lib/src/message/server/message_factory.rs +++ b/soulseek-rs-lib/src/message/server/message_factory.rs @@ -301,6 +301,15 @@ impl MessageFactory { .clone() } + /// Ask a peer where the file we queued with them sits (peer code 51). + #[must_use] + pub fn build_place_in_queue_request(filename: &str) -> Message { + Message::new() + .write_int32(51) + .write_string(filename) + .clone() + } + /// A keepalive ping (server code 32, no body). The server sends no reply; /// its only job is to keep a quiet connection from being reaped by a NAT /// or the server's own idle timeout, the way other clients ping. @@ -687,6 +696,15 @@ fn an_upload_speed_report_carries_the_rate() { assert_eq!(decoded.read_int32(), 900); } +#[test] +fn a_place_in_queue_request_names_the_file() { + let message = MessageFactory::build_place_in_queue_request("a\\b.mp3"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 51); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "a\\b.mp3"); +} + #[test] fn a_ping_is_just_its_code() { let message = MessageFactory::build_server_ping(); diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 1f9d3ee..120291e 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -4319,3 +4319,267 @@ fn a_brokered_dial_we_cannot_complete_tells_the_waiting_peer() { "the reply should quote the token the peer asked with" ); } + +// --------------------------------------------------------------------------- +// Features that were implemented but never driven end to end: the away +// status, the phrases the server refuses to search for, and asking a peer +// where our queued file sits. +// --------------------------------------------------------------------------- + +#[test] +fn going_away_is_visible_to_another_user() { + use soulseek_rs::types::UserStatus; + let server = server_or_skip!(); + + let mut alice = + Client::with_settings(server.settings("e2e_away_alice", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_away_bob", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + // Online to begin with, as the post-login handshake announces. + let status_of = |client: &Client| -> Option { + client + .user_info("e2e_away_alice") + .and_then(|info| info.presence) + .map(|presence| presence.status) + }; + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && status_of(&bob) != Some(UserStatus::Online) + { + bob.request_user_info("e2e_away_alice").expect("ask"); + std::thread::sleep(Duration::from_millis(250)); + } + assert_eq!(status_of(&bob), Some(UserStatus::Online)); + + alice.set_away(true).expect("alice goes away"); + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline && status_of(&bob) != Some(UserStatus::Away) + { + bob.request_user_info("e2e_away_alice").expect("ask again"); + std::thread::sleep(Duration::from_millis(250)); + } + assert_eq!( + status_of(&bob), + Some(UserStatus::Away), + "the away status should reach another user" + ); + + // And coming back is visible too. + alice.set_away(false).expect("alice comes back"); + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && status_of(&bob) != Some(UserStatus::Online) + { + bob.request_user_info("e2e_away_alice") + .expect("ask once more"); + std::thread::sleep(Duration::from_millis(250)); + } + assert_eq!(status_of(&bob), Some(UserStatus::Online)); +} + +/// A minimal server that answers a login and then sends `after_login`. +/// +/// soulfind keeps its search filters in its database, which the suite has no +/// way to seed, so the excluded-phrase path is driven from a stub that speaks +/// just enough of the protocol to deliver code 160 on the wire. +fn stub_server_sending( + after_login: Vec, +) -> (String, std::thread::JoinHandle<()>) { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind stub server"); + let addr = listener.local_addr().expect("stub addr"); + let handle = std::thread::spawn(move || { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("stub read timeout"); + // Wait for the login (code 1) before answering it. + loop { + match read_framed(&mut stream) { + Ok(msg) if msg.get_message_code() == 1 => break, + Ok(_) => {} + Err(_) => return, + } + } + let mut ok = Message::new(); + ok.write_int32(1) + .write_int8(1) + .write_string("stub greeting") + .write_int32(0) + .write_string("") + .write_int8(0); + if stream.write_all(&ok.get_buffer()).is_err() { + return; + } + for message in after_login { + if stream.write_all(&message.get_buffer()).is_err() { + return; + } + } + let _ = stream.flush(); + // Hold the connection open so the client stays logged in. + std::thread::sleep(Duration::from_secs(10)); + }); + (format!("{}:{}", addr.ip(), addr.port()), handle) +} + +#[test] +fn an_excluded_phrase_is_refused_before_a_search_is_spent() { + let mut phrases = Message::new(); + phrases + .write_int32(160) + .write_int32(2) + .write_string("banned-phrase") + .write_string("blocked"); + let (addr, stub) = stub_server_sending(vec![phrases]); + let (host, port) = addr.rsplit_once(':').expect("stub addr"); + + let mut client = Client::with_settings(ClientSettings { + username: "e2e_excluded".to_string(), + password: "pw".to_string(), + server_address: PeerAddress::new( + host.to_string(), + port.parse().expect("stub port"), + ), + enable_listen: false, + listen_port: 0, + shared_directories: Vec::new(), + version: ClientVersion::default(), + }); + client.connect().expect("connect to stub"); + assert!(client.login().expect("login to stub")); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && client.excluded_search_phrases().is_empty() + { + std::thread::sleep(Duration::from_millis(100)); + } + assert_eq!( + client.excluded_search_phrases(), + ["banned-phrase", "blocked"], + "the announced phrases should be kept" + ); + + // A query carrying one is refused here rather than spent on the server, + // and the match is case-insensitive on substrings, as the server's is. + match client.search("A BANNED-PHRASE query", Duration::from_millis(100)) { + Err(soulseek_rs::SoulseekRs::SearchPhraseExcluded(phrase)) => { + assert_eq!(phrase, "banned-phrase"); + } + other => panic!("expected a refusal, got {other:?}"), + } + // A clean query is not refused. + assert!( + client + .search("something else", Duration::from_millis(100)) + .is_ok() + ); + + drop(client); + let _ = stub.join(); +} + +#[test] +fn a_queued_download_can_ask_where_it_sits() { + // We queue a file with a peer that never starts it, then ask where it + // sits (peer code 51). The peer's answer (code 44) must land on the + // download as its queue position. + let server = server_or_skip!(); + + let listen_port = free_port().expect("free listen port"); + let mut client = Client::with_settings(server.listening_settings( + "e2e_place_asker", + "pw", + listen_port, + )); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + let filename = "waiting_in_line.mp3"; + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let (asked_tx, asked_rx) = std::sync::mpsc::channel(); + let listen_addr = format!("127.0.0.1:{listen_port}"); + let peer = std::thread::spawn(move || -> std::io::Result<()> { + let mut p = connect_retry(&listen_addr, Duration::from_secs(5))?; + p.set_read_timeout(Some(Duration::from_secs(15)))?; + p.write_all(&peer_init_bytes("e2e_place_peer", "P", 0))?; + p.flush()?; + let _ = ready_tx.send(()); + + // Sit on the queue request, then answer the place request with 7. + loop { + let mut msg = read_framed(&mut p)?; + // A QueueUpload (43) is accepted but never started: the file + // stays in line, which is the state being asked about. + if msg.get_message_code() == 51 { + msg.set_pointer(8); + let asked = msg.read_string(); + let mut reply = Message::new(); + reply.write_int32(44).write_string(&asked).write_int32(7); + p.write_all(&reply.get_buffer())?; + p.flush()?; + let _ = asked_tx.send(asked); + break; + } + } + std::thread::sleep(Duration::from_secs(1)); + Ok(()) + }); + ready_rx + .recv_timeout(Duration::from_secs(5)) + .expect("mock peer P connection"); + std::thread::sleep(Duration::from_millis(1000)); + + let download_dir = unique_download_dir(); + let (_download, _status_rx) = client + .download( + filename.to_string(), + "e2e_place_peer".to_string(), + 10, + download_dir.display().to_string(), + ) + .expect("start download"); + + // Ask, retrying until the control connection carries it. + let deadline = Instant::now() + Duration::from_secs(10); + let mut asked = None; + while Instant::now() < deadline && asked.is_none() { + client + .request_place_in_queue("e2e_place_peer", filename) + .expect("ask for our place"); + asked = asked_rx.recv_timeout(Duration::from_millis(500)).ok(); + } + assert_eq!( + asked.as_deref(), + Some(filename), + "the peer should be asked about the file we queued" + ); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut place = None; + while Instant::now() < deadline && place.is_none() { + place = client + .get_all_downloads() + .into_iter() + .find(|d| d.filename == filename) + .and_then(|d| d.queue_position); + if place.is_none() { + std::thread::sleep(Duration::from_millis(100)); + } + } + assert_eq!( + place, + Some(7), + "the peer's answer should land on the download" + ); + + let _ = peer.join(); + let _ = std::fs::remove_dir_all(download_dir); +} From 19791d1699a38b4b1d9b7da74719d62a117443ad Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 16:33:59 +0000 Subject: [PATCH 25/55] feat(rooms): private room membership, operators and refusals Private rooms were unreachable: the client could ask to join one but had no way to invite, promote, resign or be told what happened, and a room name already owned by someone else came back as silence rather than a refusal (code 1003). Adds the rosters the server pushes after login (133/148), the changes it narrates to them (134/135, 143/144), our own standing (139/140, 145/146), and the messages that drive them, with the rosters kept current in client state. The daemon protocol and the TUI room transcript carry the new events rather than dropping them. --- soulseek-rs-lib/src/actor/server_actor.rs | 86 +++++ soulseek-rs-lib/src/client/mod.rs | 91 ++++- soulseek-rs-lib/src/client/social.rs | 131 ++++++++ soulseek-rs-lib/src/client/tests.rs | 83 +++++ .../src/message/server/message_factory.rs | 118 +++++++ soulseek-rs-lib/src/message/server/mod.rs | 5 + .../src/message/server/private_rooms.rs | 317 ++++++++++++++++++ soulseek-rs-lib/src/types.rs | 22 ++ soulseek-rs-lib/tests/e2e.rs | 125 +++++++ soulseek-rs/docs/openrpc.json | 228 +++++++++++++ soulseek-rs/src/daemon/proto/dto.rs | 186 ++++++++++ soulseek-rs/src/models/rooms.rs | 75 +++++ 12 files changed, 1466 insertions(+), 1 deletion(-) create mode 100644 soulseek-rs-lib/src/message/server/private_rooms.rs diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index 16bd473..bd43d56 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -26,6 +26,10 @@ use crate::message::server::UserJoinedRoomHandler; use crate::message::server::UserLeftRoomHandler; use crate::message::server::WatchUserHandler; use crate::message::server::WishListIntervalHandler; +use crate::message::server::{ + CantCreateRoomHandler, OwnRoomStandingHandler, RoomMembersHandler, + RoomOperatorsHandler, RoomRosterChangeHandler, +}; use crate::message::server::{ GetUserStatsHandler, GetUserStatusHandler, RoomListHandler, }; @@ -229,6 +233,36 @@ pub enum ServerMessage { }, /// Phrases the server refuses to search for (code 160). ExcludedSearchPhrases(Vec), + /// Who may enter a private room (code 133). + PrivateRoomMembers { + room: String, + users: Vec, + }, + /// Who runs a private room (code 148). + PrivateRoomOperators { + room: String, + users: Vec, + }, + /// One user joined or left a private room's member or operator roster + /// (codes 134/135 and 143/144). + PrivateRoomRosterChanged { + room: String, + username: String, + /// True for the member roster, false for the operator roster. + members: bool, + added: bool, + }, + /// Our own membership (139/140) or operatorship (145/146) of a private + /// room was granted or revoked. + OwnRoomStandingChanged { + room: String, + members: bool, + granted: bool, + }, + /// The room we asked to join could not be created (code 1003). + CantCreateRoom { + room: String, + }, } pub struct ServerActor { @@ -386,6 +420,19 @@ impl ServerActor { handlers.register_handler(GetPeerAddressHandler); handlers.register_handler(ConnectToPeerHandler); handlers.register_handler(CantConnectToPeerHandler); + handlers.register_handler(RoomMembersHandler); + handlers.register_handler(RoomOperatorsHandler); + handlers.register_handler(RoomRosterChangeHandler::member_added()); + handlers.register_handler(RoomRosterChangeHandler::member_removed()); + handlers.register_handler(RoomRosterChangeHandler::operator_added()); + handlers.register_handler(RoomRosterChangeHandler::operator_removed()); + handlers.register_handler(OwnRoomStandingHandler::membership_granted()); + handlers.register_handler(OwnRoomStandingHandler::membership_revoked()); + handlers + .register_handler(OwnRoomStandingHandler::operatorship_granted()); + handlers + .register_handler(OwnRoomStandingHandler::operatorship_revoked()); + handlers.register_handler(CantCreateRoomHandler); handlers.register_handler(RoomTickersHandler); handlers.register_handler(RoomTickerAddedHandler); handlers.register_handler(RoomTickerRemovedHandler); @@ -694,6 +741,45 @@ impl ServerActor { token, }); } + ServerMessage::PrivateRoomMembers { room, users } => { + self.forward_room_event(RoomEvent::PrivateMembers { + room, + users, + }); + } + ServerMessage::PrivateRoomOperators { room, users } => { + self.forward_room_event(RoomEvent::PrivateOperators { + room, + users, + }); + } + ServerMessage::PrivateRoomRosterChanged { + room, + username, + members, + added, + } => { + self.forward_room_event(RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + }); + } + ServerMessage::OwnRoomStandingChanged { + room, + members, + granted, + } => { + self.forward_room_event(RoomEvent::OwnStandingChanged { + room, + members, + granted, + }); + } + ServerMessage::CantCreateRoom { room } => { + self.forward_room_event(RoomEvent::CantCreate { room }); + } ServerMessage::ExcludedSearchPhrases(phrases) => { self.forward_to_client(ClientOperation::ExcludedSearchPhrases( phrases, diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 61ad87a..a6e54c0 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -440,6 +440,11 @@ pub struct ClientContext { /// Per-member statistics for each joined room, from the stat vectors the /// server sends alongside the membership list. room_member_stats: HashMap>, + /// Who may enter each private room we belong to (code 133), kept current + /// from the later roster changes. + private_room_members: HashMap>, + /// Who runs each of those rooms (code 148). + private_room_operators: HashMap>, /// The ticker board of each joined room, kept current from the board sent /// on join (code 113) and the later add/remove events (114/115). room_tickers: HashMap>, @@ -490,6 +495,15 @@ pub struct ClientContext { upload_events: Vec, actor_system: Arc, } +/// A roster sorted and de-duplicated, so membership lookups can binary-search +/// it and a repeated name cannot appear twice. +fn sorted_unique(users: &[String]) -> Vec { + let mut users = users.to_vec(); + users.sort(); + users.dedup(); + users +} + impl Default for ClientContext { fn default() -> Self { Self::new() @@ -569,6 +583,8 @@ impl ClientContext { room_members: HashMap::new(), room_member_stats: HashMap::new(), room_tickers: HashMap::new(), + private_room_members: HashMap::new(), + private_room_operators: HashMap::new(), recommendations: None, global_recommendations: None, item_recommendations: HashMap::new(), @@ -641,11 +657,84 @@ impl ClientContext { board.retain(|t| &t.username != username); } } - RoomEvent::Message { .. } | RoomEvent::GlobalMessage { .. } => {} + RoomEvent::PrivateMembers { room, users } => { + self.private_room_members + .insert(room.clone(), sorted_unique(users)); + } + RoomEvent::PrivateOperators { room, users } => { + self.private_room_operators + .insert(room.clone(), sorted_unique(users)); + } + RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + } => { + let roster = if *members { + self.private_room_members.entry(room.clone()).or_default() + } else { + self.private_room_operators.entry(room.clone()).or_default() + }; + match (added, roster.binary_search(username)) { + (true, Err(at)) => roster.insert(at, username.clone()), + (false, Ok(at)) => { + roster.remove(at); + } + _ => {} + } + } + RoomEvent::OwnStandingChanged { + room, + members, + granted, + } => { + // Our own standing is rendered from the same rosters, so a + // revocation must drop the room rather than leave a roster + // we are no longer part of looking current. + if !granted { + // Losing membership takes the operator roster with it: + // a room we cannot enter has no roster worth showing. + if *members { + self.private_room_members.remove(room); + } + self.private_room_operators.remove(room); + } + } + RoomEvent::Message { .. } + | RoomEvent::GlobalMessage { .. } + | RoomEvent::CantCreate { .. } => {} } self.room_events.push(event); } + /// Who may enter the private room `room`, as last reported. + #[must_use] + pub fn private_room_members(&self, room: &str) -> Vec { + self.private_room_members + .get(room) + .cloned() + .unwrap_or_default() + } + + /// Who runs the private room `room`, as last reported. + #[must_use] + pub fn private_room_operators(&self, room: &str) -> Vec { + self.private_room_operators + .get(room) + .cloned() + .unwrap_or_default() + } + + /// The private rooms we belong to, in a stable order. + #[must_use] + pub fn private_rooms(&self) -> Vec { + let mut rooms: Vec = + self.private_room_members.keys().cloned().collect(); + rooms.sort(); + rooms + } + /// The ticker board of `room` as last reported by the server. #[must_use] pub fn room_tickers(&self, room: &str) -> Vec { diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs index 488deb8..ef0dda6 100644 --- a/soulseek-rs-lib/src/client/social.rs +++ b/soulseek-rs-lib/src/client/social.rs @@ -21,6 +21,137 @@ impl Client { self.send_server_message(MessageFactory::build_server_ping()) } + /// Join a private room, creating it (and owning it) if the name is free. + /// + /// A name already taken by someone else's private room comes back as a + /// [`crate::RoomEvent::CantCreate`] rather than a join. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn join_private_room(&self, room: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_join_room(room, true)) + } + + /// Invite `username` into the private room we own (code 134). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn add_room_member(&self, room: &str, username: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_add_room_member( + room, username, + )) + } + + /// Remove `username` from the private room we own (code 135). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn remove_room_member(&self, room: &str, username: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_remove_room_member( + room, username, + )) + } + + /// Give up our own membership of a private room (code 136). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn leave_private_room(&self, room: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_cancel_room_membership( + room, + )) + } + + /// Give up ownership of a private room, which deletes it (code 137). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn disband_private_room(&self, room: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_cancel_room_ownership( + room, + )) + } + + /// Say whether we accept private-room invitations (code 141). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn set_room_invitations_enabled(&self, enabled: bool) -> Result<()> { + self.send_server_message(MessageFactory::build_enable_room_invitations( + enabled, + )) + } + + /// Make `username` an operator of our private room (code 143). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn add_room_operator(&self, room: &str, username: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_add_room_operator( + room, username, + )) + } + + /// Take operator status away from `username` (code 144). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn remove_room_operator( + &self, + room: &str, + username: &str, + ) -> Result<()> { + self.send_server_message(MessageFactory::build_remove_room_operator( + room, username, + )) + } + + /// Give up our own operator status in `room` (code 147). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn resign_room_operatorship(&self, room: &str) -> Result<()> { + self.send_server_message( + MessageFactory::build_cancel_room_operatorship(room), + ) + } + + /// Who may enter the private room `room`, as the server last reported. + #[must_use] + pub fn private_room_members(&self, room: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.private_room_members(room), + Err(e) => { + error!("[client] private_room_members: {}", e); + Vec::new() + } + } + } + + /// Who runs the private room `room`. + #[must_use] + pub fn private_room_operators(&self, room: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.private_room_operators(room), + Err(e) => { + error!("[client] private_room_operators: {}", e); + Vec::new() + } + } + } + + /// The private rooms we belong to. + #[must_use] + pub fn private_rooms(&self) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.private_rooms(), + Err(e) => { + error!("[client] private_rooms: {}", e); + Vec::new() + } + } + } + /// Set our online status (server code 28): away, or back to online. /// /// Other clients see it in their `GetUserStatus` and watch replies, and diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs index b1b8285..eefd4ff 100644 --- a/soulseek-rs-lib/src/client/tests.rs +++ b/soulseek-rs-lib/src/client/tests.rs @@ -951,3 +951,86 @@ fn asking_again_about_a_users_interests_drops_the_previous_answer() { "a stale answer must not be mistaken for the next one" ); } + +#[test] +fn a_private_room_roster_tracks_who_is_added_and_removed() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["bob".into(), "alice".into(), "bob".into()], + }); + assert_eq!( + ctx.private_room_members("club"), + vec!["alice".to_string(), "bob".to_string()], + "a roster is sorted and free of repeats" + ); + + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "carol".into(), + members: true, + added: true, + }); + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "alice".into(), + members: true, + added: false, + }); + assert_eq!( + ctx.private_room_members("club"), + vec!["bob".to_string(), "carol".to_string()] + ); + + // Operators are a separate roster in the same room. + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "bob".into(), + members: false, + added: true, + }); + assert_eq!(ctx.private_room_operators("club"), vec!["bob".to_string()]); + assert_eq!(ctx.private_rooms(), vec!["club".to_string()]); +} + +#[test] +fn revoked_membership_drops_the_room_we_can_no_longer_see() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["alice".into()], + }); + ctx.apply_room_event(RoomEvent::PrivateOperators { + room: "club".into(), + users: vec!["alice".into()], + }); + + ctx.apply_room_event(RoomEvent::OwnStandingChanged { + room: "club".into(), + members: true, + granted: false, + }); + assert!(ctx.private_rooms().is_empty()); + assert!(ctx.private_room_operators("club").is_empty()); +} + +#[test] +fn losing_operatorship_keeps_the_membership_it_does_not_touch() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["alice".into()], + }); + ctx.apply_room_event(RoomEvent::PrivateOperators { + room: "club".into(), + users: vec!["alice".into()], + }); + + ctx.apply_room_event(RoomEvent::OwnStandingChanged { + room: "club".into(), + members: false, + granted: false, + }); + assert_eq!(ctx.private_room_members("club"), vec!["alice".to_string()]); + assert!(ctx.private_room_operators("club").is_empty()); +} diff --git a/soulseek-rs-lib/src/message/server/message_factory.rs b/soulseek-rs-lib/src/message/server/message_factory.rs index cc3feb7..4ea1ebe 100644 --- a/soulseek-rs-lib/src/message/server/message_factory.rs +++ b/soulseek-rs-lib/src/message/server/message_factory.rs @@ -310,6 +310,73 @@ impl MessageFactory { .clone() } + /// Invite `username` into the private room we own (server code 134). + #[must_use] + pub fn build_add_room_member(room: &str, username: &str) -> Message { + Message::new() + .write_int32(134) + .write_string(room) + .write_string(username) + .clone() + } + + /// Remove `username` from the private room we own (server code 135). + #[must_use] + pub fn build_remove_room_member(room: &str, username: &str) -> Message { + Message::new() + .write_int32(135) + .write_string(room) + .write_string(username) + .clone() + } + + /// Give up our own membership of a private room (server code 136). + #[must_use] + pub fn build_cancel_room_membership(room: &str) -> Message { + Message::new().write_int32(136).write_string(room).clone() + } + + /// Give up ownership of a private room, which deletes it (code 137). + #[must_use] + pub fn build_cancel_room_ownership(room: &str) -> Message { + Message::new().write_int32(137).write_string(room).clone() + } + + /// Say whether we accept private-room invitations (server code 141). + #[must_use] + pub fn build_enable_room_invitations(enabled: bool) -> Message { + Message::new() + .write_int32(141) + .write_int8(u8::from(enabled)) + .clone() + } + + /// Make `username` an operator of our private room (server code 143). + #[must_use] + pub fn build_add_room_operator(room: &str, username: &str) -> Message { + Message::new() + .write_int32(143) + .write_string(room) + .write_string(username) + .clone() + } + + /// Take operator status away from `username` (server code 144). + #[must_use] + pub fn build_remove_room_operator(room: &str, username: &str) -> Message { + Message::new() + .write_int32(144) + .write_string(room) + .write_string(username) + .clone() + } + + /// Give up our own operator status in a room (server code 147). + #[must_use] + pub fn build_cancel_room_operatorship(room: &str) -> Message { + Message::new().write_int32(147).write_string(room).clone() + } + /// A keepalive ping (server code 32, no body). The server sends no reply; /// its only job is to keep a quiet connection from being reaped by a NAT /// or the server's own idle timeout, the way other clients ping. @@ -830,3 +897,54 @@ fn a_multi_user_message_counts_its_recipients_first() { assert_eq!(decoded.read_string(), "bob"); assert_eq!(decoded.read_string(), "hi all"); } + +#[test] +fn the_private_room_roster_messages_carry_room_then_user() { + for (message, code) in [ + (MessageFactory::build_add_room_member("club", "bob"), 134), + (MessageFactory::build_remove_room_member("club", "bob"), 135), + (MessageFactory::build_add_room_operator("club", "bob"), 143), + ( + MessageFactory::build_remove_room_operator("club", "bob"), + 144, + ), + ] { + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), code); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "club"); + assert_eq!(decoded.read_string(), "bob"); + } +} + +#[test] +fn giving_up_a_room_standing_names_only_the_room() { + for (message, code) in [ + (MessageFactory::build_cancel_room_membership("club"), 136), + (MessageFactory::build_cancel_room_ownership("club"), 137), + (MessageFactory::build_cancel_room_operatorship("club"), 147), + ] { + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), code); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "club"); + } +} + +#[test] +fn room_invitations_are_toggled_by_a_single_byte() { + let message = MessageFactory::build_enable_room_invitations(true); + assert_eq!(message.get_data(), [141, 0, 0, 0, 1]); + let message = MessageFactory::build_enable_room_invitations(false); + assert_eq!(message.get_data(), [141, 0, 0, 0, 0]); +} + +#[test] +fn a_private_join_sets_the_private_flag() { + let message = MessageFactory::build_join_room("club", true); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 14); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "club"); + assert_eq!(decoded.read_int32(), 1); +} diff --git a/soulseek-rs-lib/src/message/server/mod.rs b/soulseek-rs-lib/src/message/server/mod.rs index 76eb9e4..41a4f77 100644 --- a/soulseek-rs-lib/src/message/server/mod.rs +++ b/soulseek-rs-lib/src/message/server/mod.rs @@ -14,6 +14,7 @@ mod message_factory; mod message_user; mod parent_min_speed; mod parent_speed_ratio; +mod private_rooms; mod privileged_users; mod relogged; mod room_list; @@ -47,6 +48,10 @@ pub use message_factory::MessageFactory; pub use message_user::MessageUser; pub use parent_min_speed::ParentMinSpeedHandler; pub use parent_speed_ratio::ParentSpeedRatioHandler; +pub use private_rooms::{ + CantCreateRoomHandler, OwnRoomStandingHandler, RoomMembersHandler, + RoomOperatorsHandler, RoomRosterChangeHandler, +}; pub use privileged_users::{CheckPrivilegesHandler, PrivilegedUsersHandler}; pub use relogged::ReloggedHandler; pub use room_list::RoomListHandler; diff --git a/soulseek-rs-lib/src/message/server/private_rooms.rs b/soulseek-rs-lib/src/message/server/private_rooms.rs new file mode 100644 index 0000000..18d8aa6 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/private_rooms.rs @@ -0,0 +1,317 @@ +//! Private rooms: who may enter one and who runs it. +//! +//! The server pushes the membership and operator rosters of every private +//! room we belong to right after login (codes 133 and 148), then narrates +//! changes to them: a member added or removed (134/135), our own membership +//! or operatorship granted or revoked (139/140, 145/146), and a room we asked +//! for that could not be created (1003). + +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; + +/// Read a `[room][count][username]...` roster, stopping at the payload's end. +fn read_roster(message: &mut Message) -> (String, Vec) { + let room = message.read_string(); + let count = message.read_int32(); + let mut users = Vec::new(); + for _ in 0..count { + let username = message.read_string(); + if username.is_empty() { + break; + } + users.push(username); + } + (room, users) +} + +/// `RoomMembers` (code 133): who may enter a private room. +pub struct RoomMembersHandler; + +impl MessageHandler for RoomMembersHandler { + fn get_code(&self) -> u32 { + 133 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let (room, users) = read_roster(message); + let _ = sender.send(ServerMessage::PrivateRoomMembers { room, users }); + } +} + +/// `RoomOperators` (code 148): who runs a private room. +pub struct RoomOperatorsHandler; + +impl MessageHandler for RoomOperatorsHandler { + fn get_code(&self) -> u32 { + 148 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let (room, users) = read_roster(message); + let _ = + sender.send(ServerMessage::PrivateRoomOperators { room, users }); + } +} + +/// One user added to (`added`) or removed from a private room's roster: +/// members (134/135) or operators (143/144). +pub struct RoomRosterChangeHandler { + code: u32, + members: bool, + added: bool, +} + +impl RoomRosterChangeHandler { + #[must_use] + pub const fn member_added() -> Self { + Self { + code: 134, + members: true, + added: true, + } + } + + #[must_use] + pub const fn member_removed() -> Self { + Self { + code: 135, + members: true, + added: false, + } + } + + #[must_use] + pub const fn operator_added() -> Self { + Self { + code: 143, + members: false, + added: true, + } + } + + #[must_use] + pub const fn operator_removed() -> Self { + Self { + code: 144, + members: false, + added: false, + } + } +} + +impl MessageHandler for RoomRosterChangeHandler { + fn get_code(&self) -> u32 { + self.code + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let _ = sender.send(ServerMessage::PrivateRoomRosterChanged { + room, + username, + members: self.members, + added: self.added, + }); + } +} + +/// Our own standing in a private room changed: membership granted (139) or +/// revoked (140), operatorship granted (145) or revoked (146). +pub struct OwnRoomStandingHandler { + code: u32, + members: bool, + granted: bool, +} + +impl OwnRoomStandingHandler { + #[must_use] + pub const fn membership_granted() -> Self { + Self { + code: 139, + members: true, + granted: true, + } + } + + #[must_use] + pub const fn membership_revoked() -> Self { + Self { + code: 140, + members: true, + granted: false, + } + } + + #[must_use] + pub const fn operatorship_granted() -> Self { + Self { + code: 145, + members: false, + granted: true, + } + } + + #[must_use] + pub const fn operatorship_revoked() -> Self { + Self { + code: 146, + members: false, + granted: false, + } + } +} + +impl MessageHandler for OwnRoomStandingHandler { + fn get_code(&self) -> u32 { + self.code + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let _ = sender.send(ServerMessage::OwnRoomStandingChanged { + room, + members: self.members, + granted: self.granted, + }); + } +} + +/// `CantCreateRoom` (code 1003): the room we asked to join could not be +/// created — the name is taken by a private room we are not a member of, or +/// it is otherwise refused. +pub struct CantCreateRoomHandler; + +impl MessageHandler for CantCreateRoomHandler { + fn get_code(&self) -> u32 { + 1003 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let _ = sender.send(ServerMessage::CantCreateRoom { room }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn a_member_roster_lists_the_rooms_members() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("club"); + m.write_int32(2); + m.write_string("alice"); + m.write_string("bob"); + }); + + RoomMembersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::PrivateRoomMembers { room, users }) => { + assert_eq!(room, "club"); + assert_eq!(users, ["alice", "bob"]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_roster_count_beyond_the_payload_stops_at_the_data() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("club"); + m.write_int32(u32::MAX); + m.write_string("alice"); + }); + + RoomOperatorsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::PrivateRoomOperators { users, .. }) => { + assert_eq!(users, ["alice"]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn each_roster_change_reports_which_roster_and_which_way() { + for (handler, members, added) in [ + (RoomRosterChangeHandler::member_added(), true, true), + (RoomRosterChangeHandler::member_removed(), true, false), + (RoomRosterChangeHandler::operator_added(), false, true), + (RoomRosterChangeHandler::operator_removed(), false, false), + ] { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("club"); + m.write_string("alice"); + }); + + handler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::PrivateRoomRosterChanged { + room, + username, + members: got_members, + added: got_added, + }) => { + assert_eq!(room, "club"); + assert_eq!(username, "alice"); + assert_eq!((got_members, got_added), (members, added)); + } + other => panic!("unexpected: {other:?}"), + } + } + } + + #[test] + fn our_own_standing_reports_the_room_and_the_change() { + for (handler, members, granted) in [ + (OwnRoomStandingHandler::membership_granted(), true, true), + (OwnRoomStandingHandler::membership_revoked(), true, false), + (OwnRoomStandingHandler::operatorship_granted(), false, true), + (OwnRoomStandingHandler::operatorship_revoked(), false, false), + ] { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("club"); + }); + + handler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::OwnRoomStandingChanged { + room, + members: got_members, + granted: got_granted, + }) => { + assert_eq!(room, "club"); + assert_eq!((got_members, got_granted), (members, granted)); + } + other => panic!("unexpected: {other:?}"), + } + } + } + + #[test] + fn a_refused_room_names_itself() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("taken"); + }); + + CantCreateRoomHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::CantCreateRoom { room }) => { + assert_eq!(room, "taken"); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/types.rs b/soulseek-rs-lib/src/types.rs index c057acf..8fa98e1 100644 --- a/soulseek-rs-lib/src/types.rs +++ b/soulseek-rs-lib/src/types.rs @@ -414,6 +414,28 @@ pub enum RoomEvent { username: String, message: String, }, + /// Who may enter a private room (code 133). + PrivateMembers { room: String, users: Vec }, + /// Who runs a private room (code 148). + PrivateOperators { room: String, users: Vec }, + /// One user joined or left a private room's member roster (`members` + /// true, codes 134/135) or its operator roster (143/144). + PrivateRosterChanged { + room: String, + username: String, + members: bool, + added: bool, + }, + /// Our own membership (`members` true, codes 139/140) or operatorship + /// (145/146) of a private room was granted or revoked. + OwnStandingChanged { + room: String, + members: bool, + granted: bool, + }, + /// The room we asked to join could not be created (code 1003) — the name + /// belongs to a private room we are not a member of. + CantCreate { room: String }, } /// One user's ticker (the scrolling one-line message) in a room. diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 120291e..062be52 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -4583,3 +4583,128 @@ fn a_queued_download_can_ask_where_it_sits() { let _ = peer.join(); let _ = std::fs::remove_dir_all(download_dir); } + +#[test] +fn a_private_room_is_owned_granted_and_revoked() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_private_club"; + let mut owner = + Client::with_settings(server.settings("e2e_priv_owner", "pw")); + let mut guest = + Client::with_settings(server.settings("e2e_priv_guest", "pw")); + owner.connect().expect("owner connect"); + guest.connect().expect("guest connect"); + assert!(owner.login().expect("owner login")); + assert!(guest.login().expect("guest login")); + + guest + .set_room_invitations_enabled(true) + .expect("guest accepts invitations"); + owner + .join_private_room(room) + .expect("owner creates the room"); + std::thread::sleep(Duration::from_millis(750)); + + // The guest is invited, and hears about it (code 139). + owner + .add_room_member(room, "e2e_priv_guest") + .expect("owner invites the guest"); + let granted = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: true, granted: true + } if r == room) + .then_some(()) + }); + assert!( + granted.is_some(), + "the guest should be told they are a member" + ); + + // The owner's own roster now carries the guest (code 134). + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && !owner + .private_room_members(room) + .iter() + .any(|u| u == "e2e_priv_guest") + { + std::thread::sleep(Duration::from_millis(100)); + } + assert!( + owner + .private_room_members(room) + .iter() + .any(|u| u == "e2e_priv_guest"), + "the member roster should carry the guest, got {:?}", + owner.private_room_members(room) + ); + + // A member can be made an operator (code 145 for them, 143 for the room). + owner + .add_room_operator(room, "e2e_priv_guest") + .expect("owner promotes the guest"); + let promoted = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: false, granted: true + } if r == room) + .then_some(()) + }); + assert!( + promoted.is_some(), + "the guest should be told they run the room" + ); + + // And membership can be taken away again (code 140). + owner + .remove_room_member(room, "e2e_priv_guest") + .expect("owner removes the guest"); + let revoked = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: true, granted: false + } if r == room) + .then_some(()) + }); + assert!(revoked.is_some(), "the guest should be told they are out"); +} + +#[test] +fn a_private_room_someone_else_owns_cannot_be_taken() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_private_taken"; + let mut owner = + Client::with_settings(server.settings("e2e_taken_owner", "pw")); + owner.connect().expect("owner connect"); + assert!(owner.login().expect("owner login")); + owner + .join_private_room(room) + .expect("owner creates the room"); + std::thread::sleep(Duration::from_millis(750)); + + // An outsider asking for the same name must be refused (code 1003), not + // handed the room. + let mut outsider = + Client::with_settings(server.settings("e2e_taken_outsider", "pw")); + outsider.connect().expect("outsider connect"); + assert!(outsider.login().expect("outsider login")); + outsider + .join_private_room(room) + .expect("outsider asks for the same room"); + + let refused = + await_room_event(&outsider, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::CantCreate { room: r } if r == room) + .then_some(()) + }); + assert!( + refused.is_some(), + "a private room owned by someone else must be refused" + ); + assert!( + outsider.private_rooms().is_empty(), + "a refused room must not appear as one we belong to" + ); +} diff --git a/soulseek-rs/docs/openrpc.json b/soulseek-rs/docs/openrpc.json index d9ab8e0..0ccad0c 100644 --- a/soulseek-rs/docs/openrpc.json +++ b/soulseek-rs/docs/openrpc.json @@ -729,6 +729,218 @@ "username" ], "type": "object" + }, + { + "description": "The ticker board of a room, sent on join.", + "properties": { + "event": { + "const": "tickers", + "type": "string" + }, + "room": { + "type": "string" + }, + "tickers": { + "items": { + "$ref": "#/components/schemas/RoomTickerDto" + }, + "type": "array" + } + }, + "required": [ + "event", + "room", + "tickers" + ], + "type": "object" + }, + { + "properties": { + "event": { + "const": "ticker_added", + "type": "string" + }, + "room": { + "type": "string" + }, + "ticker": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username", + "ticker" + ], + "type": "object" + }, + { + "properties": { + "event": { + "const": "ticker_removed", + "type": "string" + }, + "room": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username" + ], + "type": "object" + }, + { + "description": "A message from the global room feed; `room` names where it was said.", + "properties": { + "event": { + "const": "global_message", + "type": "string" + }, + "message": { + "type": "string" + }, + "room": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username", + "message" + ], + "type": "object" + }, + { + "description": "Who may enter a private room.", + "properties": { + "event": { + "const": "private_members", + "type": "string" + }, + "room": { + "type": "string" + }, + "users": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "event", + "room", + "users" + ], + "type": "object" + }, + { + "description": "Who runs a private room.", + "properties": { + "event": { + "const": "private_operators", + "type": "string" + }, + "room": { + "type": "string" + }, + "users": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "event", + "room", + "users" + ], + "type": "object" + }, + { + "description": "A private room's member (`members` true) or operator roster changed.", + "properties": { + "added": { + "type": "boolean" + }, + "event": { + "const": "private_roster_changed", + "type": "string" + }, + "members": { + "type": "boolean" + }, + "room": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username", + "members", + "added" + ], + "type": "object" + }, + { + "description": "Our own membership or operatorship of a private room changed.", + "properties": { + "event": { + "const": "own_standing_changed", + "type": "string" + }, + "granted": { + "type": "boolean" + }, + "members": { + "type": "boolean" + }, + "room": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "members", + "granted" + ], + "type": "object" + }, + { + "description": "The room we asked to join could not be created.", + "properties": { + "event": { + "const": "cant_create", + "type": "string" + }, + "room": { + "type": "string" + } + }, + "required": [ + "event", + "room" + ], + "type": "object" } ] }, @@ -760,6 +972,22 @@ ], "type": "object" }, + "RoomTickerDto": { + "description": "One user's ticker in a room.", + "properties": { + "ticker": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "ticker" + ], + "type": "object" + }, "RoomUserStatsDto": { "description": "One member of a room, as the JoinRoom reply described them.", "properties": { diff --git a/soulseek-rs/src/daemon/proto/dto.rs b/soulseek-rs/src/daemon/proto/dto.rs index 82ea25a..32fd958 100644 --- a/soulseek-rs/src/daemon/proto/dto.rs +++ b/soulseek-rs/src/daemon/proto/dto.rs @@ -373,6 +373,79 @@ pub enum RoomEventDto { room: String, username: String, }, + /// The ticker board of a room, sent on join. + Tickers { + room: String, + tickers: Vec, + }, + TickerAdded { + room: String, + username: String, + ticker: String, + }, + TickerRemoved { + room: String, + username: String, + }, + /// A message from the global room feed; `room` names where it was said. + GlobalMessage { + room: String, + username: String, + message: String, + }, + /// Who may enter a private room. + PrivateMembers { + room: String, + users: Vec, + }, + /// Who runs a private room. + PrivateOperators { + room: String, + users: Vec, + }, + /// A private room's member (`members` true) or operator roster changed. + PrivateRosterChanged { + room: String, + username: String, + members: bool, + added: bool, + }, + /// Our own membership or operatorship of a private room changed. + OwnStandingChanged { + room: String, + members: bool, + granted: bool, + }, + /// The room we asked to join could not be created. + CantCreate { + room: String, + }, +} + +/// One user's ticker in a room. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct RoomTickerDto { + pub username: String, + pub ticker: String, +} + +impl From<&soulseek_rs::RoomTicker> for RoomTickerDto { + fn from(ticker: &soulseek_rs::RoomTicker) -> Self { + Self { + username: ticker.username.clone(), + ticker: ticker.ticker.clone(), + } + } +} + +impl From for soulseek_rs::RoomTicker { + fn from(dto: RoomTickerDto) -> Self { + Self { + username: dto.username, + ticker: dto.ticker, + } + } } impl From<&RoomEvent> for RoomEventDto { @@ -403,6 +476,67 @@ impl From<&RoomEvent> for RoomEventDto { room: room.clone(), username: username.clone(), }, + RoomEvent::Tickers { room, tickers } => Self::Tickers { + room: room.clone(), + tickers: tickers.iter().map(RoomTickerDto::from).collect(), + }, + RoomEvent::TickerAdded { + room, + username, + ticker, + } => Self::TickerAdded { + room: room.clone(), + username: username.clone(), + ticker: ticker.clone(), + }, + RoomEvent::TickerRemoved { room, username } => { + Self::TickerRemoved { + room: room.clone(), + username: username.clone(), + } + } + RoomEvent::GlobalMessage { + room, + username, + message, + } => Self::GlobalMessage { + room: room.clone(), + username: username.clone(), + message: message.clone(), + }, + RoomEvent::PrivateMembers { room, users } => Self::PrivateMembers { + room: room.clone(), + users: users.clone(), + }, + RoomEvent::PrivateOperators { room, users } => { + Self::PrivateOperators { + room: room.clone(), + users: users.clone(), + } + } + RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + } => Self::PrivateRosterChanged { + room: room.clone(), + username: username.clone(), + members: *members, + added: *added, + }, + RoomEvent::OwnStandingChanged { + room, + members, + granted, + } => Self::OwnStandingChanged { + room: room.clone(), + members: *members, + granted: *granted, + }, + RoomEvent::CantCreate { room } => { + Self::CantCreate { room: room.clone() } + } } } } @@ -432,6 +566,58 @@ impl From for RoomEvent { RoomEventDto::UserLeft { room, username } => { Self::UserLeft { room, username } } + RoomEventDto::Tickers { room, tickers } => Self::Tickers { + room, + tickers: tickers.into_iter().map(Into::into).collect(), + }, + RoomEventDto::TickerAdded { + room, + username, + ticker, + } => Self::TickerAdded { + room, + username, + ticker, + }, + RoomEventDto::TickerRemoved { room, username } => { + Self::TickerRemoved { room, username } + } + RoomEventDto::GlobalMessage { + room, + username, + message, + } => Self::GlobalMessage { + room, + username, + message, + }, + RoomEventDto::PrivateMembers { room, users } => { + Self::PrivateMembers { room, users } + } + RoomEventDto::PrivateOperators { room, users } => { + Self::PrivateOperators { room, users } + } + RoomEventDto::PrivateRosterChanged { + room, + username, + members, + added, + } => Self::PrivateRosterChanged { + room, + username, + members, + added, + }, + RoomEventDto::OwnStandingChanged { + room, + members, + granted, + } => Self::OwnStandingChanged { + room, + members, + granted, + }, + RoomEventDto::CantCreate { room } => Self::CantCreate { room }, } } } diff --git a/soulseek-rs/src/models/rooms.rs b/soulseek-rs/src/models/rooms.rs index 5250cdd..8897f39 100644 --- a/soulseek-rs/src/models/rooms.rs +++ b/soulseek-rs/src/models/rooms.rs @@ -336,6 +336,81 @@ impl RoomsState { .push(RoomLine::system(format!("← {username}"))); } } + // Tickers and private-room standing are narrated into the room's + // own transcript: they belong to a room the user has open, and a + // system line is where the rest of that room's news already goes. + RoomEvent::Tickers { room, tickers } => { + if let Some(idx) = self.open_index(&room) { + for ticker in tickers { + self.open[idx].lines.push(RoomLine::system(format!( + "≡ {}: {}", + ticker.username, ticker.ticker + ))); + } + } + } + RoomEvent::TickerAdded { + room, + username, + ticker, + } => { + if let Some(idx) = self.open_index(&room) { + self.open[idx].lines.push(RoomLine::system(format!( + "≡ {username}: {ticker}" + ))); + } + } + RoomEvent::TickerRemoved { room, username } => { + if let Some(idx) = self.open_index(&room) { + self.open[idx].lines.push(RoomLine::system(format!( + "≡ {username} cleared their ticker" + ))); + } + } + RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + } => { + if let Some(idx) = self.open_index(&room) { + let roster = if members { "member" } else { "operator" }; + let verb = if added { "is now" } else { "is no longer" }; + self.open[idx].lines.push(RoomLine::system(format!( + "{username} {verb} a {roster}" + ))); + } + } + RoomEvent::OwnStandingChanged { + room, + members, + granted, + } => { + if let Some(idx) = self.open_index(&room) { + let what = if members { + "membership" + } else { + "operator status" + }; + let verb = if granted { "granted" } else { "revoked" }; + self.open[idx].lines.push(RoomLine::system(format!( + "— your {what} was {verb} —" + ))); + } + } + RoomEvent::CantCreate { room } => { + if let Some(idx) = self.open_index(&room) { + self.open[idx].lines.push(RoomLine::system( + "— this room cannot be created —".to_string(), + )); + } + } + // The global feed carries rooms the user has not opened, and the + // private rosters are read from the client rather than rendered + // into a transcript. + RoomEvent::GlobalMessage { .. } + | RoomEvent::PrivateMembers { .. } + | RoomEvent::PrivateOperators { .. } => {} } // The active room's member list may have grown/shrunk (join/leave or a // wholesale replace on Joined); keep the selection highlight in range so From f5a97744f88ef784bed2c0743b15504ccc7e9a8a Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 16:39:10 +0000 Subject: [PATCH 26/55] fix(messages): acknowledge stored private messages, and disconnect cleanly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs an e2e test found the moment one existed for this path. A private message the server held while we were offline arrives with new_message false, and the handler only acknowledged the ones flagged new — so exactly the messages that need an ack never got one and the server re-delivered them at every login, forever. Every delivery is now acknowledged. A dropped Client also left its server connection open, so the session lived on: mail addressed to us went to a socket nobody was reading and a later login collided with our own ghost. Client::disconnect closes the server and peer connections, and Drop calls it. Also covers two features that were never driven end to end: an offline message delivered once and not again, and a wishlist search answered like any other. --- soulseek-rs-lib/src/actor/peer_registry.rs | 18 ++++ soulseek-rs-lib/src/client/connection.rs | 24 +++++ soulseek-rs-lib/src/client/mod.rs | 6 ++ soulseek-rs-lib/src/client/tests.rs | 6 +- .../src/message/server/message_user.rs | 71 +++++++++++-- soulseek-rs-lib/tests/e2e.rs | 100 ++++++++++++++++++ 6 files changed, 215 insertions(+), 10 deletions(-) diff --git a/soulseek-rs-lib/src/actor/peer_registry.rs b/soulseek-rs-lib/src/actor/peer_registry.rs index 02d1ce0..2c7c6f9 100644 --- a/soulseek-rs-lib/src/actor/peer_registry.rs +++ b/soulseek-rs-lib/src/actor/peer_registry.rs @@ -202,6 +202,24 @@ impl PeerRegistry { None } + /// Stop every peer actor and empty the registry, closing the connections + /// they hold. Used when the client shuts down. + pub fn stop_all(&self) { + let handles = match self.peers.lock_safe() { + Ok(mut peers) => peers + .drain() + .map(|(_, (_, handle))| handle) + .collect::>(), + Err(e) => { + error!("[peer_registry] stop_all: {}", e); + return; + } + }; + for handle in handles { + let _ = handle.stop(); + } + } + #[must_use] pub fn contains(&self, username: &str) -> bool { match self.peers.lock_safe() { diff --git a/soulseek-rs-lib/src/client/connection.rs b/soulseek-rs-lib/src/client/connection.rs index 3d4f36f..a34e66c 100644 --- a/soulseek-rs-lib/src/client/connection.rs +++ b/soulseek-rs-lib/src/client/connection.rs @@ -133,6 +133,30 @@ impl Client { Ok(()) } + /// Close the server connection and every peer connection. + /// + /// The server sees the socket close and marks us offline, which is what + /// releases the username: a client that simply goes out of scope without + /// this leaves a session the server still believes in, so mail addressed + /// to us is delivered to a connection nobody is reading and later logins + /// collide with our own ghost. + /// + /// Called automatically when the client is dropped. The peer listener's + /// bound port, if one was opened, is released with the process. + pub fn disconnect(&mut self) { + if let Some(handle) = self.server_handle.take() { + let _ = handle.stop(); + } + let registry = self + .context + .read_safe() + .ok() + .and_then(|ctx| ctx.peer_registry.clone()); + if let Some(registry) = registry { + registry.stop_all(); + } + } + /// Log in and wait for the server's verdict. /// /// The wait is bounded: the actor only answers once it has a working diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index a6e54c0..b108b2b 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -1163,6 +1163,12 @@ pub struct Client { session: SessionWatch, } +impl Drop for Client { + fn drop(&mut self) { + self.disconnect(); + } +} + impl Client { pub fn new( username: impl Into, diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs index eefd4ff..d85b8f5 100644 --- a/soulseek-rs-lib/src/client/tests.rs +++ b/soulseek-rs-lib/src/client/tests.rs @@ -551,7 +551,7 @@ fn an_expired_broker_connect_fails_the_queued_downloads() { let (_ops_tx, ops_rx) = mpsc::channel(); Client::listen_to_client_operations( ops_rx, - client.context, + client.context.clone(), "u".to_string(), ); @@ -582,7 +582,7 @@ fn a_replayed_transfer_response_does_not_start_a_second_transfer() { let (ops_tx, ops_rx) = mpsc::channel(); Client::listen_to_client_operations( ops_rx, - client.context, + client.context.clone(), "u".to_string(), ); @@ -648,7 +648,7 @@ fn a_cancelled_download_is_not_started_when_the_peer_allows_it() { let (ops_tx, ops_rx) = mpsc::channel(); Client::listen_to_client_operations( ops_rx, - client.context, + client.context.clone(), "u".to_string(), ); let peer = Peer::new( diff --git a/soulseek-rs-lib/src/message/server/message_user.rs b/soulseek-rs-lib/src/message/server/message_user.rs index 0520197..9817e2f 100644 --- a/soulseek-rs-lib/src/message/server/message_user.rs +++ b/soulseek-rs-lib/src/message/server/message_user.rs @@ -28,16 +28,73 @@ impl MessageHandler for MessageUser { info!("[MessageUser] User message received:{:?}", user_message); - // Acknowledge freshly delivered messages so the server does not keep - // re-sending them on every reconnect. - if new_message { - let _ = sender.send(ServerMessage::SendMessage( - MessageFactory::build_message_acked(id), - )); - } + // Acknowledge every message, not only the ones flagged new. The flag + // marks a message delivered as it was sent; one the server stored + // while we were offline arrives with it false, and those are exactly + // the messages the server keeps re-sending at every login until they + // are acknowledged. + let _ = sender.send(ServerMessage::SendMessage( + MessageFactory::build_message_acked(id), + )); // Surface the message to the client so it can be read via the API. let _ = sender.send(ServerMessage::PrivateMessageReceived(user_message)); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + fn deliver(new_message: bool) -> Vec { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(7); + m.write_int32(1_700_000_000); + m.write_string("alice"); + m.write_string("hello"); + m.write_int8(u8::from(new_message)); + }); + MessageUser.handle(&mut message, tx); + rx.try_iter().collect() + } + + fn acked_id(messages: &[ServerMessage]) -> Option { + messages.iter().find_map(|message| match message { + ServerMessage::SendMessage(m) => { + let mut decoded = Message::new_with_data(m.get_buffer()); + (decoded.get_message_code() == 23).then(|| { + decoded.set_pointer(8); + decoded.read_int32() + }) + } + _ => None, + }) + } + + #[test] + fn a_freshly_delivered_message_is_acknowledged() { + let sent = deliver(true); + assert_eq!(acked_id(&sent), Some(7)); + } + + #[test] + fn a_message_stored_while_we_were_offline_is_acknowledged_too() { + // Stored messages arrive with new_message false; without an ack the + // server re-delivers them at every login, forever. + let sent = deliver(false); + assert_eq!(acked_id(&sent), Some(7)); + } + + #[test] + fn the_message_itself_is_surfaced_to_the_client() { + let sent = deliver(false); + assert!(sent.iter().any(|message| matches!( + message, + ServerMessage::PrivateMessageReceived(m) + if m.message() == "hello" && m.username() == "alice" + ))); + } +} diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 062be52..f8f2a91 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -4708,3 +4708,103 @@ fn a_private_room_someone_else_owns_cannot_be_taken() { "a refused room must not appear as one we belong to" ); } + +#[test] +fn an_acknowledged_offline_message_is_not_delivered_twice() { + // Private messages sent to an offline user are stored by the server and + // handed over at the next login; the client acknowledges each one + // (MessageAcked, code 23) so the server drops it. Without that ack the + // same message arrives again on every login, which is what this pins. + let server = server_or_skip!(); + + // Register the recipient so the server will hold mail for them. + let mut recipient = + Client::with_settings(server.settings("e2e_ack_recipient", "pw")); + recipient.connect().expect("recipient connect"); + assert!(recipient.login().expect("recipient login")); + drop(recipient); + // The dropped session's socket must actually be gone before the mail is + // sent, or the server delivers it live to a connection nobody is reading. + std::thread::sleep(Duration::from_secs(2)); + + let mut sender = + Client::with_settings(server.settings("e2e_ack_sender", "pw")); + sender.connect().expect("sender connect"); + assert!(sender.login().expect("sender login")); + let body = "left while you were out"; + sender + .send_private_message("e2e_ack_recipient", body) + .expect("send to an offline user"); + std::thread::sleep(Duration::from_millis(500)); + + let received_once = |label: &str| -> bool { + let mut client = + Client::with_settings(server.settings("e2e_ack_recipient", "pw")); + client.connect().unwrap_or_else(|e| panic!("{label}: {e}")); + assert!(client.login().expect("recipient login"), "{label}"); + let deadline = Instant::now() + Duration::from_secs(3); + let mut got = false; + while Instant::now() < deadline && !got { + got = client + .take_private_messages() + .iter() + .any(|m| m.message() == body); + if !got { + std::thread::sleep(Duration::from_millis(100)); + } + } + std::thread::sleep(Duration::from_millis(500)); + got + }; + + assert!( + received_once("first login"), + "the stored message should arrive at the next login" + ); + assert!( + !received_once("second login"), + "an acknowledged message must not be delivered again" + ); +} + +#[test] +fn a_wishlist_search_is_answered_like_any_other() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + let content: Vec = (0..1024u32).map(|i| (i % 251) as u8).collect(); + std::fs::write(share_dir.join("e2e_wishlist_talisman.bin"), &content) + .unwrap(); + + let (_sharer, searcher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_wish_sharer", + "e2e_wish_seeker", + ); + + // A wish returns at once; its results accumulate under the query. + let query = "talisman"; + searcher + .start_wishlist_search(query) + .expect("start a wishlist search"); + + let reply = reply_from(&searcher, query, "e2e_wish_sharer") + .expect("a wishlist search should be answered like a plain one"); + assert!( + reply + .files + .iter() + .any(|f| f.name.contains("e2e_wishlist_talisman")), + "the reply should carry the matching file, got {:?}", + reply.files + ); + + // The server also announces how often it will accept one. + assert!( + searcher.wishlist_interval() > Duration::ZERO, + "the wishlist interval should be a real wait" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} From 460031b532e4b2b322c0a3a9d8806e7f3443a620 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 16:47:55 +0000 Subject: [PATCH 27/55] feat(distributed): serve children in the search tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client read the distributed network but never carried it: a peer dialling with a D connection was logged and dropped, and AcceptChildren was hardcoded false, so this client took from the tree without ever relaying a search for anyone else. With accept_children set, peers hang from us: each is told where our branch sits when it connects and again when the branch moves, every search that reaches us is passed down, and a child that goes away loses its slot. Ten at a time, matching Nicotine+, since each costs a socket and a copy of the network's whole search stream — which is also why it is opt-in, through the new accept_children config key (ignored without a listener, since a child would have nothing to dial). --- soulseek-rs-lib/examples/stress.rs | 1 + soulseek-rs-lib/src/client/children.rs | 278 +++++++++++++++++++++ soulseek-rs-lib/src/client/distributed.rs | 23 +- soulseek-rs-lib/src/client/mod.rs | 36 ++- soulseek-rs-lib/src/client/operations.rs | 76 ++++-- soulseek-rs-lib/src/message/distributed.rs | 9 +- soulseek-rs-lib/src/peer/listen.rs | 10 +- soulseek-rs-lib/tests/e2e.rs | 176 +++++++++++++ soulseek-rs-lib/tests/resume_e2e.rs | 1 + soulseek-rs/src/persist/config.rs | 71 +++++- soulseek-rs/src/run.rs | 3 + soulseek-rs/tests/common/mod.rs | 1 + 12 files changed, 648 insertions(+), 37 deletions(-) create mode 100644 soulseek-rs-lib/src/client/children.rs diff --git a/soulseek-rs-lib/examples/stress.rs b/soulseek-rs-lib/examples/stress.rs index 6952ce1..81c6a8f 100644 --- a/soulseek-rs-lib/examples/stress.rs +++ b/soulseek-rs-lib/examples/stress.rs @@ -1026,6 +1026,7 @@ fn main() { enable_listen: true, listen_port: client_port, shared_directories: vec![share_dir.display().to_string()], + accept_children: false, version: ClientVersion::default(), }); client.connect().expect("client connect"); diff --git a/soulseek-rs-lib/src/client/children.rs b/soulseek-rs-lib/src/client/children.rs new file mode 100644 index 0000000..e22cfa1 --- /dev/null +++ b/soulseek-rs-lib/src/client/children.rs @@ -0,0 +1,278 @@ +//! Serving children: the other half of the distributed search network. +//! +//! A leaf only reads the tree; a parent also carries it. When the client is +//! configured to accept children, peers dial us with a `D` connection, we tell +//! each one where our branch sits, and every search that reaches us is passed +//! down to all of them. That is the whole duty — a child answers searches +//! itself, and reports its own place to the server. +//! +//! Accepting children is opt-in ([`crate::ClientSettings::accept_children`]): +//! it costs a socket and the network's whole search stream per child, which is +//! not something a client should take on without being asked to. + +use std::collections::HashMap; +use std::io::Write; +use std::net::TcpStream; + +use crate::message::Message; +use crate::message::distributed; +use crate::{debug, trace}; + +/// How many children to carry at once. Nicotine+ offers ten; the cost is one +/// socket and one copy of every search each, so the cap is what keeps a +/// parent's outbound traffic bounded. +pub const MAX_CHILDREN: usize = 10; + +/// The children hanging from us, keyed by username. +pub struct Children { + links: HashMap, + max: usize, + accepting: bool, +} + +impl Default for Children { + fn default() -> Self { + Self::new(false) + } +} + +impl Children { + #[must_use] + pub fn new(accepting: bool) -> Self { + Self { + links: HashMap::new(), + max: MAX_CHILDREN, + accepting, + } + } + + /// Whether we take children at all, and still have room for one. + #[must_use] + pub fn has_room(&self) -> bool { + self.accepting && self.links.len() < self.max + } + + /// Whether the client accepts children at all, regardless of how full it + /// is — what the server is told in `AcceptChildren` (code 100). + #[must_use] + pub const fn accepting(&self) -> bool { + self.accepting + } + + #[must_use] + pub fn len(&self) -> usize { + self.links.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.links.is_empty() + } + + #[must_use] + pub fn usernames(&self) -> Vec { + let mut names: Vec = self.links.keys().cloned().collect(); + names.sort(); + names + } + + /// Take on `username` as a child. Refused when we are not accepting or + /// are full; a second connection from a child we already carry replaces + /// the first, since the peer only reconnects when it thinks the old one + /// is gone. + pub fn accept(&mut self, username: &str, stream: TcpStream) -> bool { + if !self.accepting { + return false; + } + if self.links.len() >= self.max && !self.links.contains_key(username) { + debug!( + "[distributed] refusing child {}: already carrying {}", + username, + self.links.len() + ); + return false; + } + self.links.insert(username.to_string(), stream); + true + } + + pub fn remove(&mut self, username: &str) { + if self.links.remove(username).is_some() { + debug!("[distributed] child {} is gone", username); + } + } + + /// Send one frame to `username`, dropping the child if the write fails. + pub fn send_to(&mut self, username: &str, message: &Message) { + let failed = match self.links.get_mut(username) { + Some(stream) => stream.write_all(&message.get_buffer()).is_err(), + None => return, + }; + if failed { + self.remove(username); + } + } + + /// Send one frame to every child, dropping those whose write fails: a + /// child that has gone away must not keep its slot. + pub fn broadcast(&mut self, message: &Message) { + let bytes = message.get_buffer(); + let mut dead = Vec::new(); + for (username, stream) in &mut self.links { + if stream.write_all(&bytes).is_err() { + dead.push(username.clone()); + } + } + for username in dead { + self.remove(&username); + } + } + + /// Tell `username` where our branch sits — what a child needs before it + /// can report its own place to the server. + pub fn send_stance_to(&mut self, username: &str, root: &str, level: u32) { + trace!( + "[distributed] telling {} we sit at {}/{}", + username, root, level + ); + self.send_to(username, &distributed::build_branch_root(root)); + self.send_to( + username, + &distributed::build_branch_level( + i32::try_from(level).unwrap_or(i32::MAX), + ), + ); + } + + /// Tell every child our branch moved. + pub fn broadcast_stance(&mut self, root: &str, level: u32) { + self.broadcast(&distributed::build_branch_root(root)); + self.broadcast(&distributed::build_branch_level( + i32::try_from(level).unwrap_or(i32::MAX), + )); + } + + /// Pass a search down the tree. + pub fn broadcast_search( + &mut self, + username: &str, + token: u32, + query: &str, + ) { + if self.links.is_empty() { + return; + } + self.broadcast(&distributed::build_search(username, token, query)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + use std::net::{TcpListener, TcpStream}; + + /// A connected socket pair: the child's end and ours. + fn pair() -> (TcpStream, TcpStream) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + let ours = TcpStream::connect(addr).expect("connect"); + let (theirs, _) = listener.accept().expect("accept"); + (theirs, ours) + } + + fn read_frames(stream: &mut TcpStream, bytes: usize) -> Vec { + stream + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .expect("timeout"); + let mut buf = vec![0u8; bytes]; + stream.read_exact(&mut buf).expect("read"); + buf + } + + #[test] + fn a_client_that_does_not_accept_children_takes_none() { + let (_child, ours) = pair(); + let mut children = Children::new(false); + assert!(!children.accept("alice", ours)); + assert!(children.is_empty()); + assert!(!children.has_room()); + } + + #[test] + fn children_are_taken_up_to_the_cap() { + let mut children = Children::new(true); + // The child ends are kept alive so the links stay up; the parent + // drops a child whose socket closes. + let mut ends = Vec::new(); + for i in 0..MAX_CHILDREN { + let (child, ours) = pair(); + ends.push(child); + assert!(children.accept(&format!("user{i}"), ours)); + } + assert_eq!(ends.len(), MAX_CHILDREN, "every link is still held"); + assert!(!children.has_room(), "a full parent has no room"); + + let (_late, ours) = pair(); + assert!(!children.accept("one_too_many", ours)); + assert_eq!(children.len(), MAX_CHILDREN); + } + + #[test] + fn a_reconnecting_child_replaces_its_own_link() { + let mut children = Children::new(true); + let (_first, ours) = pair(); + assert!(children.accept("alice", ours)); + let (_second, again) = pair(); + assert!(children.accept("alice", again)); + assert_eq!(children.usernames(), vec!["alice".to_string()]); + } + + #[test] + fn a_search_reaches_every_child() { + let mut children = Children::new(true); + let (mut first, ours) = pair(); + let (mut second, theirs) = pair(); + children.accept("alice", ours); + children.accept("bob", theirs); + + children.broadcast_search("seeker", 42, "jazz"); + + let expected = + distributed::build_search("seeker", 42, "jazz").get_buffer(); + assert_eq!(read_frames(&mut first, expected.len()), expected); + assert_eq!(read_frames(&mut second, expected.len()), expected); + } + + #[test] + fn a_child_that_hung_up_loses_its_slot() { + let mut children = Children::new(true); + let (child, ours) = pair(); + children.accept("alice", ours); + drop(child); + + // The first write after the peer is gone may still be buffered; the + // second is the one that fails. Either way the child must not keep + // its slot forever. + for _ in 0..50 { + children.broadcast_search("seeker", 1, "q"); + if children.is_empty() { + break; + } + } + assert!(children.is_empty(), "a dead child is dropped"); + } + + #[test] + fn a_child_is_told_where_our_branch_sits() { + let mut children = Children::new(true); + let (mut child, ours) = pair(); + children.accept("alice", ours); + + children.send_stance_to("alice", "rooty", 3); + + let mut expected = distributed::build_branch_root("rooty").get_buffer(); + expected.extend(distributed::build_branch_level(3).get_buffer()); + assert_eq!(read_frames(&mut child, expected.len()), expected); + } +} diff --git a/soulseek-rs-lib/src/client/distributed.rs b/soulseek-rs-lib/src/client/distributed.rs index 013ecf9..cfc89de 100644 --- a/soulseek-rs-lib/src/client/distributed.rs +++ b/soulseek-rs-lib/src/client/distributed.rs @@ -297,15 +297,34 @@ pub fn announce( server: Option<&Sender>, branch: &Branch, has_parent: bool, + accept_children: bool, ) { let Some(server) = server else { return; }; - for message in distributed::stance(&branch.root, branch.level, has_parent) { + for message in distributed::stance( + &branch.root, + branch.level, + has_parent, + accept_children, + ) { let _ = server.send(ServerMessage::SendMessage(message)); } } +/// Tell the server where we sit, and tell our children too: a branch that +/// moved leaves every child reporting a stale place until they hear it. +pub fn announce_move( + ctx: &mut super::ClientContext, + branch: &Branch, + has_parent: bool, +) { + let server = ctx.server_sender.clone(); + let accepting = ctx.children.accepting(); + announce(server.as_ref(), branch, has_parent, accepting); + ctx.children.broadcast_stance(&branch.root, branch.level); +} + /// Dial a parent candidate on its own thread: a `D` PeerInit, then whatever it /// sends comes back as operations until the link closes or is cancelled. pub fn spawn_link(dial: Dial, own: String, ops: Sender) { @@ -668,7 +687,7 @@ mod tests { fn announcing_tells_the_server_the_root_the_level_and_that_we_have_a_parent() { let (tx, rx) = std::sync::mpsc::channel(); - announce(Some(&tx), &Branch::under("rooty", 4), true); + announce(Some(&tx), &Branch::under("rooty", 4), true, false); let mut sent: Vec = Vec::new(); while let Ok(ServerMessage::SendMessage(m)) = rx.try_recv() { sent.push(m); diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index b108b2b..abaf670 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -191,6 +191,11 @@ pub struct ClientSettings { /// Directories whose files are shared with (uploaded to) other peers. /// Empty means nothing is shared. pub shared_directories: Vec, + /// Whether to serve children in the distributed search network: peers + /// hang from us with a `D` connection and we pass every search we receive + /// down to them. Off by default — it costs a socket and the network's + /// whole search stream per child. + pub accept_children: bool, /// The version reported to the server on login. Defaults to the /// soulseek-rs major version with minor version 1 ("unidentified"); /// clients built on this library should reserve their own minor @@ -223,6 +228,7 @@ impl Default for ClientSettings { enable_listen: true, listen_port: DEFAULT_LISTEN_PORT, shared_directories: Vec::new(), + accept_children: false, version: ClientVersion::default(), } } @@ -343,6 +349,12 @@ pub enum ClientOperation { PossibleParents(Vec<(String, String, u16)>), /// The server told us to drop our parent. ResetDistributed, + /// A peer dialled us with a `D` connection, asking to hang from us in the + /// distributed search network. + ChildConnected { + username: String, + stream: std::net::TcpStream, + }, /// Recommendations from the server: either from our own interests /// (`global` false, code 54) or server-wide (`global` true, code 56). Recommendations { @@ -405,6 +417,8 @@ pub struct ClientContext { operations: Option>, /// Our place in the distributed search network. leaf: distributed::Leaf, + /// The children hanging from us, when the client serves any. + pub(crate) children: children::Children, searches: HashMap, private_messages: Vec, /// Correlation tokens for server-brokered (firewalled) connections, mapping @@ -565,6 +579,7 @@ impl ClientContext { server_sender: None, operations: None, leaf: distributed::Leaf::new(""), + children: children::Children::default(), searches: HashMap::new(), private_messages: Vec::new(), pending_connect_tokens: HashMap::new(), @@ -1180,14 +1195,14 @@ impl Client { #[must_use] pub fn with_settings(settings: ClientSettings) -> Self { logger::init(); + let mut context = ClientContext::for_user(&settings.username); + context.children = children::Children::new(settings.accept_children); Self { enable_listen: settings.enable_listen, listen_port: settings.listen_port, bound_port: None, address: settings.server_address, - context: Arc::new(RwLock::new(ClientContext::for_user( - &settings.username, - ))), + context: Arc::new(RwLock::new(context)), username: settings.username, password: settings.password, version: settings.version, @@ -1197,6 +1212,20 @@ impl Client { } } + /// The children currently hanging from us in the distributed search + /// network, by username. Empty unless the client was built with + /// [`ClientSettings::accept_children`]. + #[must_use] + pub fn children(&self) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.children.usernames(), + Err(e) => { + error!("[client] children: {}", e); + Vec::new() + } + } + } + /// The username we log in as, for attributing our own messages. #[must_use] pub fn username(&self) -> &str { @@ -1334,6 +1363,7 @@ impl Client { } } +mod children; mod connection; mod distributed; mod downloads; diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index b2dee36..094a72d 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -29,10 +29,8 @@ impl Client { if let Some(branch) = ctx.leaf.due_announcement(Instant::now()) { - super::distributed::announce( - ctx.server_sender.as_ref(), - &branch, - true, + super::distributed::announce_move( + &mut ctx, &branch, true, ); } } @@ -539,6 +537,16 @@ impl Client { token, query, } => { + // Pass it down the tree first: carrying the search + // stream is a parent's whole duty, and it is owed + // even for a search we ourselves cannot answer. + if let Ok(mut ctx) = client_context.write_safe() + && !ctx.children.is_empty() + { + ctx.children + .broadcast_search(&username, token, &query); + } + // Don't answer our own distributed search. if username == own_username { continue; @@ -679,10 +687,8 @@ impl Client { if let Some(branch) = ctx.leaf.branch_level(&parent, link, level) { - super::distributed::announce( - ctx.server_sender.as_ref(), - &branch, - true, + super::distributed::announce_move( + &mut ctx, &branch, true, ); } } @@ -697,10 +703,8 @@ impl Client { if let Some(branch) = ctx.leaf.branch_root(&parent, link, &root) { - super::distributed::announce( - ctx.server_sender.as_ref(), - &branch, - true, + super::distributed::announce_move( + &mut ctx, &branch, true, ); } } @@ -717,10 +721,8 @@ impl Client { if let Some(branch) = ctx.leaf.search_from(&parent, link) { - super::distributed::announce( - ctx.server_sender.as_ref(), - &branch, - true, + super::distributed::announce_move( + &mut ctx, &branch, true, ); } if ctx.leaf.is_parent(&parent, link) @@ -739,10 +741,9 @@ impl Client { continue; }; if ctx.leaf.closed(&parent, link) { - super::distributed::announce( - ctx.server_sender.as_ref(), - &ctx.leaf.branch(), - false, + let branch = ctx.leaf.branch(); + super::distributed::announce_move( + &mut ctx, &branch, false, ); } } @@ -753,10 +754,9 @@ impl Client { continue; }; ctx.leaf.reset(); - super::distributed::announce( - ctx.server_sender.as_ref(), - &ctx.leaf.branch(), - false, + let branch = ctx.leaf.branch(); + super::distributed::announce_move( + &mut ctx, &branch, false, ); } ClientOperation::PrivilegedUsers(users) => { @@ -816,6 +816,34 @@ impl Client { ctx.apply_user_interests(interests); } } + ClientOperation::ChildConnected { username, stream } => { + // A peer wants to hang from us. Take it on if this + // client serves children and has room, then tell it + // where our branch sits so it can report its own + // place; otherwise the socket is dropped here and the + // peer looks for another parent. + let Ok(mut ctx) = client_context.write_safe() else { + continue; + }; + if !ctx.children.has_room() { + continue; + } + if stream.set_nodelay(true).is_err() { + continue; + } + if ctx.children.accept(&username, stream) { + let branch = ctx.leaf.branch(); + ctx.children.send_stance_to( + &username, + &branch.root, + branch.level, + ); + debug!( + "[distributed] carrying {} children", + ctx.children.len() + ); + } + } ClientOperation::ExcludedSearchPhrases(phrases) => { if let Ok(mut ctx) = client_context.write_safe() { ctx.set_excluded_search_phrases(phrases); diff --git a/soulseek-rs-lib/src/message/distributed.rs b/soulseek-rs-lib/src/message/distributed.rs index 2ae015c..9917ce6 100644 --- a/soulseek-rs-lib/src/message/distributed.rs +++ b/soulseek-rs-lib/src/message/distributed.rs @@ -108,12 +108,17 @@ pub fn build_branch_level(level: i32) -> Message { /// What we tell the server about our place in the tree: the four messages /// Nicotine+ sends together whenever it changes. #[must_use] -pub fn stance(root: &str, level: u32, has_parent: bool) -> Vec { +pub fn stance( + root: &str, + level: u32, + has_parent: bool, + accept_children: bool, +) -> Vec { vec![ MessageFactory::build_have_no_parent(!has_parent), MessageFactory::build_branch_root(root), MessageFactory::build_branch_level(level), - MessageFactory::build_accept_children(false), + MessageFactory::build_accept_children(accept_children), ] } diff --git a/soulseek-rs-lib/src/peer/listen.rs b/soulseek-rs-lib/src/peer/listen.rs index eff0b7b..2726a8a 100644 --- a/soulseek-rs-lib/src/peer/listen.rs +++ b/soulseek-rs-lib/src/peer/listen.rs @@ -378,9 +378,17 @@ fn handle_incoming_connection( deadline, ), ConnectionType::D => { + // A child asking to hang from us. The client decides whether it + // serves children at all; here we only hand the socket over. debug!( - "[listener:{peer_ip}:{peer_port}] connection type is D, not supported yet, closing connection. " + "[listener:{peer_ip}:{peer_port}] distributed child {} offered", + init_data.username ); + let _ = + context.client_sender.send(ClientOperation::ChildConnected { + username: init_data.username, + stream, + }); } } } diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index f8f2a91..64e08b0 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -118,6 +118,7 @@ impl TestServer { enable_listen: false, listen_port: 0, shared_directories: Vec::new(), + accept_children: false, version: ClientVersion::default(), } } @@ -4450,6 +4451,7 @@ fn an_excluded_phrase_is_refused_before_a_search_is_spent() { enable_listen: false, listen_port: 0, shared_directories: Vec::new(), + accept_children: false, version: ClientVersion::default(), }); client.connect().expect("connect to stub"); @@ -4808,3 +4810,177 @@ fn a_wishlist_search_is_answered_like_any_other() { let _ = std::fs::remove_dir_all(share_dir); } + +// --------------------------------------------------------------------------- +// The other half of the tree: serving children. +// +// A client that accepts children takes `D` connections from peers, tells each +// where our branch sits, and passes every search it receives down to them. +// --------------------------------------------------------------------------- + +/// Read distributed frames from a child link until `pick` matches or time runs +/// out. +fn read_distributed_until( + stream: &mut TcpStream, + timeout: Duration, + mut pick: impl FnMut(&distributed::Distributed) -> Option, +) -> Option { + stream + .set_read_timeout(Some(Duration::from_millis(500))) + .ok()?; + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + // read_framed already returns the frame with its length prefix, + // which is exactly what the parser expects. + let Ok(mut frame) = read_framed(stream) else { + continue; + }; + if let Some(parsed) = distributed::parse(&mut frame) + && let Some(found) = pick(&parsed) + { + return Some(found); + } + } + None +} + +/// Dial `port` as a distributed child called `username`. +fn dial_as_child(port: u16, username: &str) -> std::io::Result { + let mut child = + connect_retry(&format!("127.0.0.1:{port}"), Duration::from_secs(5))?; + child.write_all(&peer_init_bytes(username, "D", 0))?; + child.flush()?; + Ok(child) +} + +#[test] +fn a_parent_tells_a_child_where_it_sits_and_passes_searches_down() { + let server = server_or_skip!(); + + let listen_port = free_port().expect("free listen port"); + let mut parent = Client::with_settings(ClientSettings { + accept_children: true, + ..server.listening_settings("e2e_par_parent", "pw", listen_port) + }); + parent.connect().expect("parent connect"); + assert!(parent.login().expect("parent login")); + + let mut child = + dial_as_child(listen_port, "e2e_par_child").expect("child dials"); + + // The child is told our branch before anything else: without it, it has + // nothing to report to the server as its own place. + let root = read_distributed_until( + &mut child, + Duration::from_secs(10), + |f| match f { + distributed::Distributed::BranchRoot(root) => Some(root.clone()), + _ => None, + }, + ) + .expect("a child should be told our branch root"); + assert_eq!( + root, "e2e_par_parent", + "a parentless client is its own root" + ); + let level = read_distributed_until( + &mut child, + Duration::from_secs(10), + |f| match f { + distributed::Distributed::BranchLevel(level) => Some(*level), + _ => None, + }, + ) + .expect("a child should be told our branch level"); + assert_eq!(level, 0); + + let deadline = Instant::now() + Duration::from_secs(5); + while parent.children().is_empty() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(100)); + } + assert_eq!(parent.children(), vec!["e2e_par_child".to_string()]); + + // A search the server hands us must reach the child, whether or not we + // can answer it ourselves — carrying the stream is the parent's duty. + let addr = format!("{}:{}", server.host, server.port); + let mut searcher = + login_raw(&addr, "e2e_par_searcher", "pw").expect("searcher login"); + let token = 515_151_u32; + searcher + .write_all( + &MessageFactory::build_file_search_message( + token, + "e2e_child_relay_probe", + ) + .get_buffer(), + ) + .expect("send a search"); + searcher.flush().expect("flush the search"); + + let relayed = read_distributed_until( + &mut child, + Duration::from_secs(20), + |f| match f { + distributed::Distributed::Search { + username, + token: got, + query, + } if *got == token => Some((username.clone(), query.clone())), + _ => None, + }, + ) + .expect("the search should be passed down to the child"); + assert_eq!( + relayed, + ( + "e2e_par_searcher".to_string(), + "e2e_child_relay_probe".to_string() + ) + ); + + // A child that hangs up loses its place. + drop(child); + let deadline = Instant::now() + Duration::from_secs(20); + while !parent.children().is_empty() && Instant::now() < deadline { + searcher + .write_all( + &MessageFactory::build_file_search_message( + token + 1, + "e2e_child_relay_probe", + ) + .get_buffer(), + ) + .expect("send another search"); + std::thread::sleep(Duration::from_millis(500)); + } + assert!( + parent.children().is_empty(), + "a child that went away should not keep its slot" + ); +} + +#[test] +fn a_client_that_does_not_serve_children_turns_one_away() { + let server = server_or_skip!(); + + // The default: a leaf. A peer that dials with a D connection is dropped, + // and never told a branch it could hang from. + let listen_port = free_port().expect("free listen port"); + let mut leaf = Client::with_settings(server.listening_settings( + "e2e_leafonly", + "pw", + listen_port, + )); + leaf.connect().expect("leaf connect"); + assert!(leaf.login().expect("leaf login")); + + let mut child = + dial_as_child(listen_port, "e2e_leafonly_child").expect("child dials"); + + let told = + read_distributed_until(&mut child, Duration::from_secs(3), |f| { + matches!(f, distributed::Distributed::BranchRoot(_)).then_some(()) + }); + assert!(told.is_none(), "a leaf must not take on a child"); + assert!(leaf.children().is_empty()); +} diff --git a/soulseek-rs-lib/tests/resume_e2e.rs b/soulseek-rs-lib/tests/resume_e2e.rs index 2f5cf55..7271c25 100644 --- a/soulseek-rs-lib/tests/resume_e2e.rs +++ b/soulseek-rs-lib/tests/resume_e2e.rs @@ -67,6 +67,7 @@ impl Subject { enable_listen: true, listen_port: 0, shared_directories: Vec::new(), + accept_children: false, version: ClientVersion::default(), }); client.connect().expect("connect subject"); diff --git a/soulseek-rs/src/persist/config.rs b/soulseek-rs/src/persist/config.rs index 3781647..6dd6f8c 100644 --- a/soulseek-rs/src/persist/config.rs +++ b/soulseek-rs/src/persist/config.rs @@ -12,6 +12,11 @@ pub struct FileConfig { pub server: Option, pub listener_port: Option, pub disable_listener: Option, + /// Serve children in the distributed search network: other peers hang + /// from this client and every search it receives is passed down to them. + /// Off by default — it costs a socket and the network's whole search + /// stream per child, and it needs the listener. + pub accept_children: Option, pub download_dir: Option, /// Single shared folder (also what `--shared-dir` sets). Prefer /// `shared_dirs` for multiple; both may be combined. @@ -46,6 +51,7 @@ impl FileConfig { "server", "listener_port", "disable_listener", + "accept_children", "download_dir", "shared_dirs", "max_concurrent_downloads", @@ -64,6 +70,7 @@ impl FileConfig { "server" => self.server.clone(), "listener_port" => self.listener_port.map(|v| v.to_string()), "disable_listener" => self.disable_listener.map(|v| v.to_string()), + "accept_children" => self.accept_children.map(|v| v.to_string()), "download_dir" => self.download_dir.clone(), "shared_dirs" => { let dirs = self.shared_dirs.clone().unwrap_or_default(); @@ -127,6 +134,22 @@ impl FileConfig { }) }; } + "accept_children" => { + self.accept_children = if clear { + None + } else { + Some(match value { + "true" | "1" | "yes" => true, + "false" | "0" | "no" => false, + other => { + return Err(format!( + "accept_children wants true or false, got \ + '{other}'" + )); + } + }) + }; + } "shared_dirs" => { self.shared_dirs = (!clear).then(|| { value @@ -251,6 +274,7 @@ pub struct Resolved { pub server: String, pub listener_port: u16, pub disable_listener: bool, + pub accept_children: bool, pub download_dir: String, pub shared_dirs: Vec, pub max_concurrent_downloads: usize, @@ -283,6 +307,11 @@ pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved { .clone() .or_else(|| file.download_dir.clone()) .unwrap_or_else(super::paths::default_download_dir); + let disable_listener = if cli.listener { + false + } else { + cli.no_listener || file.disable_listener.unwrap_or(false) + }; Resolved { username: cli.username.clone().or_else(|| file.username.clone()), server: cli @@ -294,11 +323,11 @@ pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved { .listener_port .or(file.listener_port) .unwrap_or(DEFAULT_LISTENER_PORT), - disable_listener: if cli.listener { - false - } else { - cli.no_listener || file.disable_listener.unwrap_or(false) - }, + disable_listener, + // A client with no listener has nothing for a child to dial, so the + // setting only takes effect alongside one. + accept_children: file.accept_children.unwrap_or(false) + && !disable_listener, download_dir: download_dir.clone(), shared_dirs: resolve_shared_dirs(cli, file, &download_dir), max_concurrent_downloads: cli @@ -496,6 +525,37 @@ mod tests { assert!(!resolve(&cli, &FileConfig::default()).disable_listener); } + #[test] + fn accepting_children_needs_a_listener() { + // Serving children means peers dial us; with the listener off there + // is nothing to dial, so the setting must not survive resolution. + let file = FileConfig { + accept_children: Some(true), + ..FileConfig::default() + }; + assert!(resolve(&bare_cli(), &file).accept_children); + + let off = FileConfig { + disable_listener: Some(true), + ..file.clone() + }; + assert!(!resolve(&bare_cli(), &off).accept_children); + + let mut cli = bare_cli(); + cli.no_listener = true; + assert!(!resolve(&cli, &file).accept_children); + } + + #[test] + fn accept_children_round_trips_through_get_and_set() { + let mut file = FileConfig::default(); + file.set("accept_children", "true").expect("set"); + assert_eq!(file.get("accept_children").as_deref(), Some("true")); + assert!(file.set("accept_children", "maybe").is_err()); + file.set("accept_children", "").expect("clear"); + assert_eq!(file.get("accept_children"), None); + } + #[test] fn file_values_override_defaults() { let file = FileConfig { @@ -503,6 +563,7 @@ mod tests { server: Some("localhost:2242".into()), listener_port: Some(4321), disable_listener: Some(true), + accept_children: None, download_dir: Some("/music".into()), shared_dir: Some("/shared".into()), shared_dirs: None, diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs index af56d40..11c2c20 100644 --- a/soulseek-rs/src/run.rs +++ b/soulseek-rs/src/run.rs @@ -259,6 +259,7 @@ fn context( enable_listen: !resolved.disable_listener, listen_port: resolved.listener_port, shared_directories: shared_directories(resolved, out), + accept_children: resolved.accept_children, version: ClientVersion::REFERENCE_CLIENT, }, download_dir, @@ -432,6 +433,7 @@ fn run_default_tui( let enable_listen = !resolved.disable_listener; let listen_port = resolved.listener_port; + let accept_children = resolved.accept_children; let make_settings = move |username: String, password: String| ClientSettings { username, @@ -440,6 +442,7 @@ fn run_default_tui( enable_listen, listen_port, shared_directories: shared_directories.clone(), + accept_children, version: ClientVersion::REFERENCE_CLIENT, }; diff --git a/soulseek-rs/tests/common/mod.rs b/soulseek-rs/tests/common/mod.rs index 223131c..03ada03 100644 --- a/soulseek-rs/tests/common/mod.rs +++ b/soulseek-rs/tests/common/mod.rs @@ -137,6 +137,7 @@ pub fn login(host: &str, port: u16, user: &str, shares: Vec) -> Client { enable_listen: true, listen_port: free_port().expect("peer port"), shared_directories: shares, + accept_children: false, version: soulseek_rs::ClientVersion::default(), }); client.connect().expect("peer connect"); From bf3e3ec4ad2ba0174ac0186b9a965c604200f42c Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 16:48:58 +0000 Subject: [PATCH 28/55] docs(readme): document the accept_children setting --- README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3b7032c..e1f550f 100644 --- a/README.md +++ b/README.md @@ -533,6 +533,7 @@ file, in that order of precedence. Flags work before or after the subcommand. | `--shared-dir` (repeatable) | `SOULSEEK_SHARED_DIR` | `shared_dir` / `shared_dirs` | the download dir | | `--listener-port` | `SOULSEEK_LISTENER_PORT` | `listener_port` | `2234` | | `--no-listener` / `--listener` | `SOULSEEK_NO_LISTENER` | `disable_listener` | listener on | +| — | — | `accept_children` | off | | `--max-concurrent-downloads` | `SOULSEEK_MAX_CONCURRENT_DOWNLOADS` | `max_concurrent_downloads` | `20` | | `--search-timeout` | `SOULSEEK_SEARCH_TIMEOUT` | `search_timeout` | `10` | | `--daemon ADDR` | `SOULSEEK_DAEMON` | `daemon` | local socket if one is up | @@ -548,10 +549,17 @@ the config and state directories wholesale. The file lives at also read from a `.env` in the working directory, usually the tidiest way to hand a container its credentials. -`config get` and `config set` cover the eleven settings the file holds -(`username`, `server`, `listener_port`, `disable_listener`, `download_dir`, -`shared_dirs`, `max_concurrent_downloads`, `search_timeout`, `password_cmd`, -`daemon`, `daemon_token`), and list them back at you when you name something +`accept_children` makes this client carry part of the distributed search +network: other peers hang from it and every search it receives is passed down +to them. It is off by default — each child costs a socket and a copy of the +network's whole search stream — and it needs the listener, since a child has to +be able to dial in. + +`config get` and `config set` cover the twelve settings the file holds +(`username`, `server`, `listener_port`, `disable_listener`, `accept_children`, +`download_dir`, `shared_dirs`, `max_concurrent_downloads`, `search_timeout`, +`password_cmd`, `daemon`, `daemon_token`), and list them back at you when you +name something else. An empty string clears a key, and `shared_dirs` takes a comma-separated list. Waits expressed in seconds (`--search-timeout`, `--timeout`, `--duration`) are bounded to one day, so a mistyped flag is rejected rather From 0630b548b9635216d1623a3be8d44abd51d0df66 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:01:19 +0000 Subject: [PATCH 29/55] feat(protocol): folder requests, and heuristics matched to other clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading Nicotine+ and slskd against what was just written turned up three places where this client had the message right and the behaviour wrong. Excluded search phrases (code 160) are not a filter on the searches we send — the server already drops those. Nicotine+ applies them to the files it offers, leaving out any whose path carries an excluded phrase, and a reply with nothing left is not sent. Ours now does the same, and the local refusal of outgoing searches is gone. Serving children now follows the same rules as Nicotine+: the limit comes from the server's ParentMinSpeed and ParentSpeedRatio against the upload speed the server records for us (capped at ten, none at all below the minimum), children are refused until something feeds us the search stream and let go when it stops, a second link from a child we already carry is refused, and AcceptChildren is re-sent whenever that answer changes. Where the server has never reported our speed there is nothing to apply the formula to, so the configured cap stands, which is how slskd runs. Queued downloads now ask where they sit every five minutes, as Nicotine+ does, instead of only when a caller asks; a brokered file connection we cannot complete reports CantConnectToPeer like a control connection does; and a peer's shares can be asked for one folder at a time (peer code 36 and its reply, 37), which is what "download folder" is built on. --- soulseek-rs-lib/src/actor/peer_actor.rs | 29 ++- soulseek-rs-lib/src/actor/server_actor.rs | 13 ++ soulseek-rs-lib/src/client/children.rs | 199 +++++++++++++++--- soulseek-rs-lib/src/client/connection.rs | 38 +++- soulseek-rs-lib/src/client/distributed.rs | 6 +- soulseek-rs-lib/src/client/mod.rs | 109 ++++++++++ soulseek-rs-lib/src/client/operations.rs | 131 +++++++++++- soulseek-rs-lib/src/client/search.rs | 21 +- soulseek-rs-lib/src/client/social.rs | 36 ++++ soulseek-rs-lib/src/client/tests.rs | 54 ++++- soulseek-rs-lib/src/error.rs | 6 - .../src/message/peer/folder_contents.rs | 31 +++ soulseek-rs-lib/src/message/peer/mod.rs | 4 +- .../src/message/server/parent_min_speed.rs | 26 ++- .../src/message/server/parent_speed_ratio.rs | 25 ++- soulseek-rs-lib/tests/e2e.rs | 130 +++++++++--- 16 files changed, 751 insertions(+), 107 deletions(-) diff --git a/soulseek-rs-lib/src/actor/peer_actor.rs b/soulseek-rs-lib/src/actor/peer_actor.rs index 74b9e21..b7aefe8 100644 --- a/soulseek-rs-lib/src/actor/peer_actor.rs +++ b/soulseek-rs-lib/src/actor/peer_actor.rs @@ -2,11 +2,11 @@ use crate::actor::{Actor, ActorHandle, ConnectionState}; use crate::client::ClientOperation; use crate::dispatcher::MessageDispatcher; use crate::message::peer::{ - FileSearchResponse, FolderContentsRequest, GetShareFileList, PeerInit, - PlaceInQueueRequest, PlaceInQueueResponse, QueueUploadHandler, - SharedDirectory, SharedFileListResponseHandler, TransferRequest, - TransferResponse, UploadDeniedHandler, UploadFailedHandler, - UserInfoRequest, + FileSearchResponse, FolderContentsRequest, FolderContentsResponseHandler, + GetShareFileList, PeerInit, PlaceInQueueRequest, PlaceInQueueResponse, + QueueUploadHandler, SharedDirectory, SharedFileListResponseHandler, + TransferRequest, TransferResponse, UploadDeniedHandler, + UploadFailedHandler, UserInfoRequest, }; use crate::message::server::MessageFactory; use crate::message::{Handlers, Message, MessageReader, MessageType}; @@ -47,6 +47,12 @@ pub enum PeerMessage { ShareListRequested, /// A peer we are browsing sent us their shared-file listing (code 5). ShareListReceived(Vec), + /// A peer answered our `FolderContentsRequest` with one folder's listing. + FolderContentsReceived { + token: u32, + folder: String, + directories: Vec, + }, /// A peer asked what we say about ourselves (they sent us code 15). UserInfoRequested, /// A peer asked for one folder of our shares (they sent us code 36). @@ -199,6 +205,7 @@ impl PeerActor { handlers.register_handler(TransferResponse); handlers.register_handler(GetShareFileList); handlers.register_handler(FolderContentsRequest); + handlers.register_handler(FolderContentsResponseHandler); handlers.register_handler(UserInfoRequest); handlers.register_handler(UploadDeniedHandler); handlers.register_handler(UploadFailedHandler); @@ -310,6 +317,18 @@ impl PeerActor { directories, }); } + PeerMessage::FolderContentsReceived { + token, + folder, + directories, + } => { + self.forward(ClientOperation::FolderContents { + username: self.peer_username(), + token, + folder, + directories, + }); + } PeerMessage::UserInfoRequested => { self.forward(ClientOperation::UserInfoRequested { requester_key: self.peer_username(), diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index bd43d56..26b76dd 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -233,6 +233,11 @@ pub enum ServerMessage { }, /// Phrases the server refuses to search for (code 160). ExcludedSearchPhrases(Vec), + /// The upload speed a client needs before the server lets it carry + /// children (code 83). + ParentMinSpeed(u32), + /// The divisor turning that speed into a child count (code 84). + ParentSpeedRatio(u32), /// Who may enter a private room (code 133). PrivateRoomMembers { room: String, @@ -780,6 +785,14 @@ impl ServerActor { ServerMessage::CantCreateRoom { room } => { self.forward_room_event(RoomEvent::CantCreate { room }); } + ServerMessage::ParentMinSpeed(speed) => { + self.forward_to_client(ClientOperation::ParentMinSpeed(speed)); + } + ServerMessage::ParentSpeedRatio(ratio) => { + self.forward_to_client(ClientOperation::ParentSpeedRatio( + ratio, + )); + } ServerMessage::ExcludedSearchPhrases(phrases) => { self.forward_to_client(ClientOperation::ExcludedSearchPhrases( phrases, diff --git a/soulseek-rs-lib/src/client/children.rs b/soulseek-rs-lib/src/client/children.rs index e22cfa1..0a054d0 100644 --- a/soulseek-rs-lib/src/client/children.rs +++ b/soulseek-rs-lib/src/client/children.rs @@ -6,6 +6,14 @@ //! down to all of them. That is the whole duty — a child answers searches //! itself, and reports its own place to the server. //! +//! How many children to carry follows Nicotine+: the server announces the +//! upload speed a parent needs (`ParentMinSpeed`, code 83) and the divisor +//! turning speed into a child count (`ParentSpeedRatio`, code 84), and a +//! client whose recorded speed is below the minimum carries none. Until the +//! server has reported our own speed there is nothing to apply the formula to, +//! so the configured cap stands — which is how slskd runs the whole time, with +//! an operator-set limit and no speed gate. +//! //! Accepting children is opt-in ([`crate::ClientSettings::accept_children`]): //! it costs a socket and the network's whole search stream per child, which is //! not something a client should take on without being asked to. @@ -18,16 +26,39 @@ use crate::message::Message; use crate::message::distributed; use crate::{debug, trace}; -/// How many children to carry at once. Nicotine+ offers ten; the cost is one -/// socket and one copy of every search each, so the cap is what keeps a -/// parent's outbound traffic bounded. +/// How many children to carry at once. Nicotine+ caps its speed-derived limit +/// here too, for the same reason: each child is a socket and a copy of the +/// network's whole search stream. pub const MAX_CHILDREN: usize = 10; +/// The child limit the server's figures imply, following Nicotine+: +/// `speed / ratio / 100`, capped, and none at all below the minimum speed the +/// server set. `None` when the server has not reported our speed yet, which +/// leaves the caller's own cap standing. +#[must_use] +pub fn limit_from_speed( + own_speed: Option, + min_speed: u32, + ratio: u32, +) -> Option { + let speed = own_speed?; + if ratio == 0 || speed < min_speed { + return Some(0); + } + Some((speed / ratio / 100) as usize) +} + /// The children hanging from us, keyed by username. pub struct Children { links: HashMap, + /// The cap in force: [`MAX_CHILDREN`], lowered to what the server's + /// figures allow once it has reported our speed. max: usize, accepting: bool, + /// Whether anything is feeding us the search stream — a parent of our own, + /// or the server. Nicotine+ refuses children without one, and rightly: + /// a child hanging from a branch that receives nothing gets nothing. + fed: bool, } impl Default for Children { @@ -43,20 +74,40 @@ impl Children { links: HashMap::new(), max: MAX_CHILDREN, accepting, + fed: false, } } - /// Whether we take children at all, and still have room for one. - #[must_use] - pub fn has_room(&self) -> bool { - self.accepting && self.links.len() < self.max + /// Apply the server's figures: `own_speed` is what it records for us, and + /// the other two are what it announced in codes 83 and 84. + pub fn set_limits( + &mut self, + own_speed: Option, + min_speed: u32, + ratio: u32, + ) { + self.max = limit_from_speed(own_speed, min_speed, ratio) + .map_or(MAX_CHILDREN, |limit| limit.min(MAX_CHILDREN)); + debug!("[distributed] child limit is now {}", self.max); + } + + /// Say whether something is feeding us the search stream. A client with + /// nothing to relay takes no children, and drops any it has. + pub const fn set_fed(&mut self, fed: bool) { + self.fed = fed; } - /// Whether the client accepts children at all, regardless of how full it - /// is — what the server is told in `AcceptChildren` (code 100). #[must_use] - pub const fn accepting(&self) -> bool { - self.accepting + pub const fn is_fed(&self) -> bool { + self.fed + } + + /// Whether we would take another child right now: we serve children, we + /// have something to relay, and we are under the cap. This is what the + /// server is told in `AcceptChildren` (code 100). + #[must_use] + pub fn has_room(&self) -> bool { + self.accepting && self.fed && self.links.len() < self.max } #[must_use] @@ -76,19 +127,19 @@ impl Children { names } - /// Take on `username` as a child. Refused when we are not accepting or - /// are full; a second connection from a child we already carry replaces - /// the first, since the peer only reconnects when it thinks the old one - /// is gone. + /// Take on `username` as a child. Refused when we serve no children, have + /// nothing to relay, are full, or already carry that user. pub fn accept(&mut self, username: &str, stream: TcpStream) -> bool { - if !self.accepting { + if !self.has_room() { return false; } - if self.links.len() >= self.max && !self.links.contains_key(username) { + // A second connection from a child we already carry is refused, as + // Nicotine+ does: the link we hold is the live one, and dropping it + // for an unproven second would cost that child its search stream. + if self.links.contains_key(username) { debug!( - "[distributed] refusing child {}: already carrying {}", - username, - self.links.len() + "[distributed] refusing child {}: already carried", + username ); return false; } @@ -96,6 +147,15 @@ impl Children { true } + /// Drop every child, closing their links: what a client does when it can + /// no longer feed them. + pub fn drop_all(&mut self) { + if !self.links.is_empty() { + debug!("[distributed] dropping {} children", self.links.len()); + } + self.links.clear(); + } + pub fn remove(&mut self, username: &str) { if self.links.remove(username).is_some() { debug!("[distributed] child {} is gone", username); @@ -159,7 +219,7 @@ impl Children { token: u32, query: &str, ) { - if self.links.is_empty() { + if self.is_empty() { return; } self.broadcast(&distributed::build_search(username, token, query)); @@ -181,6 +241,13 @@ mod tests { (theirs, ours) } + /// A registry that serves children and has something to relay. + fn serving() -> Children { + let mut children = Children::new(true); + children.set_fed(true); + children + } + fn read_frames(stream: &mut TcpStream, bytes: usize) -> Vec { stream .set_read_timeout(Some(std::time::Duration::from_secs(2))) @@ -194,16 +261,42 @@ mod tests { fn a_client_that_does_not_accept_children_takes_none() { let (_child, ours) = pair(); let mut children = Children::new(false); + children.set_fed(true); assert!(!children.accept("alice", ours)); assert!(children.is_empty()); assert!(!children.has_room()); } #[test] - fn children_are_taken_up_to_the_cap() { + fn a_client_nothing_feeds_takes_no_children() { + // Nicotine+ refuses children while it has neither a parent nor the + // server relaying searches: a child would hang from a branch that + // receives nothing. + let (_child, ours) = pair(); let mut children = Children::new(true); - // The child ends are kept alive so the links stay up; the parent - // drops a child whose socket closes. + assert!(!children.has_room(), "unfed means no room"); + assert!(!children.accept("alice", ours)); + + children.set_fed(true); + let (_second, again) = pair(); + assert!(children.accept("alice", again)); + } + + #[test] + fn children_are_let_go_when_the_stream_dries_up() { + let mut children = serving(); + let (_child, ours) = pair(); + children.accept("alice", ours); + + children.set_fed(false); + children.drop_all(); + assert!(children.is_empty()); + assert!(!children.has_room()); + } + + #[test] + fn children_are_taken_up_to_the_cap() { + let mut children = serving(); let mut ends = Vec::new(); for i in 0..MAX_CHILDREN { let (child, ours) = pair(); @@ -219,18 +312,64 @@ mod tests { } #[test] - fn a_reconnecting_child_replaces_its_own_link() { - let mut children = Children::new(true); + fn a_second_link_from_a_child_we_carry_is_refused() { + // The link we hold is the live one; dropping it for an unproven + // second would cost that child its search stream. + let mut children = serving(); let (_first, ours) = pair(); assert!(children.accept("alice", ours)); let (_second, again) = pair(); - assert!(children.accept("alice", again)); + assert!(!children.accept("alice", again)); assert_eq!(children.usernames(), vec!["alice".to_string()]); } + #[test] + fn the_child_limit_follows_the_servers_figures() { + // Nicotine+: speed / ratio / 100, capped, and none at all below the + // minimum speed the server set. + assert_eq!(limit_from_speed(Some(50_000), 1_024, 50), Some(10)); + assert_eq!(limit_from_speed(Some(10_000), 1_024, 50), Some(2)); + assert_eq!( + limit_from_speed(Some(500), 1_024, 50), + Some(0), + "too slow to parent" + ); + assert_eq!( + limit_from_speed(Some(10_000), 1_024, 0), + Some(0), + "a zero ratio means the server is not allowing children" + ); + assert_eq!( + limit_from_speed(None, 1_024, 50), + None, + "an unreported speed leaves the caller's own cap standing" + ); + } + + #[test] + fn a_speed_the_server_has_not_reported_leaves_the_cap_alone() { + let mut children = serving(); + children.set_limits(None, 1_024, 50); + assert!(children.has_room(), "an unreported speed leaves us open"); + + // The server's figures win: at this speed the formula allows two. + children.set_limits(Some(10_000), 1_024, 50); + let mut ends = Vec::new(); + for i in 0..2 { + let (child, ours) = pair(); + ends.push(child); + assert!(children.accept(&format!("user{i}"), ours)); + } + assert!(!children.has_room(), "two is the limit at this speed"); + + children.set_limits(Some(100), 1_024, 50); + assert!(!children.has_room(), "a slow client carries none"); + assert_eq!(ends.len(), 2); + } + #[test] fn a_search_reaches_every_child() { - let mut children = Children::new(true); + let mut children = serving(); let (mut first, ours) = pair(); let (mut second, theirs) = pair(); children.accept("alice", ours); @@ -246,7 +385,7 @@ mod tests { #[test] fn a_child_that_hung_up_loses_its_slot() { - let mut children = Children::new(true); + let mut children = serving(); let (child, ours) = pair(); children.accept("alice", ours); drop(child); @@ -265,7 +404,7 @@ mod tests { #[test] fn a_child_is_told_where_our_branch_sits() { - let mut children = Children::new(true); + let mut children = serving(); let (mut child, ours) = pair(); children.accept("alice", ours); diff --git a/soulseek-rs-lib/src/client/connection.rs b/soulseek-rs-lib/src/client/connection.rs index a34e66c..12f9d43 100644 --- a/soulseek-rs-lib/src/client/connection.rs +++ b/soulseek-rs-lib/src/client/connection.rs @@ -264,6 +264,25 @@ impl Client { registered } + /// Tell the server we could not reach the peer it asked us to connect to, + /// quoting the token that peer is waiting on. + pub(crate) fn report_cant_connect( + client_context: &Arc>, + token: u32, + username: &str, + ) { + let sender = match client_context.read_safe() { + Ok(ctx) => ctx.server_sender.clone(), + Err(_) => return, + }; + let Some(sender) = sender else { return }; + let message = + crate::message::server::MessageFactory::build_cant_connect_to_peer( + token, username, + ); + let _ = sender.send(ServerMessage::SendMessage(message)); + } + pub(crate) fn connect_to_peer( peer: Peer, client_context: Arc>, @@ -306,7 +325,13 @@ impl Client { own_username, ); - match download_peer.download_file(client_context, None, None) { + let brokered = peer_clone.brokered; + let username = peer_clone.username; + match download_peer.download_file( + client_context.clone(), + None, + None, + ) { Ok((download, filename)) => { trace!( "[client] downloaded {} bytes {:?} ", @@ -315,6 +340,17 @@ impl Client { } Err(e) => { trace!("[client] failed to download: {}", e); + // A file connection the server asked us to make and + // we could not complete owes that peer a + // CantConnectToPeer, exactly as a control connection + // does: it is waiting on this token. + if brokered { + Self::report_cant_connect( + &client_context, + token, + &username, + ); + } } } } diff --git a/soulseek-rs-lib/src/client/distributed.rs b/soulseek-rs-lib/src/client/distributed.rs index cfc89de..df84a2b 100644 --- a/soulseek-rs-lib/src/client/distributed.rs +++ b/soulseek-rs-lib/src/client/distributed.rs @@ -320,8 +320,10 @@ pub fn announce_move( has_parent: bool, ) { let server = ctx.server_sender.clone(); - let accepting = ctx.children.accepting(); - announce(server.as_ref(), branch, has_parent, accepting); + // What the server needs to know is whether it may offer us to another + // peer right now, not whether we serve children in principle. + let has_room = ctx.children.has_room(); + announce(server.as_ref(), branch, has_parent, has_room); ctx.children.broadcast_stance(&branch.root, branch.level); } diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index abaf670..9343e62 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -149,6 +149,11 @@ fn upload_speed( /// Build a `FileSearchResponse` for `query` against `shares`, or `None` if /// nothing matches. `own_username` is the name the searcher will download from. +// One reply's worth of state: the shares to search, who we are, and the +// figures the reply advertises. Kept as arguments rather than a struct +// because every one of them is read straight from the client context at the +// call site, and a struct would only move the same list one line up. +#[allow(clippy::too_many_arguments)] fn build_search_response( shares: &Shares, own_username: &str, @@ -157,13 +162,21 @@ fn build_search_response( free_slot: bool, speed: u32, queue_length: u32, + excluded_phrases: &[String], ) -> Option { let matches = shares.search(query); if matches.is_empty() { return None; } + // A file whose path carries a phrase the server excludes (code 160) is + // left out of the reply, the way Nicotine+ does it: the exclusion is the + // server policing what travels the search network, and answering with one + // anyway is what it is asking us not to do. let entries: Vec = matches .iter() + .filter(|f| { + !path_carries_excluded_phrase(&f.virtual_path, excluded_phrases) + }) .take(crate::types::MAX_SEARCH_REPLY_FILES) .map(|f| FileEntry { name: &f.virtual_path, @@ -171,6 +184,9 @@ fn build_search_response( attribs: &f.attributes, }) .collect(); + if entries.is_empty() { + return None; + } Some(build_file_search_response( own_username, token, @@ -181,6 +197,18 @@ fn build_search_response( )) } +/// Whether `path` contains any of the phrases the server excludes. Matching +/// is on the lowercased path, as the phrases themselves are lowercase. +fn path_carries_excluded_phrase(path: &str, excluded: &[String]) -> bool { + if excluded.is_empty() { + return false; + } + let lowered = path.to_lowercase(); + excluded + .iter() + .any(|phrase| !phrase.is_empty() && lowered.contains(phrase)) +} + #[derive(Debug, Clone)] pub struct ClientSettings { pub username: String, @@ -312,6 +340,13 @@ pub enum ClientOperation { token: u32, folder: String, }, + /// A peer answered our request for one folder of their shares. + FolderContents { + username: String, + token: u32, + folder: String, + directories: Vec, + }, /// A peer we are browsing returned their shared-file listing. BrowseResult { username: String, @@ -382,6 +417,10 @@ pub enum ClientOperation { }, /// Phrases the server refuses to search for (code 160). ExcludedSearchPhrases(Vec), + /// The minimum upload speed for carrying children (code 83). + ParentMinSpeed(u32), + /// The divisor turning that speed into a child count (code 84). + ParentSpeedRatio(u32), /// A parent candidate told us how deep it sits. `link` says which dial /// to that user is talking. ParentBranchLevel { @@ -419,6 +458,13 @@ pub struct ClientContext { leaf: distributed::Leaf, /// The children hanging from us, when the client serves any. pub(crate) children: children::Children, + /// The upload speed the server records for us, once it has told us. The + /// child limit is derived from it. + own_average_speed: Option, + /// The speed a parent needs (code 83) and the divisor turning speed into + /// a child count (code 84), as the server announced them. + parent_min_speed: u32, + parent_speed_ratio: u32, searches: HashMap, private_messages: Vec, /// Correlation tokens for server-brokered (firewalled) connections, mapping @@ -443,6 +489,8 @@ pub struct ClientContext { pending_serves: HashMap>, /// Shared-file listings received from peers we browsed. browse_results: HashMap>, + /// One-folder listings received from peers, keyed by peer and folder. + folder_contents: HashMap<(String, String), Vec>, pending_browses: HashMap, /// Latest snapshot of the public chat-room list (from `RoomList`, code 64). room_list: Vec, @@ -580,6 +628,9 @@ impl ClientContext { operations: None, leaf: distributed::Leaf::new(""), children: children::Children::default(), + own_average_speed: None, + parent_min_speed: 0, + parent_speed_ratio: 0, searches: HashMap::new(), private_messages: Vec::new(), pending_connect_tokens: HashMap::new(), @@ -592,6 +643,7 @@ impl ClientContext { active_uploads: HashMap::new(), pending_serves: HashMap::new(), browse_results: HashMap::new(), + folder_contents: HashMap::new(), pending_browses: HashMap::new(), room_list: Vec::new(), room_events: Vec::new(), @@ -723,6 +775,63 @@ impl ClientContext { self.room_events.push(event); } + /// Record what the server says our own upload speed is, and re-derive the + /// child limit from it. + pub fn set_own_average_speed(&mut self, speed: u32) { + self.own_average_speed = Some(speed); + self.apply_child_limits(); + } + + /// Record the server's parent figures (codes 83 and 84). + pub fn set_parent_min_speed(&mut self, speed: u32) { + self.parent_min_speed = speed; + self.apply_child_limits(); + } + + pub fn set_parent_speed_ratio(&mut self, ratio: u32) { + self.parent_speed_ratio = ratio; + self.apply_child_limits(); + } + + fn apply_child_limits(&mut self) { + self.children.set_limits( + self.own_average_speed, + self.parent_min_speed, + self.parent_speed_ratio, + ); + } + + /// Whether something is feeding us the distributed search stream: a + /// parent of our own, or the server relaying searches to us. Children may + /// only hang from a client that has one. + pub fn set_fed_by(&mut self, fed: bool) { + self.children.set_fed(fed); + if !fed { + self.children.drop_all(); + } + } + + /// Record one folder's listing from `username`. + pub fn store_folder_contents( + &mut self, + username: String, + folder: String, + directories: Vec, + ) { + self.folder_contents.insert((username, folder), directories); + } + + /// Remove and return the listing of `folder` from `username`, if it has + /// arrived. + pub fn take_folder_contents( + &mut self, + username: &str, + folder: &str, + ) -> Option> { + self.folder_contents + .remove(&(username.to_string(), folder.to_string())) + } + /// Who may enter the private room `room`, as last reported. #[must_use] pub fn private_room_members(&self, room: &str) -> Vec { diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index 094a72d..4b31e16 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -10,6 +10,12 @@ use crate::peer::DownloadError; const CONNECT_SWEEP_INTERVAL: Duration = Duration::from_secs(1); +/// How often to ask the peers holding our queued downloads where they sit +/// (peer code 51). Five minutes is what Nicotine+ uses: often enough that a +/// queue position on screen means something, rare enough that a peer with a +/// long queue is not pestered. +const QUEUE_POSITION_INTERVAL: Duration = Duration::from_secs(300); + impl Client { pub(crate) fn listen_to_client_operations( reader: Receiver, @@ -18,6 +24,7 @@ impl Client { ) { thread::spawn(move || { let mut last_sweep = Instant::now(); + let mut last_queue_poll = Instant::now(); loop { let next = reader.recv_timeout(CONNECT_SWEEP_INTERVAL); if last_sweep.elapsed() >= CONNECT_SWEEP_INTERVAL { @@ -35,6 +42,10 @@ impl Client { } } } + if last_queue_poll.elapsed() >= QUEUE_POSITION_INTERVAL { + last_queue_poll = Instant::now(); + Self::poll_queue_positions(&client_context); + } let operation = match next { Ok(operation) => operation, Err(mpsc::RecvTimeoutError::Timeout) => continue, @@ -537,14 +548,28 @@ impl Client { token, query, } => { - // Pass it down the tree first: carrying the search - // stream is a parent's whole duty, and it is owed - // even for a search we ourselves cannot answer. - if let Ok(mut ctx) = client_context.write_safe() - && !ctx.children.is_empty() + // Every search we receive comes from the tree: from + // our parent, or from the server acting as one. That + // is what qualifies us to carry children at all — + // Nicotine+ refuses them until something feeds it — + // and passing the search down is the whole duty. + let capacity_changed = match client_context.write_safe() { - ctx.children - .broadcast_search(&username, token, &query); + Ok(mut ctx) => { + let was_fed = ctx.children.is_fed(); + let before = ctx.children.len(); + ctx.set_fed_by(true); + ctx.children + .broadcast_search(&username, token, &query); + // A child whose socket has gone is dropped by + // that write, which frees a slot the server + // should hear about. + !was_fed || ctx.children.len() != before + } + Err(_) => false, + }; + if capacity_changed { + Self::announce_child_capacity(&client_context); } // Don't answer our own distributed search. @@ -560,6 +585,7 @@ impl Client { ctx.has_free_upload_slot(), ctx.last_upload_speed, ctx.upload_queue.len() as u32, + &ctx.excluded_search_phrases(), ), Err(e) => { error!("[client] IncomingSearch read: {}", e); @@ -754,6 +780,10 @@ impl Client { continue; }; ctx.leaf.reset(); + // A fresh session feeds us nothing until the tree (or + // the server) starts again, so the children we were + // carrying are let go rather than left starving. + ctx.set_fed_by(false); let branch = ctx.leaf.branch(); super::distributed::announce_move( &mut ctx, &branch, false, @@ -831,6 +861,9 @@ impl Client { if stream.set_nodelay(true).is_err() { continue; } + if username == own_username { + continue; // we cannot hang from ourselves + } if ctx.children.accept(&username, stream) { let branch = ctx.leaf.branch(); ctx.children.send_stance_to( @@ -842,8 +875,48 @@ impl Client { "[distributed] carrying {} children", ctx.children.len() ); + // The server stops offering us to peers once we + // are full, and starts again when one leaves. + let full = !ctx.children.has_room(); + drop(ctx); + if full { + Self::announce_child_capacity(&client_context); + } } } + ClientOperation::FolderContents { + username, + token, + folder, + directories, + } => { + trace!( + "[client] folder {} from {} (token {}): {} dirs", + folder, + username, + token, + directories.len() + ); + if let Ok(mut ctx) = client_context.write_safe() { + ctx.store_folder_contents( + username, + folder, + directories, + ); + } + } + ClientOperation::ParentMinSpeed(speed) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.set_parent_min_speed(speed); + } + Self::announce_child_capacity(&client_context); + } + ClientOperation::ParentSpeedRatio(ratio) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.set_parent_speed_ratio(ratio); + } + Self::announce_child_capacity(&client_context); + } ClientOperation::ExcludedSearchPhrases(phrases) => { if let Ok(mut ctx) = client_context.write_safe() { ctx.set_excluded_search_phrases(phrases); @@ -1075,6 +1148,50 @@ impl Client { }); } + /// Ask every peer holding a queued download of ours where it sits. The + /// answers arrive as `PlaceInQueueResponse` and land on the downloads. + fn poll_queue_positions(client_context: &Arc>) { + let (registry, queued) = match client_context.read_safe() { + Ok(ctx) => ( + ctx.peer_registry.clone(), + ctx.get_downloads() + .iter() + .filter(|d| matches!(d.status, DownloadStatus::Queued)) + .map(|d| (d.username.clone(), d.filename.clone())) + .collect::>(), + ), + Err(_) => return, + }; + let Some(registry) = registry else { return }; + for (username, filename) in queued { + // Only over a connection we already hold: a peer we cannot reach + // is a connection problem, not a queue question. + if !registry.contains(&username) { + continue; + } + let request = + MessageFactory::build_place_in_queue_request(&filename); + let _ = registry + .send_to_peer(&username, PeerMessage::SendMessage(request)); + } + } + + /// Tell the server whether we would take another child right now + /// (`AcceptChildren`, code 100). Sent whenever that answer changes: the + /// server only offers us to peers while it is true. + fn announce_child_capacity(client_context: &Arc>) { + let (sender, has_room) = match client_context.read_safe() { + Ok(ctx) => (ctx.server_sender.clone(), ctx.children.has_room()), + Err(_) => return, + }; + let Some(sender) = sender else { return }; + let message = + crate::message::server::MessageFactory::build_accept_children( + has_room, + ); + let _ = sender.send(ServerMessage::SendMessage(message)); + } + /// Answer a peer with the message `build` derives from the client state. fn reply_to_peer( client_context: &Arc>, diff --git a/soulseek-rs-lib/src/client/search.rs b/soulseek-rs-lib/src/client/search.rs index cf9ed10..a8a2b18 100644 --- a/soulseek-rs-lib/src/client/search.rs +++ b/soulseek-rs-lib/src/client/search.rs @@ -62,9 +62,6 @@ impl Client { let Some(handle) = &self.server_handle else { return Err(SoulseekRs::NotConnected); }; - if let Some(phrase) = self.excluded_phrase_in(query) { - return Err(SoulseekRs::SearchPhraseExcluded(phrase)); - } let token = next_search_token(); self.context.write_safe()?.searches.insert( @@ -122,9 +119,6 @@ impl Client { if self.server_handle.is_none() { return Err(SoulseekRs::NotConnected); } - if let Some(phrase) = self.excluded_phrase_in(query) { - return Err(SoulseekRs::SearchPhraseExcluded(phrase)); - } let token = next_search_token(); self.context.write_safe()?.searches.insert( query.to_string(), @@ -136,8 +130,9 @@ impl Client { Ok(token) } - /// The phrases the server refuses to search for (code 160), as last - /// announced. Empty until the server has said. + /// The phrases the server excludes from the search network (code 160), as + /// last announced. Files whose path carries one are left out of the + /// replies this client sends. Empty until the server has said. #[must_use] pub fn excluded_search_phrases(&self) -> Vec { self.context @@ -146,16 +141,6 @@ impl Client { .unwrap_or_default() } - /// The excluded phrase `query` carries, if any — what a search would be - /// refused for. - #[must_use] - pub fn excluded_phrase_in(&self, query: &str) -> Option { - self.context - .read_safe() - .ok() - .and_then(|ctx| ctx.excluded_phrase_in(query)) - } - /// Let responses accumulate for `timeout`, or until cancelled. /// /// Peers answer a search over their own connections whenever they get round diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs index ef0dda6..5e3e439 100644 --- a/soulseek-rs-lib/src/client/social.rs +++ b/soulseek-rs-lib/src/client/social.rs @@ -166,6 +166,42 @@ impl Client { )) } + /// Ask `username` for the contents of one folder of their shares (peer + /// code 36) — what "download folder" in other clients asks for, without + /// pulling their whole listing. + /// + /// The answer arrives asynchronously; take it with + /// [`Client::take_folder_contents`]. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection, + /// or [`crate::SoulseekRs::LockPoisoned`] if the client state is poisoned. + pub fn request_folder_contents( + &self, + username: &str, + folder: &str, + ) -> Result<()> { + let request = crate::message::peer::build_folder_contents_request( + crate::client::next_search_token(), + folder, + ); + self.send_to_peer_or_queue(username, request) + } + + /// Remove and return the listing of `folder` from `username`, once their + /// answer to [`Client::request_folder_contents`] has arrived. + #[must_use] + pub fn take_folder_contents( + &self, + username: &str, + folder: &str, + ) -> Option> { + self.context + .write_safe() + .ok() + .and_then(|mut ctx| ctx.take_folder_contents(username, folder)) + } + /// Ask `username` where the file we queued with them sits (peer code 51). /// /// The answer arrives asynchronously and lands on the download, readable diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs index d85b8f5..111a2b1 100644 --- a/soulseek-rs-lib/src/client/tests.rs +++ b/soulseek-rs-lib/src/client/tests.rs @@ -161,7 +161,7 @@ fn build_search_response_matches_shares_and_echoes_token() { let shares = Shares::scan(&dir).unwrap(); let response = - build_search_response(&shares, "me", 99, "xyzzy", true, 0, 0) + build_search_response(&shares, "me", 99, "xyzzy", true, 0, 0, &[]) .expect("a matching share yields a response"); let mut decoded = crate::message::Message::new_with_data(response.get_buffer()); @@ -172,7 +172,7 @@ fn build_search_response_matches_shares_and_echoes_token() { assert!(result.files.iter().any(|f| f.name.contains("probe_xyzzy"))); assert!( - build_search_response(&shares, "me", 1, "nomatch", true, 0, 0) + build_search_response(&shares, "me", 1, "nomatch", true, 0, 0, &[]) .is_none() ); let _ = std::fs::remove_dir_all(dir); @@ -1034,3 +1034,53 @@ fn losing_operatorship_keeps_the_membership_it_does_not_touch() { assert_eq!(ctx.private_room_members("club"), vec!["alice".to_string()]); assert!(ctx.private_room_operators("club").is_empty()); } + +#[test] +fn a_file_the_server_excludes_is_left_out_of_a_reply() { + // The server's excluded phrases (code 160) police what travels the search + // network: a matching file whose path carries one must not be offered, + // and a reply with nothing left is not sent at all. + let dir = std::env::temp_dir() + .join(format!("soulseek-excluded-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Spam_Xyzzy.bin"), b"data").unwrap(); + let shares = Shares::scan(&dir).unwrap(); + + assert!( + build_search_response(&shares, "me", 1, "xyzzy", true, 0, 0, &[]) + .is_some(), + "with no exclusions the file is offered" + ); + assert!( + build_search_response( + &shares, + "me", + 1, + "xyzzy", + true, + 0, + 0, + &["spam".to_string()], + ) + .is_none(), + "an excluded phrase in the path, matched case-insensitively, \ + withholds the file — and with no files there is no reply" + ); + assert!( + build_search_response( + &shares, + "me", + 1, + "xyzzy", + true, + 0, + 0, + &["unrelated".to_string()], + ) + .is_some(), + "an exclusion the path does not carry changes nothing" + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/soulseek-rs-lib/src/error.rs b/soulseek-rs-lib/src/error.rs index 5b3b722..b25df46 100644 --- a/soulseek-rs-lib/src/error.rs +++ b/soulseek-rs-lib/src/error.rs @@ -20,9 +20,6 @@ pub enum SoulseekRs { CompressionError(String), /// A lock was poisoned by a panic in another thread LockPoisoned, - /// The query carries a phrase the server refuses to search for (code 160), - /// so sending it would spend a search on a query nothing answers. - SearchPhraseExcluded(String), } impl fmt::Display for SoulseekRs { @@ -44,9 +41,6 @@ impl fmt::Display for SoulseekRs { Self::CompressionError(msg) => { write!(f, "Compression error: {msg}") } - Self::SearchPhraseExcluded(phrase) => { - write!(f, "The server excludes searches for \"{phrase}\"") - } Self::LockPoisoned => { write!(f, "Lock poisoned by panicking thread") } diff --git a/soulseek-rs-lib/src/message/peer/folder_contents.rs b/soulseek-rs-lib/src/message/peer/folder_contents.rs index cf0f6f8..893554c 100644 --- a/soulseek-rs-lib/src/message/peer/folder_contents.rs +++ b/soulseek-rs-lib/src/message/peer/folder_contents.rs @@ -26,6 +26,37 @@ impl MessageHandler for FolderContentsRequest { } } +/// `FolderContentsResponse` (peer code 37): the reply to a folder we asked +/// for. The token is the one we quoted, so a client with two folders in +/// flight can tell the answers apart. +pub struct FolderContentsResponseHandler; +impl MessageHandler for FolderContentsResponseHandler { + fn get_code(&self) -> u32 { + 37 + } + fn handle(&self, message: &mut Message, sender: Sender) { + let Some((token, folder, directories)) = parse_folder_contents(message) + else { + return; + }; + let _ = sender.send(PeerMessage::FolderContentsReceived { + token, + folder, + directories, + }); + } +} + +/// Build a `FolderContentsRequest` (peer code 36). +#[must_use] +pub fn build_folder_contents_request(token: u32, folder: &str) -> Message { + Message::new() + .write_int32(36) + .write_int32(token) + .write_string(folder) + .clone() +} + /// Build a `FolderContentsResponse` (peer code 37). #[must_use] pub fn build_folder_contents( diff --git a/soulseek-rs-lib/src/message/peer/mod.rs b/soulseek-rs-lib/src/message/peer/mod.rs index b3bba95..1e48d17 100644 --- a/soulseek-rs-lib/src/message/peer/mod.rs +++ b/soulseek-rs-lib/src/message/peer/mod.rs @@ -17,7 +17,9 @@ pub use file_search_response::{ FileEntry, FileSearchResponse, build_file_search_response, }; pub use folder_contents::{ - FolderContentsRequest, build_folder_contents, parse_folder_contents, + FolderContentsRequest, FolderContentsResponseHandler, + build_folder_contents, build_folder_contents_request, + parse_folder_contents, }; pub use get_share_file_list::GetShareFileList; pub use peer_init::PeerInit; diff --git a/soulseek-rs-lib/src/message/server/parent_min_speed.rs b/soulseek-rs-lib/src/message/server/parent_min_speed.rs index b93ef6c..e783421 100644 --- a/soulseek-rs-lib/src/message/server/parent_min_speed.rs +++ b/soulseek-rs-lib/src/message/server/parent_min_speed.rs @@ -14,8 +14,32 @@ impl MessageHandler for ParentMinSpeedHandler { } fn handle(&self, message: &mut Message, sender: Sender) { - let _ = sender; let number = message.read_int32(); debug!("Parent min speed: {}", number); + // Kept, not just logged: with the ratio below it decides how many + // children this client may carry. + let _ = sender.send(ServerMessage::ParentMinSpeed(number)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn the_minimum_speed_is_reported_not_discarded() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(1024); + }); + + ParentMinSpeedHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ParentMinSpeed(speed)) => { + assert_eq!(speed, 1024); + } + other => panic!("unexpected: {other:?}"), + } } } diff --git a/soulseek-rs-lib/src/message/server/parent_speed_ratio.rs b/soulseek-rs-lib/src/message/server/parent_speed_ratio.rs index 744c6ea..e171927 100644 --- a/soulseek-rs-lib/src/message/server/parent_speed_ratio.rs +++ b/soulseek-rs-lib/src/message/server/parent_speed_ratio.rs @@ -14,8 +14,31 @@ impl MessageHandler for ParentSpeedRatioHandler { 84 } - fn handle(&self, message: &mut Message, _sender: Sender) { + fn handle(&self, message: &mut Message, sender: Sender) { let number = message.read_int32(); debug!("Parent speed ratio: {}", number); + let _ = sender.send(ServerMessage::ParentSpeedRatio(number)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn the_ratio_is_reported_not_discarded() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(50); + }); + + ParentSpeedRatioHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ParentSpeedRatio(ratio)) => { + assert_eq!(ratio, 50); + } + other => panic!("unexpected: {other:?}"), + } } } diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 64e08b0..179d22d 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -4431,7 +4431,11 @@ fn stub_server_sending( } #[test] -fn an_excluded_phrase_is_refused_before_a_search_is_spent() { +fn phrases_the_server_excludes_are_kept_for_our_replies() { + // The server tells clients which phrases are excluded from the search + // network (code 160). Nicotine+ applies them to the files it offers in a + // search reply, not to the searches it sends, and so do we — this pins + // that the announced list arrives and is kept for that use. let mut phrases = Message::new(); phrases .write_int32(160) @@ -4469,18 +4473,11 @@ fn an_excluded_phrase_is_refused_before_a_search_is_spent() { "the announced phrases should be kept" ); - // A query carrying one is refused here rather than spent on the server, - // and the match is case-insensitive on substrings, as the server's is. - match client.search("A BANNED-PHRASE query", Duration::from_millis(100)) { - Err(soulseek_rs::SoulseekRs::SearchPhraseExcluded(phrase)) => { - assert_eq!(phrase, "banned-phrase"); - } - other => panic!("expected a refusal, got {other:?}"), - } - // A clean query is not refused. + // Our own searches are not policed by them: that is the server's job, and + // no reference client refuses a query locally. assert!( client - .search("something else", Duration::from_millis(100)) + .search("a banned-phrase query", Duration::from_millis(100)) .is_ok() ); @@ -4865,6 +4862,28 @@ fn a_parent_tells_a_child_where_it_sits_and_passes_searches_down() { parent.connect().expect("parent connect"); assert!(parent.login().expect("parent login")); + // A client takes children only once something feeds it the search + // stream — here the server, which relays every search to us. Until then + // a child would hang from a branch that receives nothing, which is why + // Nicotine+ refuses one too. + let addr = format!("{}:{}", server.host, server.port); + let mut searcher = + login_raw(&addr, "e2e_par_searcher", "pw").expect("searcher login"); + let search = |searcher: &mut TcpStream, token: u32| { + searcher + .write_all( + &MessageFactory::build_file_search_message( + token, + "e2e_child_relay_probe", + ) + .get_buffer(), + ) + .expect("send a search"); + searcher.flush().expect("flush the search"); + }; + search(&mut searcher, 515_150); + std::thread::sleep(Duration::from_secs(2)); + let mut child = dial_as_child(listen_port, "e2e_par_child").expect("child dials"); @@ -4902,20 +4921,8 @@ fn a_parent_tells_a_child_where_it_sits_and_passes_searches_down() { // A search the server hands us must reach the child, whether or not we // can answer it ourselves — carrying the stream is the parent's duty. - let addr = format!("{}:{}", server.host, server.port); - let mut searcher = - login_raw(&addr, "e2e_par_searcher", "pw").expect("searcher login"); let token = 515_151_u32; - searcher - .write_all( - &MessageFactory::build_file_search_message( - token, - "e2e_child_relay_probe", - ) - .get_buffer(), - ) - .expect("send a search"); - searcher.flush().expect("flush the search"); + search(&mut searcher, token); let relayed = read_distributed_until( &mut child, @@ -4938,19 +4945,24 @@ fn a_parent_tells_a_child_where_it_sits_and_passes_searches_down() { ) ); + // A second link from the same child is refused: the one we hold is live. + let mut duplicate = + dial_as_child(listen_port, "e2e_par_child").expect("child dials again"); + let told_again = + read_distributed_until(&mut duplicate, Duration::from_secs(3), |f| { + matches!(f, distributed::Distributed::BranchRoot(_)).then_some(()) + }); + assert!(told_again.is_none(), "a child we carry is not taken twice"); + assert_eq!(parent.children(), vec!["e2e_par_child".to_string()]); + drop(duplicate); + // A child that hangs up loses its place. drop(child); let deadline = Instant::now() + Duration::from_secs(20); + let mut next_token = token + 1; while !parent.children().is_empty() && Instant::now() < deadline { - searcher - .write_all( - &MessageFactory::build_file_search_message( - token + 1, - "e2e_child_relay_probe", - ) - .get_buffer(), - ) - .expect("send another search"); + search(&mut searcher, next_token); + next_token += 1; std::thread::sleep(Duration::from_millis(500)); } assert!( @@ -4984,3 +4996,55 @@ fn a_client_that_does_not_serve_children_turns_one_away() { assert!(told.is_none(), "a leaf must not take on a child"); assert!(leaf.children().is_empty()); } + +#[test] +fn one_folder_of_a_peers_shares_can_be_asked_for_by_itself() { + // "Download folder" in other clients asks for one folder (peer code 36) + // rather than pulling the peer's whole listing. + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::create_dir_all(share_dir.join("wanted")).unwrap(); + std::fs::create_dir_all(share_dir.join("unwanted")).unwrap(); + std::fs::write(share_dir.join("wanted").join("keep.bin"), b"abcd").unwrap(); + std::fs::write(share_dir.join("unwanted").join("skip.bin"), b"efgh") + .unwrap(); + + let (_sharer, asker) = sharer_and_searcher( + &server, + &share_dir, + "e2e_folder_sharer", + "e2e_folder_asker", + ); + + // The folder is named the way the sharer advertises it: the share root's + // basename, then the subfolder, backslash-separated. + let root = share_dir.file_name().unwrap().to_string_lossy().to_string(); + let folder = format!("{root}\\wanted"); + + let deadline = Instant::now() + Duration::from_secs(20); + let mut listing = None; + while Instant::now() < deadline && listing.is_none() { + asker + .request_folder_contents("e2e_folder_sharer", &folder) + .expect("ask for one folder"); + std::thread::sleep(Duration::from_millis(500)); + listing = asker.take_folder_contents("e2e_folder_sharer", &folder); + } + let listing = listing.expect("the peer should answer with that folder"); + + assert!( + listing + .iter() + .any(|dir| dir.files.iter().any(|f| f.name.contains("keep.bin"))), + "the folder we asked for should be in the answer, got {listing:?}" + ); + assert!( + !listing + .iter() + .any(|dir| dir.files.iter().any(|f| f.name.contains("skip.bin"))), + "no other folder should come with it, got {listing:?}" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} From 773b5a4100dcc089f58d624d36d756412bcc6f3b Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:06:08 +0000 Subject: [PATCH 30/55] feat(interests): keep our own list and send it again each login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server holds interests only for the session that set them, so a client that does not re-send its list comes back from a reconnect with none. That is why Nicotine+ sends its whole list at every login, and why it lowercases each item before sending — the server matches them case-insensitively, so two spellings would otherwise be held as two interests. Ours now keeps the list, in one spelling, and puts it back on the wire when a session starts; an interest set before there is a connection is kept rather than dropped. A room's ticker board is also let go when we leave the room, since the server sends the whole board again on the next join. --- soulseek-rs-lib/src/actor/server_actor.rs | 2 + .../src/actor/server_actor/tests.rs | 6 ++ soulseek-rs-lib/src/client/mod.rs | 46 +++++++++++++ soulseek-rs-lib/src/client/operations.rs | 24 +++++++ soulseek-rs-lib/src/client/social.rs | 31 +++++++-- soulseek-rs-lib/src/client/tests.rs | 42 ++++++++++++ soulseek-rs-lib/tests/e2e.rs | 65 +++++++++++++++++++ 7 files changed, 212 insertions(+), 4 deletions(-) diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index 26b76dd..ec0e3c3 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -834,6 +834,8 @@ impl ServerActor { // The distributed stance is the leaf's to announce, and a new // session starts without a parent. self.forward_to_client(ClientOperation::ResetDistributed); + // The server holds interests only for the life of a session. + self.forward_to_client(ClientOperation::SessionEstablished); } match self.context.write_safe() { Ok(mut ctx) => ctx.logged_in = Some(message), diff --git a/soulseek-rs-lib/src/actor/server_actor/tests.rs b/soulseek-rs-lib/src/actor/server_actor/tests.rs index 8faba13..0809d99 100644 --- a/soulseek-rs-lib/src/actor/server_actor/tests.rs +++ b/soulseek-rs-lib/src/actor/server_actor/tests.rs @@ -210,6 +210,12 @@ fn a_successful_login_resets_the_distributed_leaf() { client.try_recv(), Ok(ClientOperation::ResetDistributed) )); + // A new session also has to be told what only the session held: our + // interests, which the server forgets when the old one ended. + assert!(matches!( + client.try_recv(), + Ok(ClientOperation::SessionEstablished) + )); actor.handle_login_status(false); assert!(client.try_recv().is_err(), "a failed login resets nothing"); } diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 9343e62..f2c50ad 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -384,6 +384,9 @@ pub enum ClientOperation { PossibleParents(Vec<(String, String, u16)>), /// The server told us to drop our parent. ResetDistributed, + /// A session just started: anything the server keeps only per-session — + /// our interests — has to be sent again. + SessionEstablished, /// A peer dialled us with a `D` connection, asking to hang from us in the /// distributed search network. ChildConnected { @@ -523,6 +526,10 @@ pub struct ClientContext { item_similar_users: HashMap>, /// Interests of other users (code 57), keyed by username. user_interests: HashMap, + /// What we ourselves like and hate. The server keeps these only for the + /// duration of a session, so they are held here and sent again after each + /// login — the same thing Nicotine+ does from its config. + own_interests: UserInterests, /// What the server has told us about other users, merged across the /// separate status and statistics replies. user_info: HashMap, @@ -658,6 +665,7 @@ impl ClientContext { similar_users: Vec::new(), item_similar_users: HashMap::new(), user_interests: HashMap::new(), + own_interests: UserInterests::default(), user_info: HashMap::new(), watched_users: HashSet::new(), wishlist_interval: None, @@ -687,6 +695,9 @@ impl ClientContext { } RoomEvent::Left { room } => { self.room_members.remove(room); + // A board for a room we are not in is stale; the server sends + // the whole board again on the next join. + self.room_tickers.remove(room); } RoomEvent::UserJoined { room, username } => { let members = @@ -775,6 +786,41 @@ impl ClientContext { self.room_events.push(event); } + /// Remember an interest of our own so it can be sent again next login. + /// Interests are matched by the server case-insensitively, and Nicotine+ + /// lowercases them before sending; do the same so two spellings of one + /// interest cannot both be held. + pub fn add_own_interest(&mut self, item: &str, liked: bool) -> String { + let item = item.trim().to_lowercase(); + let list = if liked { + &mut self.own_interests.likes + } else { + &mut self.own_interests.hates + }; + if !item.is_empty() && !list.contains(&item) { + list.push(item.clone()); + } + item + } + + /// Forget one of our own interests. + pub fn remove_own_interest(&mut self, item: &str, liked: bool) -> String { + let item = item.trim().to_lowercase(); + let list = if liked { + &mut self.own_interests.likes + } else { + &mut self.own_interests.hates + }; + list.retain(|held| held != &item); + item + } + + /// What we like and hate, as last set. + #[must_use] + pub fn own_interests(&self) -> UserInterests { + self.own_interests.clone() + } + /// Record what the server says our own upload speed is, and re-derive the /// child limit from it. pub fn set_own_average_speed(&mut self, speed: u32) { diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index 4b31e16..b70030d 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -917,6 +917,30 @@ impl Client { } Self::announce_child_capacity(&client_context); } + ClientOperation::SessionEstablished => { + // Interests live in the server only for the session + // that set them, so a new one starts by sending ours + // again — what Nicotine+ does from its config file. + let (sender, interests) = match client_context + .read_safe() + { + Ok(ctx) => { + (ctx.server_sender.clone(), ctx.own_interests()) + } + Err(_) => continue, + }; + let Some(sender) = sender else { continue }; + for item in &interests.likes { + let _ = sender.send(ServerMessage::SendMessage( + MessageFactory::build_add_thing_i_like(item), + )); + } + for item in &interests.hates { + let _ = sender.send(ServerMessage::SendMessage( + MessageFactory::build_add_thing_i_hate(item), + )); + } + } ClientOperation::ExcludedSearchPhrases(phrases) => { if let Ok(mut ctx) = client_context.write_safe() { ctx.set_excluded_search_phrases(phrases); diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs index 5e3e439..908a073 100644 --- a/soulseek-rs-lib/src/client/social.rs +++ b/soulseek-rs-lib/src/client/social.rs @@ -265,10 +265,16 @@ impl Client { /// Add an interest (code 51). Interests are what the recommendation and /// similar-user queries below are computed from. /// + /// The server holds interests only for the session that set them, so the + /// list is kept here and sent again after every login. An interest added + /// before there is a connection is kept all the same — the error says + /// only that it could not go out yet, not that it was dropped. + /// /// # Errors /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. pub fn add_interest(&self, item: &str) -> Result<()> { - self.send_server_message(MessageFactory::build_add_thing_i_like(item)) + let item = self.context.write_safe()?.add_own_interest(item, true); + self.send_server_message(MessageFactory::build_add_thing_i_like(&item)) } /// Drop an interest (code 52). @@ -276,8 +282,9 @@ impl Client { /// # Errors /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. pub fn remove_interest(&self, item: &str) -> Result<()> { + let item = self.context.write_safe()?.remove_own_interest(item, true); self.send_server_message(MessageFactory::build_remove_thing_i_like( - item, + &item, )) } @@ -286,7 +293,8 @@ impl Client { /// # Errors /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. pub fn add_dislike(&self, item: &str) -> Result<()> { - self.send_server_message(MessageFactory::build_add_thing_i_hate(item)) + let item = self.context.write_safe()?.add_own_interest(item, false); + self.send_server_message(MessageFactory::build_add_thing_i_hate(&item)) } /// Drop a dislike (code 118). @@ -294,8 +302,9 @@ impl Client { /// # Errors /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. pub fn remove_dislike(&self, item: &str) -> Result<()> { + let item = self.context.write_safe()?.remove_own_interest(item, false); self.send_server_message(MessageFactory::build_remove_thing_i_hate( - item, + &item, )) } @@ -318,6 +327,20 @@ impl Client { self.send_server_message(MessageFactory::build_global_recommendations()) } + /// What we ourselves like and hate, as set through this client. The + /// server forgets these when the session ends, so they are re-sent after + /// each login. + #[must_use] + pub fn own_interests(&self) -> UserInterests { + match self.context.read_safe() { + Ok(ctx) => ctx.own_interests(), + Err(e) => { + error!("[client] own_interests: {}", e); + UserInterests::default() + } + } + } + /// The last reply to [`Client::request_recommendations`] as /// (recommended, recommended-against), or `None` until one arrives. #[must_use] diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs index 111a2b1..d9d75fa 100644 --- a/soulseek-rs-lib/src/client/tests.rs +++ b/soulseek-rs-lib/src/client/tests.rs @@ -1084,3 +1084,45 @@ fn a_file_the_server_excludes_is_left_out_of_a_reply() { let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn our_own_interests_are_kept_lowercased_and_deduplicated() { + // The server matches interests case-insensitively and forgets them when + // the session ends, so they are held here in one spelling to be sent + // again next login. + let mut ctx = ClientContext::new(); + assert_eq!(ctx.add_own_interest(" Krautrock ", true), "krautrock"); + ctx.add_own_interest("KRAUTROCK", true); + ctx.add_own_interest("Muzak", false); + + let interests = ctx.own_interests(); + assert_eq!(interests.likes, ["krautrock"]); + assert_eq!(interests.hates, ["muzak"]); + + ctx.remove_own_interest("KrautRock", true); + assert!(ctx.own_interests().likes.is_empty()); + assert_eq!( + ctx.own_interests().hates, + ["muzak"], + "the other list stands" + ); +} + +#[test] +fn leaving_a_room_drops_its_ticker_board() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::Tickers { + room: "jazz".into(), + tickers: vec![RoomTicker { + username: "alice".into(), + ticker: "hi".into(), + }], + }); + ctx.apply_room_event(RoomEvent::Left { + room: "jazz".into(), + }); + assert!( + ctx.room_tickers("jazz").is_empty(), + "a board for a room we left is stale" + ); +} diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 179d22d..b06b102 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -5048,3 +5048,68 @@ fn one_folder_of_a_peers_shares_can_be_asked_for_by_itself() { let _ = std::fs::remove_dir_all(share_dir); } + +#[test] +fn our_interests_are_sent_again_after_a_new_login() { + // The server keeps interests only for the session that set them, so a + // client that does not send them again comes back with none — which is + // why Nicotine+ re-sends its list at every login, from its config. Ours + // holds the list itself: an interest set before there is a connection is + // still on the server once one is made. + let server = server_or_skip!(); + + let item = "e2e_relike_shoegaze"; + let mut alice = + Client::with_settings(server.settings("e2e_relike_alice", "pw")); + // Set before connecting: there is nothing to send it over yet, and the + // login is what puts it on the wire. + let _ = alice.add_interest(item); + assert_eq!(alice.own_interests().likes, [item]); + + alice.connect().expect("alice connect"); + assert!(alice.login().expect("alice login")); + std::thread::sleep(Duration::from_millis(500)); + + let mut watcher = + Client::with_settings(server.settings("e2e_relike_watch", "pw")); + watcher.connect().expect("watcher connect"); + assert!(watcher.login().expect("watcher login")); + + let likes_of_alice = |watcher: &Client| -> Vec { + let deadline = Instant::now() + Duration::from_secs(5); + let mut likes = Vec::new(); + while Instant::now() < deadline && !likes.iter().any(|i| i == item) { + watcher + .request_user_interests("e2e_relike_alice") + .expect("ask about alice"); + std::thread::sleep(Duration::from_millis(250)); + likes = watcher + .user_interests("e2e_relike_alice") + .map(|i| i.likes) + .unwrap_or_default(); + } + likes + }; + + let likes = likes_of_alice(&watcher); + assert!( + likes.iter().any(|i| i == item), + "the login should have carried the interest with it, got {likes:?}" + ); + + // The same client, in a new session: its list goes back up by itself. + drop(alice); + std::thread::sleep(Duration::from_secs(1)); + let mut again = + Client::with_settings(server.settings("e2e_relike_alice", "pw")); + let _ = again.add_interest(item); + again.connect().expect("reconnect"); + assert!(again.login().expect("re-login")); + std::thread::sleep(Duration::from_millis(500)); + + let likes = likes_of_alice(&watcher); + assert!( + likes.iter().any(|i| i == item), + "a new session should carry the interests too, got {likes:?}" + ); +} From 6d6f4f282e48158c081208a47f963bea10da4319 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:11:04 +0000 Subject: [PATCH 31/55] feat(distributed): learn our own upload speed by watching ourselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The child limit is derived from the upload speed the server records for us, and the server pushes a user's stats to whoever watches that user — so a client that does not watch itself never learns its own figure and would carry the default cap forever. Nicotine+ reads the same number off the stats the server sends about the logged-in user. We now watch ourselves as each session starts, and the stats that come back — the initial reply and every later push — set the figure the limit is derived from. Client::own_average_speed exposes it. --- soulseek-rs-lib/src/client/mod.rs | 6 ++ soulseek-rs-lib/src/client/operations.rs | 88 ++++++++++++++++++------ soulseek-rs-lib/src/client/social.rs | 11 +++ soulseek-rs-lib/tests/e2e.rs | 63 +++++++++++++++++ 4 files changed, 146 insertions(+), 22 deletions(-) diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index f2c50ad..06150fc 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -821,6 +821,12 @@ impl ClientContext { self.own_interests.clone() } + /// The upload speed the server records for us, if it has said. + #[must_use] + pub const fn own_average_speed(&self) -> Option { + self.own_average_speed + } + /// Record what the server says our own upload speed is, and re-derive the /// child limit from it. pub fn set_own_average_speed(&mut self, speed: u32) { diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index b70030d..fed1473 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -443,17 +443,35 @@ impl Client { average_speed, shared_files, shared_folders, - } => match client_context.write_safe() { - Ok(mut ctx) => ctx.apply_user_stats( - username, - average_speed, - shared_files, - shared_folders, - ), - Err(e) => { - error!("[client] UserStatsReceived write: {}", e); + } => { + // Stats about ourselves carry the speed the server + // records for us — pushed whenever it changes, since + // we watch ourselves — and the distributed child + // limit is derived from it. + let own = username == own_username; + match client_context.write_safe() { + Ok(mut ctx) => { + ctx.apply_user_stats( + username, + average_speed, + shared_files, + shared_folders, + ); + if own { + ctx.set_own_average_speed(average_speed); + } + } + Err(e) => { + error!( + "[client] UserStatsReceived write: {}", + e + ); + } } - }, + if own { + Self::announce_child_capacity(&client_context); + } + } ClientOperation::WatchedUserReceived { username, exists, @@ -461,19 +479,36 @@ impl Client { average_speed, shared_files, shared_folders, - } => match client_context.write_safe() { - Ok(mut ctx) => ctx.apply_watched_user( - username, - exists, - status, - average_speed, - shared_files, - shared_folders, - ), - Err(e) => { - error!("[client] WatchedUserReceived write: {}", e); + } => { + let own = username == own_username; + match client_context.write_safe() { + Ok(mut ctx) => { + ctx.apply_watched_user( + username, + exists, + status, + average_speed, + shared_files, + shared_folders, + ); + // Watching ourselves is how our own recorded + // speed reaches us, and the child limit is + // derived from it. + if own && let Some(speed) = average_speed { + ctx.set_own_average_speed(speed); + } + } + Err(e) => { + error!( + "[client] WatchedUserReceived write: {}", + e + ); + } } - }, + if own { + Self::announce_child_capacity(&client_context); + } + } ClientOperation::RoomEvent(event) => { match client_context.write_safe() { Ok(mut ctx) => ctx.apply_room_event(event), @@ -930,6 +965,15 @@ impl Client { Err(_) => continue, }; let Some(sender) = sender else { continue }; + // Watch ourselves: the server pushes a user's stats + // to whoever watches them, and our own recorded + // upload speed is what the distributed child limit is + // derived from. Nicotine+ reads the same figure from + // the stats the server sends about the logged-in + // user. + let _ = sender.send(ServerMessage::SendMessage( + MessageFactory::build_watch_user(&own_username), + )); for item in &interests.likes { let _ = sender.send(ServerMessage::SendMessage( MessageFactory::build_add_thing_i_like(item), diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs index 908a073..607317a 100644 --- a/soulseek-rs-lib/src/client/social.rs +++ b/soulseek-rs-lib/src/client/social.rs @@ -327,6 +327,17 @@ impl Client { self.send_server_message(MessageFactory::build_global_recommendations()) } + /// The upload speed the server records for us, once it has reported it. + /// It is what the distributed child limit is derived from, and it only + /// becomes known after an upload has finished and been reported. + #[must_use] + pub fn own_average_speed(&self) -> Option { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.own_average_speed()) + } + /// What we ourselves like and hate, as set through this client. The /// server forgets these when the session ends, so they are re-sent after /// each login. diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index b06b102..149896f 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -5113,3 +5113,66 @@ fn our_interests_are_sent_again_after_a_new_login() { "a new session should carry the interests too, got {likes:?}" ); } + +#[test] +fn a_finished_upload_teaches_us_our_own_recorded_speed() { + // The server records a client's upload speed and pushes it to whoever + // watches that user. We watch ourselves at login, which is how our own + // figure — the one the distributed child limit is derived from — reaches + // us without anyone asking on our behalf. + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + let content: Vec = (0..8192u32).map(|i| (i % 251) as u8).collect(); + let filename = "e2e_ownspeed_probe.bin"; + std::fs::write(share_dir.join(filename), &content).unwrap(); + + let (sharer, leecher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_ownspeed_sharer", + "e2e_ownspeed_leecher", + ); + assert_eq!( + sharer.own_average_speed(), + Some(0), + "a client that has never uploaded is recorded at zero" + ); + + let query = "ownspeed"; + let _ = leecher.search(query, Duration::from_secs(3)); + let (path, size) = reply_from(&leecher, query, "e2e_ownspeed_sharer") + .and_then(|reply| reply.files.into_iter().next()) + .map(|file| (file.name, file.size)) + .expect("the leecher should find the file"); + + let download_dir = unique_download_dir(); + let (_download, status_rx) = leecher + .download( + path, + "e2e_ownspeed_sharer".to_string(), + size, + download_dir.display().to_string(), + ) + .expect("start download"); + assert!( + wait_for_completion(&leecher, &status_rx, Duration::from_secs(25)), + "the download should complete" + ); + + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline + && sharer.own_average_speed().unwrap_or(0) == 0 + { + std::thread::sleep(Duration::from_millis(250)); + } + assert!( + sharer.own_average_speed().unwrap_or(0) > 0, + "the speed the server recorded for us should reach us unasked, got {:?} (info: {:?})", + sharer.own_average_speed(), + sharer.user_info("e2e_ownspeed_sharer"), + ); + + let _ = std::fs::remove_dir_all(share_dir); + let _ = std::fs::remove_dir_all(download_dir); +} From 0546d3a77cc9dd458243571e33cf980ee5d3071e Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:14:17 +0000 Subject: [PATCH 32/55] feat(peer): ask a peer what it says about itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client answered a UserInfoRequest but could never send one, so a user's profile — description, upload slots, queue length — was something this client served and never saw. The reply (peer code 16) is now parsed too, with the tolerances other clients carry: a picture is skipped rather than kept, a length that overruns the message is clamped, and a message that stops early (older clients, and Museek+ writing a boolean as an integer) still yields every field before it. --- soulseek-rs-lib/src/actor/peer_actor.rs | 10 ++ soulseek-rs-lib/src/client/mod.rs | 32 ++++ soulseek-rs-lib/src/client/operations.rs | 5 + soulseek-rs-lib/src/client/social.rs | 28 ++++ soulseek-rs-lib/src/message/peer/mod.rs | 5 +- soulseek-rs-lib/src/message/peer/user_info.rs | 148 ++++++++++++++++++ soulseek-rs-lib/tests/e2e.rs | 36 +++++ 7 files changed, 263 insertions(+), 1 deletion(-) diff --git a/soulseek-rs-lib/src/actor/peer_actor.rs b/soulseek-rs-lib/src/actor/peer_actor.rs index b7aefe8..a494b04 100644 --- a/soulseek-rs-lib/src/actor/peer_actor.rs +++ b/soulseek-rs-lib/src/actor/peer_actor.rs @@ -1,6 +1,7 @@ use crate::actor::{Actor, ActorHandle, ConnectionState}; use crate::client::ClientOperation; use crate::dispatcher::MessageDispatcher; +use crate::message::peer::UserInfoResponseHandler; use crate::message::peer::{ FileSearchResponse, FolderContentsRequest, FolderContentsResponseHandler, GetShareFileList, PeerInit, PlaceInQueueRequest, PlaceInQueueResponse, @@ -47,6 +48,8 @@ pub enum PeerMessage { ShareListRequested, /// A peer we are browsing sent us their shared-file listing (code 5). ShareListReceived(Vec), + /// A peer answered our `UserInfoRequest` with what it says about itself. + UserInfoReceived(crate::message::peer::PeerInfo), /// A peer answered our `FolderContentsRequest` with one folder's listing. FolderContentsReceived { token: u32, @@ -207,6 +210,7 @@ impl PeerActor { handlers.register_handler(FolderContentsRequest); handlers.register_handler(FolderContentsResponseHandler); handlers.register_handler(UserInfoRequest); + handlers.register_handler(UserInfoResponseHandler); handlers.register_handler(UploadDeniedHandler); handlers.register_handler(UploadFailedHandler); handlers.register_handler(PlaceInQueueRequest); @@ -317,6 +321,12 @@ impl PeerActor { directories, }); } + PeerMessage::UserInfoReceived(info) => { + self.forward(ClientOperation::PeerInfoReceived { + username: self.peer_username(), + info, + }); + } PeerMessage::FolderContentsReceived { token, folder, diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 06150fc..f17ec45 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -340,6 +340,11 @@ pub enum ClientOperation { token: u32, folder: String, }, + /// A peer answered our request for what it says about itself. + PeerInfoReceived { + username: String, + info: crate::message::peer::PeerInfo, + }, /// A peer answered our request for one folder of their shares. FolderContents { username: String, @@ -494,6 +499,8 @@ pub struct ClientContext { browse_results: HashMap>, /// One-folder listings received from peers, keyed by peer and folder. folder_contents: HashMap<(String, String), Vec>, + /// What peers said about themselves (peer code 16), keyed by peer. + peer_infos: HashMap, pending_browses: HashMap, /// Latest snapshot of the public chat-room list (from `RoomList`, code 64). room_list: Vec, @@ -651,6 +658,7 @@ impl ClientContext { pending_serves: HashMap::new(), browse_results: HashMap::new(), folder_contents: HashMap::new(), + peer_infos: HashMap::new(), pending_browses: HashMap::new(), room_list: Vec::new(), room_events: Vec::new(), @@ -863,6 +871,30 @@ impl ClientContext { } } + /// Record what `username` says about itself. + pub fn store_peer_info( + &mut self, + username: String, + info: crate::message::peer::PeerInfo, + ) { + self.peer_infos.insert(username, info); + } + + /// What `username` last said about itself, if anything. + #[must_use] + pub fn peer_info( + &self, + username: &str, + ) -> Option { + self.peer_infos.get(username).cloned() + } + + /// Drop what we hold about `username`, so a poll after a fresh request + /// cannot report the previous answer. + pub fn invalidate_peer_info(&mut self, username: &str) { + self.peer_infos.remove(username); + } + /// Record one folder's listing from `username`. pub fn store_folder_contents( &mut self, diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index fed1473..578aee6 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -919,6 +919,11 @@ impl Client { } } } + ClientOperation::PeerInfoReceived { username, info } => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.store_peer_info(username, info); + } + } ClientOperation::FolderContents { username, token, diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs index 607317a..10d235c 100644 --- a/soulseek-rs-lib/src/client/social.rs +++ b/soulseek-rs-lib/src/client/social.rs @@ -166,6 +166,34 @@ impl Client { )) } + /// Ask `username` what it says about itself (peer code 15): its + /// description, upload slots and queue length — a user's profile as other + /// clients show it. + /// + /// Any previous answer is dropped first, so [`Client::peer_info`] reports + /// the reply to this request rather than a stale one. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection, + /// or [`crate::SoulseekRs::LockPoisoned`] if the client state is poisoned. + pub fn request_peer_info(&self, username: &str) -> Result<()> { + self.context.write_safe()?.invalidate_peer_info(username); + let request = crate::message::peer::build_user_info_request(); + self.send_to_peer_or_queue(username, request) + } + + /// What `username` last said about itself. + #[must_use] + pub fn peer_info( + &self, + username: &str, + ) -> Option { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.peer_info(username)) + } + /// Ask `username` for the contents of one folder of their shares (peer /// code 36) — what "download folder" in other clients asks for, without /// pulling their whole listing. diff --git a/soulseek-rs-lib/src/message/peer/mod.rs b/soulseek-rs-lib/src/message/peer/mod.rs index 1e48d17..6ea8a40 100644 --- a/soulseek-rs-lib/src/message/peer/mod.rs +++ b/soulseek-rs-lib/src/message/peer/mod.rs @@ -34,4 +34,7 @@ pub use transfer_request::TransferRequest; pub use transfer_response::TransferResponse; pub use upload_denied::UploadDeniedHandler; pub use upload_failed::UploadFailedHandler; -pub use user_info::{UserInfoRequest, build_user_info}; +pub use user_info::{ + PeerInfo, UserInfoRequest, UserInfoResponseHandler, build_user_info, + build_user_info_request, +}; diff --git a/soulseek-rs-lib/src/message/peer/user_info.rs b/soulseek-rs-lib/src/message/peer/user_info.rs index 0887ad8..ee246fd 100644 --- a/soulseek-rs-lib/src/message/peer/user_info.rs +++ b/soulseek-rs-lib/src/message/peer/user_info.rs @@ -15,6 +15,71 @@ impl MessageHandler for UserInfoRequest { } } +/// Build a `UserInfoRequest` (peer code 15, no body): ask a peer what it says +/// about itself. +#[must_use] +pub fn build_user_info_request() -> Message { + Message::new().write_int32(15).clone() +} + +/// What a peer says about itself, from its `UserInfoResponse` (code 16). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PeerInfo { + pub description: String, + /// Whether a profile picture came with it, and how many bytes it was. The + /// image itself is not kept: no caller has a use for it yet, and a peer + /// can make it arbitrarily large. + pub picture_bytes: Option, + /// Uploads the peer says it has done in total. + pub total_uploads: u32, + /// How many transfers are waiting in its queue. + pub queue_size: u32, + /// Whether it has a free upload slot right now. + pub slots_free: bool, + /// Whether it would let us queue anything at all. Absent from older + /// clients, which stop the message one field early. + pub upload_allowed: Option, +} + +/// `UserInfoResponse` (peer code 16): a peer's answer about itself. +pub struct UserInfoResponseHandler; +impl MessageHandler for UserInfoResponseHandler { + fn get_code(&self) -> u32 { + 16 + } + fn handle(&self, message: &mut Message, sender: Sender) { + let description = message.read_string(); + let has_picture = message.read_bool(); + let picture_bytes = has_picture.then(|| { + let length = message.read_int32() as usize; + let available = + message.get_size().saturating_sub(message.get_pointer()); + let length = length.min(available); + let at = message.get_pointer() + length; + message.set_pointer(at); + length + }); + let total_uploads = message.read_int32(); + let queue_size = message.read_int32(); + let slots_free = message.read_bool(); + // Museek+ writes `slotsavail` as an integer, leaving three bytes of + // rubbish where the next field starts; Nicotine+ guards the read the + // same way, by only taking a field there is room for. + let upload_allowed = + (message.get_size().saturating_sub(message.get_pointer()) >= 4) + .then(|| message.read_int32()); + + let _ = sender.send(PeerMessage::UserInfoReceived(PeerInfo { + description, + picture_bytes, + total_uploads, + queue_size, + slots_free, + upload_allowed, + })); + } +} + /// Build a `UserInfoResponse` (peer code 16) with no description or picture. #[must_use] pub fn build_user_info( @@ -31,3 +96,86 @@ pub fn build_user_info( .write_bool(slots_free) .clone() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + fn parse(build: impl FnOnce(&mut Message)) -> PeerInfo { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(build); + UserInfoResponseHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(PeerMessage::UserInfoReceived(info)) => info, + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_peers_answer_carries_its_slots_and_queue() { + let info = parse(|m| { + m.write_string("hello, I share flacs"); + m.write_bool(false); + m.write_int32(42); // total uploads + m.write_int32(3); // queue size + m.write_bool(true); // a slot is free + m.write_int32(1); // uploads allowed + }); + + assert_eq!(info.description, "hello, I share flacs"); + assert_eq!(info.picture_bytes, None); + assert_eq!(info.total_uploads, 42); + assert_eq!(info.queue_size, 3); + assert!(info.slots_free); + assert_eq!(info.upload_allowed, Some(1)); + } + + #[test] + fn a_picture_is_skipped_over_rather_than_kept() { + let info = parse(|m| { + m.write_string("with a picture"); + m.write_bool(true); + m.write_int32(4); + m.write_raw_bytes(vec![1, 2, 3, 4]); + m.write_int32(7); + m.write_int32(0); + m.write_bool(false); + m.write_int32(0); + }); + + assert_eq!(info.picture_bytes, Some(4)); + assert_eq!(info.total_uploads, 7, "the fields after it still line up"); + } + + #[test] + fn a_message_that_stops_early_still_parses() { + // Older clients omit the trailing field, and Museek+ leaves rubbish + // where it would be; neither may cost us the fields before it. + let info = parse(|m| { + m.write_string("older client"); + m.write_bool(false); + m.write_int32(5); + m.write_int32(1); + m.write_bool(true); + }); + + assert_eq!(info.total_uploads, 5); + assert_eq!(info.queue_size, 1); + assert!(info.slots_free); + assert_eq!(info.upload_allowed, None); + } + + #[test] + fn a_picture_longer_than_the_message_does_not_run_off_the_end() { + let info = parse(|m| { + m.write_string("hostile"); + m.write_bool(true); + m.write_int32(u32::MAX); + m.write_raw_bytes(vec![9, 9]); + }); + + assert_eq!(info.picture_bytes, Some(2)); + assert_eq!(info.total_uploads, 0, "nothing left to read reads as zero"); + } +} diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 149896f..cf6a37d 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -5176,3 +5176,39 @@ fn a_finished_upload_teaches_us_our_own_recorded_speed() { let _ = std::fs::remove_dir_all(share_dir); let _ = std::fs::remove_dir_all(download_dir); } + +#[test] +fn a_peer_tells_us_about_itself_when_asked() { + // The profile another client shows for a user: their description, upload + // slots and queue length, straight from the peer (code 15 and its reply). + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::write(share_dir.join("e2e_peerinfo.bin"), b"data").unwrap(); + let (_sharer, asker) = sharer_and_searcher( + &server, + &share_dir, + "e2e_peerinfo_sharer", + "e2e_peerinfo_asker", + ); + + let deadline = Instant::now() + Duration::from_secs(20); + let mut info = None; + while Instant::now() < deadline && info.is_none() { + asker + .request_peer_info("e2e_peerinfo_sharer") + .expect("ask the peer about itself"); + std::thread::sleep(Duration::from_millis(500)); + info = asker.peer_info("e2e_peerinfo_sharer"); + } + let info = info.expect("the peer should answer for itself"); + + assert!( + info.slots_free, + "a peer with nothing queued has a free slot, got {info:?}" + ); + assert_eq!(info.queue_size, 0); + assert_eq!(info.picture_bytes, None); + + let _ = std::fs::remove_dir_all(share_dir); +} From 68586639accdea13136bf886aa358a3ce1141e65 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:15:29 +0000 Subject: [PATCH 33/55] docs: record what the client speaks and where it is proven --- docs/protocol-coverage.md | 115 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/protocol-coverage.md diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md new file mode 100644 index 0000000..9a4bebc --- /dev/null +++ b/docs/protocol-coverage.md @@ -0,0 +1,115 @@ +# Soulseek protocol coverage + +What this client speaks, and where each message is proven to work. "e2e" means +a test in `soulseek-rs-lib/tests/e2e.rs` drives it against a real server +(soulfind) or a real peer; "unit" means the encoder or parser is tested on its +own, which is all some messages can have — a server we do not run may never +send them. + +Message names and codes follow the Soulseek protocol as implemented by +[soulfind](https://github.com/soulfind-dev/soulfind) and +[Nicotine+](https://github.com/nicotine-plus/nicotine-plus). + +## Server messages + +| Code | Message | State | Covered by | +| --- | --- | --- | --- | +| 1 | Login | yes | e2e — login, wrong password, re-login | +| 2 | SetWaitPort | yes | e2e — the port a listening client reports | +| 3 | GetPeerAddress | yes | e2e — direct download, browse | +| 5 | WatchUser | yes | e2e — watching a user, and an unknown one | +| 6 | UnwatchUser | yes | e2e — updates stop after unwatching | +| 7 | GetUserStatus | yes | e2e — status and share counts, away status | +| 13 | SayChatroom | yes | e2e — a message between two users in a room | +| 14 | JoinRoom | yes | e2e — public rooms, and private ones | +| 15 | LeaveRoom | yes | e2e — room list and membership | +| 16 | UserJoinedRoom | yes | e2e — room membership statistics | +| 17 | UserLeftRoom | yes | e2e — room membership statistics | +| 18 | ConnectToPeer | yes | e2e — firewalled download and browse | +| 22 | MessageUser | yes | e2e — a private message between users | +| 23 | MessageAcked | yes | e2e — an offline message is delivered once | +| 26 | FileSearch | yes | e2e — search round trip, two clients | +| 28 | SetStatus | yes | e2e — going away is visible to another user | +| 32 | ServerPing | yes | e2e — the session survives pings | +| 35 | SharedFoldersFiles | yes | e2e — share counts as another user sees them | +| 36 | GetUserStats | yes | e2e — stats, and our own recorded speed | +| 41 | Relogged | yes | e2e — a second login reports the first lost | +| 42 | UserSearch | yes | e2e — the named user answers | +| 51 / 52 | AddThingILike / Remove | yes | e2e — interests and similar users | +| 54 | GetRecommendations | yes | e2e — recommendations from our interests | +| 56 | GlobalRecommendations | yes | e2e — the server-wide list | +| 57 | UserInterests | yes | e2e — another user's likes and hates | +| 64 | RoomList | yes | e2e — a joined room appears in the list | +| 66 | AdminMessage | yes | e2e — a server announcement | +| 69 | PrivilegedUsers | yes | e2e — a privileged peer overtakes in the queue | +| 71 | HaveNoParent | yes | e2e — a leaf adopting a parent | +| 83 / 84 | ParentMinSpeed / ParentSpeedRatio | yes | unit — they set the child limit | +| 92 | CheckPrivileges | yes | e2e — how much privilege time we have | +| 93 | EmbeddedMessage | yes | unit — soulfind never sends one | +| 100 | AcceptChildren | yes | e2e — serving children | +| 102 | PossibleParents | yes | e2e — a leaf adopting a parent | +| 103 | WishlistSearch | yes | e2e — a wish is answered like any search | +| 104 | WishlistInterval | yes | e2e — the announced interval is kept | +| 110 | SimilarUsers | yes | e2e — shared interests make users similar | +| 111 | ItemRecommendations | yes | e2e — what else people who like this like | +| 112 | ItemSimilarUsers | yes | e2e — who likes an item | +| 113 / 114 / 115 | RoomTickers and changes | yes | e2e — the board on join, and updates | +| 116 | SetRoomTicker | yes | e2e — a ticker reaches other members | +| 117 / 118 | AddThingIHate / Remove | yes | e2e — hates come back in UserInterests | +| 120 | RoomSearch | yes | e2e — a member of the room answers | +| 121 | SendUploadSpeed | yes | e2e — the server records a finished upload | +| 123 | GivePrivileges | yes | unit — needs privileges to exercise | +| 126 / 127 | BranchLevel / BranchRoot | yes | e2e — leaf adoption, and children | +| 130 | ResetDistributed | yes | unit — a new session starts parentless | +| 133 | RoomMembers | yes | e2e — a private room's roster | +| 134 / 135 | AddRoomMember / Remove | yes | e2e — granted and revoked | +| 136 | CancelRoomMembership | yes | unit | +| 137 | CancelRoomOwnership | yes | unit | +| 139 / 140 | RoomMembershipGranted / Revoked | yes | e2e — the guest is told | +| 141 | EnableRoomInvitations | yes | e2e — set before an invitation | +| 142 | ChangePassword | yes | e2e — the next login needs the new one | +| 143 / 144 | AddRoomOperator / Remove | yes | e2e — the guest is promoted | +| 145 / 146 | RoomOperatorshipGranted / Revoked | yes | e2e — the guest is told | +| 147 | CancelRoomOperatorship | yes | unit | +| 148 | RoomOperators | yes | unit | +| 149 | MessageUsers | yes | e2e — one message, two recipients | +| 150 / 151 / 152 | Global room | yes | e2e — the feed starts and stops | +| 160 | ExcludedSearchPhrases | yes | e2e — the list is kept for our replies | +| 1001 | CantConnectToPeer | yes | e2e — a dial we cannot complete reports back | +| 1003 | CantCreateRoom | yes | e2e — a private room owned by someone else | + +Not implemented, and deliberately so: the messages the protocol lists as +obsolete or deprecated, which no current client sends and soulfind answers only +to be polite — SendConnectToken (33), UploadSlotsFull (40), SimilarRecommendations +(50), MyRecommendations (55), PlaceInLineRequest (59), GlobalUserList (67), +ParentIP (73), UserPrivileged (122), NotifyPrivileges (124), AckNotifyPrivileges +(125), ChildDepth (129) and RelatedSearch (153). + +## Peer messages + +| Code | Message | State | Covered by | +| --- | --- | --- | --- | +| 0 | PierceFirewall | yes | e2e — download and browse through the server | +| 1 | PeerInit | yes | e2e — every direct peer test | +| 4 / 5 | GetShareFileList and its reply | yes | e2e — browsing, in both directions | +| 9 | FileSearchResponse | yes | e2e — searching, in both directions | +| 15 / 16 | UserInfoRequest and its reply | yes | e2e — asking a peer about itself, and answering | +| 36 / 37 | FolderContentsRequest and its reply | yes | e2e — one folder of a peer's shares | +| 40 | TransferRequest | yes | e2e — downloads and uploads | +| 41 | TransferResponse | yes | e2e — downloads, cancellations, refusals | +| 43 | QueueUpload | yes | e2e — queueing, slots, privileged peers | +| 44 | PlaceInQueueResponse | yes | e2e — answering, and asking | +| 46 | UploadFailed | yes | e2e — the download fails promptly | +| 50 | UploadDenied | yes | e2e — the download fails promptly | +| 51 | PlaceInQueueRequest | yes | e2e — both directions | + +## Distributed messages + +| Code | Message | State | Covered by | +| --- | --- | --- | --- | +| 3 | DistribSearch | yes | e2e — a leaf answers, a parent passes it down | +| 4 | DistribBranchLevel | yes | e2e — adoption, and telling children | +| 5 | DistribBranchRoot | yes | e2e — adoption, and telling children | +| 93 | DistribEmbeddedMessage | yes | unit — unwrapped once, as Nicotine+ does | + +Serving children is off by default; see `accept_children` in the README. From e30cca68257538c16db435baff5d4b9919bf5c6a Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:16:10 +0000 Subject: [PATCH 34/55] test(e2e): a room announces members arriving and leaving UserJoinedRoom and UserLeftRoom (codes 16 and 17) were parsed and applied to the roster with nothing proving either end of that, which is the kind of gap that only shows up when someone relies on it. --- docs/protocol-coverage.md | 4 +- soulseek-rs-lib/tests/e2e.rs | 72 ++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md index 9a4bebc..dbb96c7 100644 --- a/docs/protocol-coverage.md +++ b/docs/protocol-coverage.md @@ -23,8 +23,8 @@ Message names and codes follow the Soulseek protocol as implemented by | 13 | SayChatroom | yes | e2e — a message between two users in a room | | 14 | JoinRoom | yes | e2e — public rooms, and private ones | | 15 | LeaveRoom | yes | e2e — room list and membership | -| 16 | UserJoinedRoom | yes | e2e — room membership statistics | -| 17 | UserLeftRoom | yes | e2e — room membership statistics | +| 16 | UserJoinedRoom | yes | e2e — a member arriving is announced | +| 17 | UserLeftRoom | yes | e2e — a member leaving is announced | | 18 | ConnectToPeer | yes | e2e — firewalled download and browse | | 22 | MessageUser | yes | e2e — a private message between users | | 23 | MessageAcked | yes | e2e — an offline message is delivered once | diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index cf6a37d..059a991 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -5212,3 +5212,75 @@ fn a_peer_tells_us_about_itself_when_asked() { let _ = std::fs::remove_dir_all(share_dir); } + +#[test] +fn a_room_reports_members_arriving_and_leaving() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_room_comings"; + let mut alice = + Client::with_settings(server.settings("e2e_comings_alice", "pw")); + alice.connect().expect("alice connect"); + assert!(alice.login().expect("alice login")); + alice.join_room(room).expect("alice joins"); + std::thread::sleep(Duration::from_millis(500)); + let _ = alice.take_room_events(); + + // Bob arrives after her: she is told (code 16), and her roster grows. + let mut bob = + Client::with_settings(server.settings("e2e_comings_bob", "pw")); + bob.connect().expect("bob connect"); + assert!(bob.login().expect("bob login")); + bob.join_room(room).expect("bob joins"); + + let joined = + await_room_event(&alice, Duration::from_secs(5), |event| match event { + RoomEvent::UserJoined { room: r, username } + if r == room && username == "e2e_comings_bob" => + { + Some(()) + } + _ => None, + }); + assert!(joined.is_some(), "alice should be told bob arrived"); + assert!( + alice + .room_members(room) + .iter() + .any(|u| u == "e2e_comings_bob"), + "the roster should carry bob, got {:?}", + alice.room_members(room) + ); + + // And when he leaves (code 17) the roster loses him again. + bob.leave_room(room).expect("bob leaves"); + let left = + await_room_event(&alice, Duration::from_secs(5), |event| match event { + RoomEvent::UserLeft { room: r, username } + if r == room && username == "e2e_comings_bob" => + { + Some(()) + } + _ => None, + }); + assert!(left.is_some(), "alice should be told bob left"); + + let deadline = Instant::now() + Duration::from_secs(3); + while Instant::now() < deadline + && alice + .room_members(room) + .iter() + .any(|u| u == "e2e_comings_bob") + { + std::thread::sleep(Duration::from_millis(100)); + } + assert!( + !alice + .room_members(room) + .iter() + .any(|u| u == "e2e_comings_bob"), + "the roster should drop him, got {:?}", + alice.room_members(room) + ); +} From 17769568d2358357ce329f49eb1fc74ac656935d Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:17:41 +0000 Subject: [PATCH 35/55] test(e2e): resigning a private room, disbanding it, gifting privileges Three messages that were implemented and only encoder-tested: a member giving up their own membership (136), an owner disbanding the room and freeing its name (137), and a privileged account handing time to another user (123), which the recipient then sees as privilege time of their own. --- docs/protocol-coverage.md | 6 +- soulseek-rs-lib/tests/e2e.rs | 126 +++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md index dbb96c7..98abe88 100644 --- a/docs/protocol-coverage.md +++ b/docs/protocol-coverage.md @@ -58,13 +58,13 @@ Message names and codes follow the Soulseek protocol as implemented by | 117 / 118 | AddThingIHate / Remove | yes | e2e — hates come back in UserInterests | | 120 | RoomSearch | yes | e2e — a member of the room answers | | 121 | SendUploadSpeed | yes | e2e — the server records a finished upload | -| 123 | GivePrivileges | yes | unit — needs privileges to exercise | +| 123 | GivePrivileges | yes | e2e — a privileged account hands time over | | 126 / 127 | BranchLevel / BranchRoot | yes | e2e — leaf adoption, and children | | 130 | ResetDistributed | yes | unit — a new session starts parentless | | 133 | RoomMembers | yes | e2e — a private room's roster | | 134 / 135 | AddRoomMember / Remove | yes | e2e — granted and revoked | -| 136 | CancelRoomMembership | yes | unit | -| 137 | CancelRoomOwnership | yes | unit | +| 136 | CancelRoomMembership | yes | e2e — a member resigns | +| 137 | CancelRoomOwnership | yes | e2e — the owner disbands the room | | 139 / 140 | RoomMembershipGranted / Revoked | yes | e2e — the guest is told | | 141 | EnableRoomInvitations | yes | e2e — set before an invitation | | 142 | ChangePassword | yes | e2e — the next login needs the new one | diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 059a991..bded8db 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -5284,3 +5284,129 @@ fn a_room_reports_members_arriving_and_leaving() { alice.room_members(room) ); } + +#[test] +fn a_member_can_give_up_a_private_room_and_an_owner_can_disband_it() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_private_leaving"; + let mut owner = + Client::with_settings(server.settings("e2e_leave_owner", "pw")); + let mut guest = + Client::with_settings(server.settings("e2e_leave_guest", "pw")); + owner.connect().expect("owner connect"); + guest.connect().expect("guest connect"); + assert!(owner.login().expect("owner login")); + assert!(guest.login().expect("guest login")); + + guest + .set_room_invitations_enabled(true) + .expect("guest accepts invitations"); + owner + .join_private_room(room) + .expect("owner creates the room"); + std::thread::sleep(Duration::from_millis(750)); + owner + .add_room_member(room, "e2e_leave_guest") + .expect("owner invites the guest"); + + let granted = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: true, granted: true + } if r == room) + .then_some(()) + }); + assert!(granted.is_some(), "the guest should be a member first"); + + // The guest gives up their own membership (code 136): the owner's roster + // loses them. + guest.leave_private_room(room).expect("guest resigns"); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline + && owner + .private_room_members(room) + .iter() + .any(|u| u == "e2e_leave_guest") + { + std::thread::sleep(Duration::from_millis(200)); + } + assert!( + !owner + .private_room_members(room) + .iter() + .any(|u| u == "e2e_leave_guest"), + "a member who resigned should leave the roster, got {:?}", + owner.private_room_members(room) + ); + + // The owner disbands the room (code 137), which frees the name: someone + // else asking for it is not refused as they would be for a room that + // still belongs to another user. + owner.disband_private_room(room).expect("owner disbands"); + std::thread::sleep(Duration::from_secs(1)); + + let mut newcomer = + Client::with_settings(server.settings("e2e_leave_newcomer", "pw")); + newcomer.connect().expect("newcomer connect"); + assert!(newcomer.login().expect("newcomer login")); + newcomer + .join_private_room(room) + .expect("newcomer asks for the name"); + + let refused = + await_room_event(&newcomer, Duration::from_secs(3), |event| { + matches!(event, RoomEvent::CantCreate { room: r } if r == room) + .then_some(()) + }); + assert!( + refused.is_none(), + "a disbanded room's name should be free again" + ); +} + +#[test] +fn privileges_can_be_handed_to_another_user() { + let server = server_or_skip!(); + + // Only a privileged account can give privileges away, and privileges + // themselves are granted in soulfind's own database. + let giver = "e2e_gift_giver"; + let mut giving = Client::with_settings(server.settings(giver, "pw")); + giving.connect().expect("giver connect"); + assert!(giving.login().expect("giver login")); + if !grant(&server, giver, "privileges") { + println!("e2e skipped: cannot grant privileges on this server"); + return; + } + drop(giving); + std::thread::sleep(Duration::from_secs(1)); + + let mut giving = Client::with_settings(server.settings(giver, "pw")); + giving.connect().expect("giver reconnect"); + assert!(giving.login().expect("giver re-login")); + + let mut receiver = + Client::with_settings(server.settings("e2e_gift_receiver", "pw")); + receiver.connect().expect("receiver connect"); + assert!(receiver.login().expect("receiver login")); + + giving + .give_privileges("e2e_gift_receiver", 1) + .expect("hand over a day of privileges"); + + // The recipient now has privilege time of their own to check (code 92). + let deadline = Instant::now() + Duration::from_secs(10); + let mut left = 0; + while Instant::now() < deadline && left == 0 { + receiver + .check_privileges() + .expect("ask how much time we have"); + std::thread::sleep(Duration::from_millis(500)); + left = receiver.own_privilege_seconds().unwrap_or(0); + } + assert!( + left > 0, + "the gifted privileges should show up as time of our own" + ); +} From d26f6353a580d39682bda7e92dfbb64ab1a07f97 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:18:40 +0000 Subject: [PATCH 36/55] test(e2e): the operator roster of a private room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotion already proved the promoted user is told; this pins the other half — the roster the owner sees (code 143), and an operator standing down of their own accord (147). --- docs/protocol-coverage.md | 4 ++-- soulseek-rs-lib/tests/e2e.rs | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md index 98abe88..0646c4c 100644 --- a/docs/protocol-coverage.md +++ b/docs/protocol-coverage.md @@ -70,8 +70,8 @@ Message names and codes follow the Soulseek protocol as implemented by | 142 | ChangePassword | yes | e2e — the next login needs the new one | | 143 / 144 | AddRoomOperator / Remove | yes | e2e — the guest is promoted | | 145 / 146 | RoomOperatorshipGranted / Revoked | yes | e2e — the guest is told | -| 147 | CancelRoomOperatorship | yes | unit | -| 148 | RoomOperators | yes | unit | +| 147 | CancelRoomOperatorship | yes | e2e — an operator stands down | +| 148 | RoomOperators | yes | e2e — the roster after a promotion | | 149 | MessageUsers | yes | e2e — one message, two recipients | | 150 / 151 / 152 | Global room | yes | e2e — the feed starts and stops | | 160 | ExcludedSearchPhrases | yes | e2e — the list is kept for our replies | diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index bded8db..aa72f6b 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -4655,6 +4655,48 @@ fn a_private_room_is_owned_granted_and_revoked() { "the guest should be told they run the room" ); + // The owner's operator roster carries them too (code 143). + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && !owner + .private_room_operators(room) + .iter() + .any(|u| u == "e2e_priv_guest") + { + std::thread::sleep(Duration::from_millis(100)); + } + assert!( + owner + .private_room_operators(room) + .iter() + .any(|u| u == "e2e_priv_guest"), + "the operator roster should carry the guest, got {:?}", + owner.private_room_operators(room) + ); + + // And an operator can stand down themselves (code 147), which the + // owner's roster reflects. + guest + .resign_room_operatorship(room) + .expect("guest stands down"); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline + && owner + .private_room_operators(room) + .iter() + .any(|u| u == "e2e_priv_guest") + { + std::thread::sleep(Duration::from_millis(200)); + } + assert!( + !owner + .private_room_operators(room) + .iter() + .any(|u| u == "e2e_priv_guest"), + "an operator who stood down should leave the roster, got {:?}", + owner.private_room_operators(room) + ); + // And membership can be taken away again (code 140). owner .remove_room_member(room, "e2e_priv_guest") From 435ff382d86218b19d6777f0059e5e0109cbb417 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:23:12 +0000 Subject: [PATCH 37/55] fix(distributed): a server with no parent figures leaves our cap alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The speed-derived child limit read a missing ratio as a refusal, so on any server that never announces ParentSpeedRatio — soulfind among them — the client carried no children at all, however it was configured. A ratio we were never told is not a decision: the server's figures set the limit only when it has given them, and otherwise the configured cap stands, which is how slskd runs throughout. A speed below the announced minimum still means none, which is the safety property the rule exists for. Also stops repeating AcceptChildren: it is a standing state on the server, so it goes out when the answer changes rather than on every message that happens to prompt a recount. --- soulseek-rs-lib/src/client/children.rs | 25 +++++++++++++++++------ soulseek-rs-lib/src/client/distributed.rs | 5 ++++- soulseek-rs-lib/src/client/mod.rs | 14 +++++++++++++ soulseek-rs-lib/src/client/operations.rs | 12 +++++++++-- soulseek-rs-lib/src/client/tests.rs | 12 +++++++++++ 5 files changed, 59 insertions(+), 9 deletions(-) diff --git a/soulseek-rs-lib/src/client/children.rs b/soulseek-rs-lib/src/client/children.rs index 0a054d0..45fad3f 100644 --- a/soulseek-rs-lib/src/client/children.rs +++ b/soulseek-rs-lib/src/client/children.rs @@ -32,17 +32,24 @@ use crate::{debug, trace}; pub const MAX_CHILDREN: usize = 10; /// The child limit the server's figures imply, following Nicotine+: -/// `speed / ratio / 100`, capped, and none at all below the minimum speed the -/// server set. `None` when the server has not reported our speed yet, which -/// leaves the caller's own cap standing. +/// `speed / ratio / 100`, capped, and none at all when our recorded speed is +/// below the minimum the server set. +/// +/// `None` means the server's figures do not decide it — either it has not +/// reported our speed yet, or it never announced a ratio at all (soulfind +/// does not) — and the caller's own cap stands, which is how slskd runs the +/// whole time. #[must_use] pub fn limit_from_speed( own_speed: Option, min_speed: u32, ratio: u32, ) -> Option { + if ratio == 0 { + return None; + } let speed = own_speed?; - if ratio == 0 || speed < min_speed { + if speed < min_speed { return Some(0); } Some((speed / ratio / 100) as usize) @@ -336,8 +343,8 @@ mod tests { ); assert_eq!( limit_from_speed(Some(10_000), 1_024, 0), - Some(0), - "a zero ratio means the server is not allowing children" + None, + "a server that never announced a ratio does not decide the limit" ); assert_eq!( limit_from_speed(None, 1_024, 50), @@ -364,6 +371,12 @@ mod tests { children.set_limits(Some(100), 1_024, 50); assert!(!children.has_room(), "a slow client carries none"); + + children.set_limits(Some(100), 1_024, 0); + assert!( + children.has_room(), + "a server with no figures leaves our own cap standing" + ); assert_eq!(ends.len(), 2); } diff --git a/soulseek-rs-lib/src/client/distributed.rs b/soulseek-rs-lib/src/client/distributed.rs index df84a2b..98344cf 100644 --- a/soulseek-rs-lib/src/client/distributed.rs +++ b/soulseek-rs-lib/src/client/distributed.rs @@ -321,8 +321,11 @@ pub fn announce_move( ) { let server = ctx.server_sender.clone(); // What the server needs to know is whether it may offer us to another - // peer right now, not whether we serve children in principle. + // peer right now, not whether we serve children in principle. The stance + // carries it, so record it as told: a separate AcceptChildren right + // behind this one would say nothing new. let has_room = ctx.children.has_room(); + let _ = ctx.accept_children_change(); announce(server.as_ref(), branch, has_parent, has_room); ctx.children.broadcast_stance(&branch.root, branch.level); } diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index f17ec45..c6ab69c 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -466,6 +466,9 @@ pub struct ClientContext { leaf: distributed::Leaf, /// The children hanging from us, when the client serves any. pub(crate) children: children::Children, + /// The last `AcceptChildren` answer we gave the server, so the same + /// answer is not repeated: it is a standing state, not a heartbeat. + announced_accept_children: Option, /// The upload speed the server records for us, once it has told us. The /// child limit is derived from it. own_average_speed: Option, @@ -642,6 +645,7 @@ impl ClientContext { operations: None, leaf: distributed::Leaf::new(""), children: children::Children::default(), + announced_accept_children: None, own_average_speed: None, parent_min_speed: 0, parent_speed_ratio: 0, @@ -829,6 +833,16 @@ impl ClientContext { self.own_interests.clone() } + /// Take the `AcceptChildren` answer to send, or `None` when the server + /// already has this one. + pub fn accept_children_change(&mut self) -> Option { + let has_room = self.children.has_room(); + (self.announced_accept_children != Some(has_room)).then(|| { + self.announced_accept_children = Some(has_room); + has_room + }) + } + /// The upload speed the server records for us, if it has said. #[must_use] pub const fn own_average_speed(&self) -> Option { diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index 578aee6..57b6072 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -1253,8 +1253,16 @@ impl Client { /// (`AcceptChildren`, code 100). Sent whenever that answer changes: the /// server only offers us to peers while it is true. fn announce_child_capacity(client_context: &Arc>) { - let (sender, has_room) = match client_context.read_safe() { - Ok(ctx) => (ctx.server_sender.clone(), ctx.children.has_room()), + let (sender, has_room) = match client_context.write_safe() { + Ok(mut ctx) => { + // Only on a change: the server holds this as a standing + // state, and repeating it on every message we happen to + // receive would be noise. + let Some(has_room) = ctx.accept_children_change() else { + return; + }; + (ctx.server_sender.clone(), has_room) + } Err(_) => return, }; let Some(sender) = sender else { return }; diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs index d9d75fa..7506e66 100644 --- a/soulseek-rs-lib/src/client/tests.rs +++ b/soulseek-rs-lib/src/client/tests.rs @@ -1126,3 +1126,15 @@ fn leaving_a_room_drops_its_ticker_board() { "a board for a room we left is stale" ); } + +#[test] +fn the_server_is_told_our_child_capacity_only_when_it_changes() { + // AcceptChildren is a standing state on the server, not a heartbeat. + let mut ctx = ClientContext::new(); + assert_eq!( + ctx.accept_children_change(), + Some(false), + "the first answer is always worth sending" + ); + assert_eq!(ctx.accept_children_change(), None, "unchanged, so silent"); +} From 6f32a4e0902f1d860e80051abdc6e7f84058d8af Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 18:38:34 +0000 Subject: [PATCH 38/55] fix: review findings on the protocol work Correctness: - A child that stopped reading could freeze the whole client: the relay writes under the context lock and the socket was blocking, so one stalled peer took every download, upload and API call with it. Child writes now have a deadline, and a child that hits it loses its slot. - Relaying searches to children was gated by the leaf's answer budget, so a parent forwarded a fraction of the stream while advertising itself as healthy. The budget bounds our own share scan, which is what it was for; the tree's stream goes down whole. - A dropped client kept its listener port for the life of the process, so a retried login advertised an ephemeral port nobody was told about. The listener is now stopped and the operations thread let go. - Excluded phrases were compared against a lowercased path without being lowercased themselves, so a capitalised phrase never matched. They are normalised where they arrive. - Losing our own operatorship wiped the room's whole operator roster, though we are still a member who can see it. - A brokered ConnectToPeer of type D was logged and dropped; the peer is now told, since we never dial out to adopt a child. - A peer request made with no server connection returned Ok and parked forever; it says NotConnected, as its docs always claimed. - An empty interest was refused locally but still sent, leaving the server's list and ours permanently apart. Simplification: - Deleted the excluded-phrase helper nothing called, and the field doc that promised behaviour it never had. - The four roster handlers and four standing handlers were eight constructors carrying constants their own message code already implies: one newtype each, 100 lines less. - One place registers a search; the phrase list is borrowed, not cloned per relayed search. --- soulseek-rs-lib/src/actor/server_actor.rs | 19 ++- soulseek-rs-lib/src/client/connection.rs | 53 ++++++-- soulseek-rs-lib/src/client/mod.rs | 75 ++++++----- soulseek-rs-lib/src/client/operations.rs | 69 +++++++--- soulseek-rs-lib/src/client/rooms.rs | 12 +- soulseek-rs-lib/src/client/search.rs | 11 +- soulseek-rs-lib/src/client/social.rs | 24 +++- soulseek-rs-lib/src/client/tests.rs | 54 +++++++- .../src/message/server/private_rooms.rs | 121 +++--------------- soulseek-rs-lib/src/peer/listen.rs | 6 + soulseek-rs-lib/tests/e2e.rs | 44 +++++++ 11 files changed, 294 insertions(+), 194 deletions(-) diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index ec0e3c3..0e5e4f2 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -427,16 +427,14 @@ impl ServerActor { handlers.register_handler(CantConnectToPeerHandler); handlers.register_handler(RoomMembersHandler); handlers.register_handler(RoomOperatorsHandler); - handlers.register_handler(RoomRosterChangeHandler::member_added()); - handlers.register_handler(RoomRosterChangeHandler::member_removed()); - handlers.register_handler(RoomRosterChangeHandler::operator_added()); - handlers.register_handler(RoomRosterChangeHandler::operator_removed()); - handlers.register_handler(OwnRoomStandingHandler::membership_granted()); - handlers.register_handler(OwnRoomStandingHandler::membership_revoked()); - handlers - .register_handler(OwnRoomStandingHandler::operatorship_granted()); - handlers - .register_handler(OwnRoomStandingHandler::operatorship_revoked()); + // Members added/removed (134/135), operators added/removed (143/144). + for code in [134, 135, 143, 144] { + handlers.register_handler(RoomRosterChangeHandler(code)); + } + // Our own membership (139/140) and operatorship (145/146). + for code in [139, 140, 145, 146] { + handlers.register_handler(OwnRoomStandingHandler(code)); + } handlers.register_handler(CantCreateRoomHandler); handlers.register_handler(RoomTickersHandler); handlers.register_handler(RoomTickerAddedHandler); @@ -954,6 +952,7 @@ impl ServerActor { ) { if let Err(e) = self.client_channel.send(ClientOperation::IncomingSearch { + from_parent: false, username, token, query, diff --git a/soulseek-rs-lib/src/client/connection.rs b/soulseek-rs-lib/src/client/connection.rs index 12f9d43..01bffb7 100644 --- a/soulseek-rs-lib/src/client/connection.rs +++ b/soulseek-rs-lib/src/client/connection.rs @@ -1,9 +1,10 @@ use super::{ - Arc, Client, ClientContext, ClientOperation, ConnectionType, DownloadPeer, - Listen, Peer, PeerRegistry, Receiver, Result, RwLock, RwLockExt, Sender, - ServerActor, ServerMessage, Shares, SoulseekRs, TcpStream, error, info, - mpsc, thread, trace, warn, + Arc, AtomicBool, Client, ClientContext, ClientOperation, ConnectionType, + DownloadPeer, Listen, Peer, PeerRegistry, Receiver, Result, RwLock, + RwLockExt, Sender, ServerActor, ServerMessage, Shares, SoulseekRs, + TcpStream, error, info, mpsc, thread, trace, warn, }; +use std::sync::atomic::Ordering; /// Ceiling on the wait for a login verdict. Generous enough for a slow server /// and a retrying connect, short enough that an unattended caller ends. @@ -96,6 +97,7 @@ impl Client { } else { None }; + self.listener_stopped = Arc::new(AtomicBool::new(false)); let mut server_actor = ServerActor::new( self.address.clone(), @@ -118,9 +120,16 @@ impl Client { let client_sender = listen_sender; let context = self.context.clone(); let own_username = self.username.clone(); + let stopped = self.listener_stopped.clone(); thread::spawn(move || { - Listen::serve(&listener, client_sender, context, own_username); + Listen::serve( + &listener, + client_sender, + context, + own_username, + stopped, + ); }); } @@ -147,14 +156,26 @@ impl Client { if let Some(handle) = self.server_handle.take() { let _ = handle.stop(); } - let registry = self - .context - .read_safe() - .ok() - .and_then(|ctx| ctx.peer_registry.clone()); + let registry = match self.context.write_safe() { + Ok(mut ctx) => { + // Dropping our end of the operations channel is what lets its + // thread finish once the peers and the listener have gone. + ctx.operations = None; + ctx.peer_registry.clone() + } + Err(_) => None, + }; if let Some(registry) = registry { registry.stop_all(); } + // The listener thread is parked in accept(), so it only notices the + // flag when a connection arrives: dial it once ourselves. Without + // this the port stays bound for the life of the process, and the next + // client falls back to an ephemeral port nobody was told about. + self.listener_stopped.store(true, Ordering::Relaxed); + if let Some(port) = self.bound_port.take() { + let _ = TcpStream::connect(("127.0.0.1", port)); + } } /// Log in and wait for the server's verdict. @@ -355,7 +376,17 @@ impl Client { } } ConnectionType::D => { - error!("ConnectionType::D not implemented"); + // ponytail: we never dial out to adopt a child — children + // reach us through the listener. Tell the peer rather than + // leave it waiting; wire the outbound half if firewalled + // children turn out to matter. + if let (true, Some(token)) = (peer.brokered, peer.token) { + Self::report_cant_connect( + &client_context, + token, + &peer.username, + ); + } } } } diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index c6ab69c..a9ea8d7 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -315,6 +315,10 @@ pub enum ClientOperation { username: String, token: u32, query: String, + /// True when it came down the tree from our parent, which has + /// already passed it to our children and is subject to the answer + /// budget. A search the server relayed is neither. + from_parent: bool, }, /// A peer queued one of our shared files; `requester_key` is the registry /// key of the peer actor — the peer's username. @@ -555,8 +559,8 @@ pub struct ClientContext { privileged_users: HashSet, /// Seconds of our own privileges left (code 92), once we have asked. own_privileges: Option, - /// Phrases the server will not search for (code 160). A query carrying one - /// is refused locally rather than spent on the server. + /// Phrases the server excludes from the search network (code 160). Files + /// whose path carries one are left out of the replies we send. excluded_search_phrases: Vec, /// Peers waiting for one of our upload slots. upload_queue: Vec, @@ -779,15 +783,12 @@ impl ClientContext { members, granted, } => { - // Our own standing is rendered from the same rosters, so a - // revocation must drop the room rather than leave a roster - // we are no longer part of looking current. - if !granted { - // Losing membership takes the operator roster with it: - // a room we cannot enter has no roster worth showing. - if *members { - self.private_room_members.remove(room); - } + // Losing membership takes both rosters with it: a room we + // cannot enter has none worth showing. Losing operatorship + // leaves us a member who still sees them, and the server + // narrates that roster change separately (code 144). + if !granted && *members { + self.private_room_members.remove(room); self.private_room_operators.remove(room); } } @@ -802,29 +803,45 @@ impl ClientContext { /// Interests are matched by the server case-insensitively, and Nicotine+ /// lowercases them before sending; do the same so two spellings of one /// interest cannot both be held. - pub fn add_own_interest(&mut self, item: &str, liked: bool) -> String { + /// Returns the stored spelling, or `None` for an empty item — which is + /// no interest, and must not be sent to the server either. + pub fn add_own_interest( + &mut self, + item: &str, + liked: bool, + ) -> Option { let item = item.trim().to_lowercase(); + if item.is_empty() { + return None; + } let list = if liked { &mut self.own_interests.likes } else { &mut self.own_interests.hates }; - if !item.is_empty() && !list.contains(&item) { + if !list.contains(&item) { list.push(item.clone()); } - item + Some(item) } - /// Forget one of our own interests. - pub fn remove_own_interest(&mut self, item: &str, liked: bool) -> String { + /// Forget one of our own interests. `None` for an empty item, as above. + pub fn remove_own_interest( + &mut self, + item: &str, + liked: bool, + ) -> Option { let item = item.trim().to_lowercase(); + if item.is_empty() { + return None; + } let list = if liked { &mut self.own_interests.likes } else { &mut self.own_interests.hates }; list.retain(|held| held != &item); - item + Some(item) } /// What we like and hate, as last set. @@ -1042,9 +1059,11 @@ impl ClientContext { self.user_interests.get(username).cloned() } - /// Record the phrases the server refuses to search for (code 160). + /// Record the phrases the server excludes (code 160), lowercased here so + /// the per-file check is a plain `contains` on a lowercased path. pub fn set_excluded_search_phrases(&mut self, phrases: Vec) { - self.excluded_search_phrases = phrases; + self.excluded_search_phrases = + phrases.iter().map(|p| p.to_lowercase()).collect(); } /// The phrases the server refuses to search for. @@ -1053,20 +1072,6 @@ impl ClientContext { self.excluded_search_phrases.clone() } - /// The first excluded phrase `query` contains, if any. Matching is - /// case-insensitive and on substrings, which is how the server applies its - /// own filter. - #[must_use] - pub fn excluded_phrase_in(&self, query: &str) -> Option { - let haystack = query.to_lowercase(); - self.excluded_search_phrases - .iter() - .find(|phrase| { - !phrase.is_empty() && haystack.contains(&phrase.to_lowercase()) - }) - .cloned() - } - /// Forget any interests cached for `username`, so a poll after a fresh /// request cannot mistake the previous answer for this one. pub fn invalidate_user_interests(&mut self, username: &str) { @@ -1383,6 +1388,9 @@ pub struct Client { server_handle: Option>, context: Arc>, session: SessionWatch, + /// Tells the peer listener to stop, so a disconnected client releases the + /// port it bound. + listener_stopped: Arc, } impl Drop for Client { @@ -1416,6 +1424,7 @@ impl Client { shared_directories: settings.shared_directories, server_handle: None, session: SessionWatch::default(), + listener_stopped: Arc::new(AtomicBool::new(false)), } } diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index 57b6072..7781709 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -16,6 +16,11 @@ const CONNECT_SWEEP_INTERVAL: Duration = Duration::from_secs(1); /// long queue is not pestered. const QUEUE_POSITION_INTERVAL: Duration = Duration::from_secs(300); +/// How long a write to a child may block. The relay runs under the client +/// context lock, so an unbounded write would freeze every other operation +/// behind one child that stopped reading. +const CHILD_WRITE_TIMEOUT: Duration = Duration::from_secs(5); + impl Client { pub(crate) fn listen_to_client_operations( reader: Receiver, @@ -582,30 +587,48 @@ impl Client { username, token, query, + from_parent, } => { // Every search we receive comes from the tree: from // our parent, or from the server acting as one. That // is what qualifies us to carry children at all — // Nicotine+ refuses them until something feeds it — // and passing the search down is the whole duty. - let capacity_changed = match client_context.write_safe() + let (capacity_changed, answer) = match client_context + .write_safe() { Ok(mut ctx) => { let was_fed = ctx.children.is_fed(); let before = ctx.children.len(); ctx.set_fed_by(true); - ctx.children - .broadcast_search(&username, token, &query); + if !from_parent { + // A parent's search was passed down where + // it arrived; this is the server's. + ctx.children.broadcast_search( + &username, token, &query, + ); + } + // Answering costs a scan of everything we + // share, so the budget applies to searches + // from the tree. The server's own are few. + let answer = !from_parent + || ctx.leaf.admit_search(Instant::now()); // A child whose socket has gone is dropped by // that write, which frees a slot the server // should hear about. - !was_fed || ctx.children.len() != before + ( + !was_fed || ctx.children.len() != before, + answer, + ) } - Err(_) => false, + Err(_) => (false, false), }; if capacity_changed { Self::announce_child_capacity(&client_context); } + if !answer { + continue; + } // Don't answer our own distributed search. if username == own_username { @@ -620,7 +643,7 @@ impl Client { ctx.has_free_upload_slot(), ctx.last_upload_speed, ctx.upload_queue.len() as u32, - &ctx.excluded_search_phrases(), + &ctx.excluded_search_phrases, ), Err(e) => { error!("[client] IncomingSearch read: {}", e); @@ -786,15 +809,22 @@ impl Client { &mut ctx, &branch, true, ); } - if ctx.leaf.is_parent(&parent, link) - && ctx.leaf.admit_search(Instant::now()) - && let Some(ops) = &ctx.operations - { - let _ = ops.send(ClientOperation::IncomingSearch { - username, - token, - query, - }); + if ctx.leaf.is_parent(&parent, link) { + // The tree's stream is not ours to throttle: every + // search goes down to our children. The budget + // below bounds only the share scan we do for our + // own reply. + ctx.children + .broadcast_search(&username, token, &query); + if let Some(ops) = &ctx.operations { + let _ = + ops.send(ClientOperation::IncomingSearch { + username, + token, + query, + from_parent: true, + }); + } } } ClientOperation::ParentClosed { parent, link } => { @@ -893,7 +923,14 @@ impl Client { if !ctx.children.has_room() { continue; } - if stream.set_nodelay(true).is_err() { + // A child that stops reading must not wedge us: the + // relay writes under the context lock, so the write + // is bounded and a child that hits it loses its slot. + if stream.set_nodelay(true).is_err() + || stream + .set_write_timeout(Some(CHILD_WRITE_TIMEOUT)) + .is_err() + { continue; } if username == own_username { diff --git a/soulseek-rs-lib/src/client/rooms.rs b/soulseek-rs-lib/src/client/rooms.rs index 8fee72c..d11da0c 100644 --- a/soulseek-rs-lib/src/client/rooms.rs +++ b/soulseek-rs-lib/src/client/rooms.rs @@ -263,13 +263,17 @@ impl Client { .send_to_peer(username, PeerMessage::SendMessage(request)); } } else { + // Without a server there is nobody to resolve the peer's address, + // so the request would sit in the queue forever: say so instead. + let handle = self + .server_handle + .as_ref() + .ok_or(SoulseekRs::NotConnected)?; self.context .write_safe()? .queue_peer_message(username, request); - if let Some(handle) = &self.server_handle { - let _ = handle - .send(ServerMessage::GetPeerAddress(username.to_string())); - } + let _ = handle + .send(ServerMessage::GetPeerAddress(username.to_string())); } Ok(()) } diff --git a/soulseek-rs-lib/src/client/search.rs b/soulseek-rs-lib/src/client/search.rs index a8a2b18..3b44d20 100644 --- a/soulseek-rs-lib/src/client/search.rs +++ b/soulseek-rs-lib/src/client/search.rs @@ -59,19 +59,10 @@ impl Client { fn send_search(&self, query: &str, wishlist: bool) -> Result<()> { info!("Searching for {}", query); + let token = self.register_search(query)?; let Some(handle) = &self.server_handle else { return Err(SoulseekRs::NotConnected); }; - let token = next_search_token(); - - self.context.write_safe()?.searches.insert( - query.to_string(), - Search { - token, - results: Vec::new(), - }, - ); - let query = query.to_string(); let _ = handle.send(if wishlist { ServerMessage::WishlistSearch { token, query } diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs index 10d235c..89b1322 100644 --- a/soulseek-rs-lib/src/client/social.rs +++ b/soulseek-rs-lib/src/client/social.rs @@ -301,7 +301,11 @@ impl Client { /// # Errors /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. pub fn add_interest(&self, item: &str) -> Result<()> { - let item = self.context.write_safe()?.add_own_interest(item, true); + let Some(item) = + self.context.write_safe()?.add_own_interest(item, true) + else { + return Ok(()); // nothing to like or unlike + }; self.send_server_message(MessageFactory::build_add_thing_i_like(&item)) } @@ -310,7 +314,11 @@ impl Client { /// # Errors /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. pub fn remove_interest(&self, item: &str) -> Result<()> { - let item = self.context.write_safe()?.remove_own_interest(item, true); + let Some(item) = + self.context.write_safe()?.remove_own_interest(item, true) + else { + return Ok(()); // nothing to like or unlike + }; self.send_server_message(MessageFactory::build_remove_thing_i_like( &item, )) @@ -321,7 +329,11 @@ impl Client { /// # Errors /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. pub fn add_dislike(&self, item: &str) -> Result<()> { - let item = self.context.write_safe()?.add_own_interest(item, false); + let Some(item) = + self.context.write_safe()?.add_own_interest(item, false) + else { + return Ok(()); // nothing to like or unlike + }; self.send_server_message(MessageFactory::build_add_thing_i_hate(&item)) } @@ -330,7 +342,11 @@ impl Client { /// # Errors /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. pub fn remove_dislike(&self, item: &str) -> Result<()> { - let item = self.context.write_safe()?.remove_own_interest(item, false); + let Some(item) = + self.context.write_safe()?.remove_own_interest(item, false) + else { + return Ok(()); // nothing to like or unlike + }; self.send_server_message(MessageFactory::build_remove_thing_i_hate( &item, )) diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs index 7506e66..6be8609 100644 --- a/soulseek-rs-lib/src/client/tests.rs +++ b/soulseek-rs-lib/src/client/tests.rs @@ -1015,7 +1015,10 @@ fn revoked_membership_drops_the_room_we_can_no_longer_see() { } #[test] -fn losing_operatorship_keeps_the_membership_it_does_not_touch() { +fn losing_operatorship_keeps_the_roster_we_can_still_see() { + // Demotion does not blind us: we are still a member, and the room's other + // operators are still ours to show. The server narrates our own removal + // from that roster separately (code 144). let mut ctx = ClientContext::new(); ctx.apply_room_event(RoomEvent::PrivateMembers { room: "club".into(), @@ -1023,7 +1026,7 @@ fn losing_operatorship_keeps_the_membership_it_does_not_touch() { }); ctx.apply_room_event(RoomEvent::PrivateOperators { room: "club".into(), - users: vec!["alice".into()], + users: vec!["alice".into(), "bob".into()], }); ctx.apply_room_event(RoomEvent::OwnStandingChanged { @@ -1032,7 +1035,23 @@ fn losing_operatorship_keeps_the_membership_it_does_not_touch() { granted: false, }); assert_eq!(ctx.private_room_members("club"), vec!["alice".to_string()]); - assert!(ctx.private_room_operators("club").is_empty()); + assert_eq!( + ctx.private_room_operators("club"), + vec!["alice".to_string(), "bob".to_string()], + "the other operators are still there to show" + ); + + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "alice".into(), + members: false, + added: false, + }); + assert_eq!( + ctx.private_room_operators("club"), + vec!["bob".to_string()], + "only the demotion the server narrates removes us" + ); } #[test] @@ -1082,6 +1101,25 @@ fn a_file_the_server_excludes_is_left_out_of_a_reply() { "an exclusion the path does not carry changes nothing" ); + // The phrases are lowercased where they arrive, so a server that sends + // one capitalised still matches a path. + let mut ctx = ClientContext::new(); + ctx.set_excluded_search_phrases(vec!["SPAM".to_string()]); + assert!( + build_search_response( + &shares, + "me", + 1, + "xyzzy", + true, + 0, + 0, + &ctx.excluded_search_phrases(), + ) + .is_none(), + "a capitalised phrase from the server still withholds the file" + ); + let _ = std::fs::remove_dir_all(&dir); } @@ -1091,7 +1129,15 @@ fn our_own_interests_are_kept_lowercased_and_deduplicated() { // the session ends, so they are held here in one spelling to be sent // again next login. let mut ctx = ClientContext::new(); - assert_eq!(ctx.add_own_interest(" Krautrock ", true), "krautrock"); + assert_eq!( + ctx.add_own_interest(" Krautrock ", true).as_deref(), + Some("krautrock") + ); + assert_eq!( + ctx.add_own_interest(" ", true), + None, + "an empty interest is not stored, and is not sent either" + ); ctx.add_own_interest("KRAUTROCK", true); ctx.add_own_interest("Muzak", false); diff --git a/soulseek-rs-lib/src/message/server/private_rooms.rs b/soulseek-rs-lib/src/message/server/private_rooms.rs index 18d8aa6..7936014 100644 --- a/soulseek-rs-lib/src/message/server/private_rooms.rs +++ b/soulseek-rs-lib/src/message/server/private_rooms.rs @@ -57,55 +57,14 @@ impl MessageHandler for RoomOperatorsHandler { } } -/// One user added to (`added`) or removed from a private room's roster: -/// members (134/135) or operators (143/144). -pub struct RoomRosterChangeHandler { - code: u32, - members: bool, - added: bool, -} - -impl RoomRosterChangeHandler { - #[must_use] - pub const fn member_added() -> Self { - Self { - code: 134, - members: true, - added: true, - } - } - - #[must_use] - pub const fn member_removed() -> Self { - Self { - code: 135, - members: true, - added: false, - } - } - - #[must_use] - pub const fn operator_added() -> Self { - Self { - code: 143, - members: false, - added: true, - } - } - - #[must_use] - pub const fn operator_removed() -> Self { - Self { - code: 144, - members: false, - added: false, - } - } -} +/// One user added to or removed from a private room's roster, by the code it +/// arrived under: members added (134) or removed (135), operators added (143) +/// or removed (144). +pub struct RoomRosterChangeHandler(pub u32); impl MessageHandler for RoomRosterChangeHandler { fn get_code(&self) -> u32 { - self.code + self.0 } fn handle(&self, message: &mut Message, sender: Sender) { @@ -114,69 +73,27 @@ impl MessageHandler for RoomRosterChangeHandler { let _ = sender.send(ServerMessage::PrivateRoomRosterChanged { room, username, - members: self.members, - added: self.added, + members: matches!(self.0, 134 | 135), + added: matches!(self.0, 134 | 143), }); } } /// Our own standing in a private room changed: membership granted (139) or /// revoked (140), operatorship granted (145) or revoked (146). -pub struct OwnRoomStandingHandler { - code: u32, - members: bool, - granted: bool, -} - -impl OwnRoomStandingHandler { - #[must_use] - pub const fn membership_granted() -> Self { - Self { - code: 139, - members: true, - granted: true, - } - } - - #[must_use] - pub const fn membership_revoked() -> Self { - Self { - code: 140, - members: true, - granted: false, - } - } - - #[must_use] - pub const fn operatorship_granted() -> Self { - Self { - code: 145, - members: false, - granted: true, - } - } - - #[must_use] - pub const fn operatorship_revoked() -> Self { - Self { - code: 146, - members: false, - granted: false, - } - } -} +pub struct OwnRoomStandingHandler(pub u32); impl MessageHandler for OwnRoomStandingHandler { fn get_code(&self) -> u32 { - self.code + self.0 } fn handle(&self, message: &mut Message, sender: Sender) { let room = message.read_string(); let _ = sender.send(ServerMessage::OwnRoomStandingChanged { room, - members: self.members, - granted: self.granted, + members: matches!(self.0, 139 | 140), + granted: matches!(self.0, 139 | 145), }); } } @@ -243,10 +160,10 @@ mod tests { #[test] fn each_roster_change_reports_which_roster_and_which_way() { for (handler, members, added) in [ - (RoomRosterChangeHandler::member_added(), true, true), - (RoomRosterChangeHandler::member_removed(), true, false), - (RoomRosterChangeHandler::operator_added(), false, true), - (RoomRosterChangeHandler::operator_removed(), false, false), + (RoomRosterChangeHandler(134), true, true), + (RoomRosterChangeHandler(135), true, false), + (RoomRosterChangeHandler(143), false, true), + (RoomRosterChangeHandler(144), false, false), ] { let (tx, rx) = std::sync::mpsc::channel(); let mut message = framed(|m| { @@ -274,10 +191,10 @@ mod tests { #[test] fn our_own_standing_reports_the_room_and_the_change() { for (handler, members, granted) in [ - (OwnRoomStandingHandler::membership_granted(), true, true), - (OwnRoomStandingHandler::membership_revoked(), true, false), - (OwnRoomStandingHandler::operatorship_granted(), false, true), - (OwnRoomStandingHandler::operatorship_revoked(), false, false), + (OwnRoomStandingHandler(139), true, true), + (OwnRoomStandingHandler(140), true, false), + (OwnRoomStandingHandler(145), false, true), + (OwnRoomStandingHandler(146), false, false), ] { let (tx, rx) = std::sync::mpsc::channel(); let mut message = framed(|m| { diff --git a/soulseek-rs-lib/src/peer/listen.rs b/soulseek-rs-lib/src/peer/listen.rs index 2726a8a..beb1f64 100644 --- a/soulseek-rs-lib/src/peer/listen.rs +++ b/soulseek-rs-lib/src/peer/listen.rs @@ -1,5 +1,6 @@ use std::io; use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::Sender; use std::sync::{Arc, RwLock}; use std::thread; @@ -422,6 +423,7 @@ impl Listen { client_sender: Sender, client_context: Arc>, own_username: String, + stopped: Arc, ) { info!("[listener] listening on {:?}", listener.local_addr()); @@ -433,6 +435,10 @@ impl Listen { let handshakes = Arc::new(Semaphore::new(MAX_HANDSHAKES)); for stream in listener.incoming() { + if stopped.load(Ordering::Relaxed) { + debug!("[listener] stopping, releasing the port"); + return; + } let Ok(stream) = stream else { error!( "[listener] Failed to accept connection: {}", diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index aa72f6b..800f3b6 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -5452,3 +5452,47 @@ fn privileges_can_be_handed_to_another_user() { "the gifted privileges should show up as time of our own" ); } + +#[test] +fn a_disconnected_client_gives_its_listener_port_back() { + // A client that goes out of scope must release the port it bound: the + // next one has to be able to advertise the port it was configured with, + // not fall back to an ephemeral one nobody was told about. No server is + // needed — binding the listener happens in connect(), before the dial. + let port = free_port().expect("a port to hold"); + let settings = |port: u16| ClientSettings { + username: "e2e_port_release".to_string(), + password: "pw".to_string(), + server_address: PeerAddress::new("127.0.0.1".to_string(), 1), + enable_listen: true, + listen_port: port, + shared_directories: Vec::new(), + accept_children: false, + version: ClientVersion::default(), + }; + + let mut first = Client::with_settings(settings(port)); + first.connect().expect("bind the listener"); + assert_eq!(first.listen_port(), Some(port)); + drop(first); + + // The listener thread notices the stop the moment it is woken, which the + // disconnect does by dialling it; give it a beat to unwind. + let deadline = Instant::now() + Duration::from_secs(5); + let mut second_port = None; + while Instant::now() < deadline && second_port != Some(port) { + let mut second = Client::with_settings(settings(port)); + second.connect().expect("bind again"); + second_port = second.listen_port(); + if second_port == Some(port) { + break; + } + drop(second); + std::thread::sleep(Duration::from_millis(200)); + } + assert_eq!( + second_port, + Some(port), + "the port should be free again once the first client is dropped" + ); +} From 9bca573bc0a5f9d27c4494e0153622afffc1dfc7 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 19:35:58 +0000 Subject: [PATCH 39/55] fix(uploads): finish a transfer when the file lands, not when the peer closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stress harness found this: 38 completed uploads logged as errors, and a run that took 30s to move what should take 6. After the last byte we waited up to 30 seconds for the downloader to close the file connection. Soulseek does leave that close to the downloader, but a peer that simply leaves the socket open — or whose FIN a NAT swallows — is not a failure: it has every byte. We were marking those uploads Failed, skipping the SendUploadSpeed report that sets our advertised speed, and holding the upload slot the next peer was queued for. Cutting the wait to five seconds was not enough, and stress said so: with two slots and sixty-four queued leechers, five seconds apiece is a hundred and sixty seconds of waiting, and ten uploads never got served inside the run. The slot has to be free the moment the file is delivered, so the wait is gone — Nicotine+ finishes the transfer there too and closes from its own side. That profile went from 146s with ten uploads lost to 9.7s with none; the default profile from 30.7s and 25 MiB/s to 5.8s and 133 MiB/s. Also: a decompression ceiling on peer payloads, the numbers Nicotine+ uses (128 MiB for a search or folder reply, 2 GiB for a browse listing). Deflate reaches roughly 1000:1, so a few kilobytes from an unknown peer could otherwise expand until the process died. And the stress harness counted threads with `ps -M`, which is macOS spelling: on Linux it returns one unrelated line, so the guard against thread explosions has been reporting a single thread on CI all along. /proc/self/status has the real number. --- soulseek-rs-lib/examples/stress.rs | 10 +++ .../src/message/peer/folder_contents.rs | 2 +- .../src/message/peer/shared_file_list.rs | 15 ++-- soulseek-rs-lib/src/peer/upload_peer.rs | 70 +++++----------- soulseek-rs-lib/src/utils/zlib.rs | 83 +++++++++++++++++-- 5 files changed, 118 insertions(+), 62 deletions(-) diff --git a/soulseek-rs-lib/examples/stress.rs b/soulseek-rs-lib/examples/stress.rs index 81c6a8f..31d086e 100644 --- a/soulseek-rs-lib/examples/stress.rs +++ b/soulseek-rs-lib/examples/stress.rs @@ -895,6 +895,16 @@ fn write_baseline(path: &Path, score: &Score, cfg: &Config, m: &Metrics) { /// Live thread count for this process, so the harness can show whether load is /// being absorbed or simply turned into threads. fn thread_count() -> usize { + // Linux keeps the count in /proc; `ps -M` is macOS's spelling and returns + // a single unrelated line there, which read as one thread all along and + // made this guard against thread explosions useless on CI. + if let Ok(status) = std::fs::read_to_string("/proc/self/status") { + return status + .lines() + .find_map(|line| line.strip_prefix("Threads:")) + .and_then(|count| count.trim().parse().ok()) + .unwrap_or(0); + } Command::new("ps") .args(["-M", &std::process::id().to_string()]) .output() diff --git a/soulseek-rs-lib/src/message/peer/folder_contents.rs b/soulseek-rs-lib/src/message/peer/folder_contents.rs index 893554c..48a4bf9 100644 --- a/soulseek-rs-lib/src/message/peer/folder_contents.rs +++ b/soulseek-rs-lib/src/message/peer/folder_contents.rs @@ -79,7 +79,7 @@ pub fn build_folder_contents( pub fn parse_folder_contents( message: &mut Message, ) -> Option<(u32, String, Vec)> { - let mut body = decompress_body(message)?; + let mut body = decompress_body(message, crate::utils::zlib::MAX_INFLATED)?; let token = body.read_int32(); let folder = body.read_string(); Some((token, folder, read_directories(&mut body))) diff --git a/soulseek-rs-lib/src/message/peer/shared_file_list.rs b/soulseek-rs-lib/src/message/peer/shared_file_list.rs index cc60817..60084e5 100644 --- a/soulseek-rs-lib/src/message/peer/shared_file_list.rs +++ b/soulseek-rs-lib/src/message/peer/shared_file_list.rs @@ -4,7 +4,7 @@ use crate::message::{Message, MessageHandler}; use crate::peer::PeerMessage; -use crate::utils::zlib::{deflate, inflate}; +use crate::utils::zlib::{MAX_INFLATED_LISTING, deflate, inflate_limited}; use std::sync::mpsc::Sender; /// One file in a shared directory: its basename, size and the `(code, value)` @@ -68,7 +68,8 @@ pub fn build_shared_file_list(dirs: &[SharedDirectory]) -> Message { /// Returns an empty listing if the payload is malformed. #[must_use] pub fn parse_shared_file_list(message: &mut Message) -> Vec { - decompress_body(message) + // A whole share listing is the one payload that legitimately runs large. + decompress_body(message, MAX_INFLATED_LISTING) .map_or_else(Vec::new, |mut body| read_directories(&mut body)) } @@ -135,11 +136,15 @@ pub fn read_directories(body: &mut Message) -> Vec { dirs } -/// Inflate a compressed peer payload positioned at the blob. -pub fn decompress_body(message: &mut Message) -> Option { +/// Inflate a compressed peer payload positioned at the blob, refusing one +/// that expands past `max_out` — a peer we have never met chose these bytes. +pub fn decompress_body( + message: &mut Message, + max_out: usize, +) -> Option { let pointer = message.get_pointer(); let size = message.get_size(); - inflate(&message.get_slice(pointer, size)) + inflate_limited(&message.get_slice(pointer, size), max_out) .ok() .map(Message::new_with_data) } diff --git a/soulseek-rs-lib/src/peer/upload_peer.rs b/soulseek-rs-lib/src/peer/upload_peer.rs index 36e8978..b999833 100644 --- a/soulseek-rs-lib/src/peer/upload_peer.rs +++ b/soulseek-rs-lib/src/peer/upload_peer.rs @@ -5,7 +5,7 @@ use std::io::{self, Read, Seek, SeekFrom, Write}; use std::net::{TcpStream, ToSocketAddrs}; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::time::{Duration, Instant}; +use std::time::Duration; use crate::message::server::MessageFactory; use crate::peer::ConnectionType; @@ -99,41 +99,11 @@ pub fn serve_file( } stream.flush()?; - // Soulseek assigns closing the file-transfer connection to the downloader - // after it has read exactly the advertised number of bytes. Poll while - // waiting for that EOF so cancelling a finished-but-unacknowledged upload - // remains prompt rather than waiting for the full socket timeout. - stream.set_read_timeout(Some(Duration::from_millis(200)))?; - let close_deadline = Instant::now() + Duration::from_secs(30); - let mut unexpected = [0u8; 1]; - loop { - if cancel.load(Ordering::Relaxed) { - return Err(io::ErrorKind::Interrupted.into()); - } - match stream.read(&mut unexpected) { - Ok(0) => break, - Ok(_) => { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "unexpected data after download offset", - )); - } - Err(error) - if matches!( - error.kind(), - io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut - ) => - { - if Instant::now() >= close_deadline { - return Err(io::Error::new( - io::ErrorKind::TimedOut, - "downloader did not close the file connection", - )); - } - } - Err(error) => return Err(error), - } - } + // The file is delivered. Soulseek leaves closing to the downloader, but + // waiting for that costs the upload slot the next peer is queued for — + // and a peer that leaves the socket open, or whose FIN a NAT swallows, + // would hold it for the whole wait. Nicotine+ finishes the transfer at + // delivery for the same reason; the close below is ours to make. trace!("[upload] served {} to {}:{}", path.display(), host, port); Ok(streamed) } @@ -285,14 +255,16 @@ mod tests { } #[test] - fn serve_file_cancels_while_waiting_for_the_downloader_to_close() { - let content: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); - let path = scratch_file("cancel-close-wait", &content); + fn a_downloader_that_never_closes_still_leaves_the_upload_delivered() { + // The file is on the wire; whether the peer closes, or its FIN is + // eaten by a NAT, it has had every byte. Waiting for a close that may + // never come used to fail the transfer and hold its upload slot, + // which is what starved a queue of everyone behind it. + let content: Vec = (0..2048u32).map(|i| (i % 251) as u8).collect(); + let path = scratch_file("no-close", &content); let dir = path.parent().unwrap().to_path_buf(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = u32::from(listener.local_addr().unwrap().port()); - let cancel = Arc::new(AtomicBool::new(false)); - let cancel_flag = cancel.clone(); let (result_tx, result_rx) = mpsc::channel(); let uploader = std::thread::spawn(move || { @@ -300,10 +272,10 @@ mod tests { "127.0.0.1", port, "me", - 782, + 783, &path, &AtomicU64::new(0), - &cancel_flag, + &AtomicBool::new(false), ); let _ = result_tx.send(result); }); @@ -313,12 +285,12 @@ mod tests { stream.read_exact(&mut received).unwrap(); assert_eq!(received, content); - cancel.store(true, Ordering::Relaxed); - let error = result_rx - .recv_timeout(Duration::from_secs(2)) - .expect("cancellation must wake the close wait") - .unwrap_err(); - assert_eq!(error.kind(), std::io::ErrorKind::Interrupted); + // Hold the socket open past the grace period and say nothing. + let served = result_rx + .recv_timeout(Duration::from_secs(5)) + .expect("delivery must finish the upload, not a wait for a close") + .expect("a delivered file is a finished upload"); + assert_eq!(served, content.len() as u64); drop(stream); let _ = uploader.join(); diff --git a/soulseek-rs-lib/src/utils/zlib.rs b/soulseek-rs-lib/src/utils/zlib.rs index 46a6bc8..bace9ae 100644 --- a/soulseek-rs-lib/src/utils/zlib.rs +++ b/soulseek-rs-lib/src/utils/zlib.rs @@ -59,8 +59,33 @@ impl BitReader { use crate::error::{Result, SoulseekRs}; +/// The most a peer payload may inflate to unless the caller says otherwise. +/// +/// Nicotine+ passes this ceiling to its decompressor for a search or folder +/// reply, and a larger one only for a browse listing. +pub const MAX_INFLATED: usize = 128 * 1024 * 1024; + +/// The ceiling for a whole browse listing, which a large sharer legitimately +/// fills. The same number Nicotine+ allows. +pub const MAX_INFLATED_LISTING: usize = 2 * 1024 * 1024 * 1024; + +/// What a payload that outgrows its ceiling is refused with. +const TOO_BIG: &str = "compressed payload expands past its limit"; + /// Inflate a zlib stream, checking its adler32 trailer. +/// +/// Bounded by [`MAX_INFLATED`]; use [`inflate_limited`] for a payload with a +/// different ceiling. pub fn inflate(input: &[u8]) -> Result> { + inflate_limited(input, MAX_INFLATED) +} + +/// Inflate a zlib stream, refusing to grow the output past `max_out`. +/// +/// The ceiling is the point: deflate reaches roughly 1000:1, so a few +/// kilobytes from a peer we have never met can otherwise expand into +/// gigabytes and take the process with it. +pub fn inflate_limited(input: &[u8], max_out: usize) -> Result> { let mut r = BitReader::new(input.to_vec()); let cmf = r.read_byte()?; let cm = cmf & 15; // Compression method @@ -84,7 +109,8 @@ pub fn inflate(input: &[u8]) -> Result> { "preset dictionary not supported".to_string(), )); } - let out = inflate_blocks(&mut r).map_err(SoulseekRs::CompressionError)?; + let out = inflate_blocks(&mut r, max_out) + .map_err(SoulseekRs::CompressionError)?; // `read_bytes` assembles little-endian; the zlib trailer is big-endian. if r.read_bytes(4)?.swap_bytes() != adler32(&out) { return Err(SoulseekRs::CompressionError( @@ -105,18 +131,24 @@ fn adler32(data: &[u8]) -> u32 { (b << 16) | a } -fn inflate_blocks(r: &mut BitReader) -> std::result::Result, String> { +fn inflate_blocks( + r: &mut BitReader, + max_out: usize, +) -> std::result::Result, String> { let mut bfinal = 0; let mut out = Vec::new(); while bfinal == 0 { bfinal = r.read_bit()?; let btype = r.read_bits(2)?; match btype { - 0 => inflate_block_no_compression(r, &mut out)?, - 1 => inflate_block_fixed(r, &mut out)?, - 2 => inflate_block_dynamic(r, &mut out)?, + 0 => inflate_block_no_compression(r, &mut out, max_out)?, + 1 => inflate_block_fixed(r, &mut out, max_out)?, + 2 => inflate_block_dynamic(r, &mut out, max_out)?, _ => return Err("invalid BTYPE".to_string()), } + if out.len() > max_out { + return Err(TOO_BIG.to_string()); + } } Ok(out) } @@ -124,9 +156,13 @@ fn inflate_blocks(r: &mut BitReader) -> std::result::Result, String> { fn inflate_block_no_compression( r: &mut BitReader, o: &mut Vec, + max_out: usize, ) -> std::result::Result<(), String> { let len = r.read_bytes(2)?; let _nlen = r.read_bytes(2)?; + if o.len().saturating_add(len as usize) > max_out { + return Err(TOO_BIG.to_string()); + } for _ in 0..len { o.push(r.read_byte()?); } @@ -214,8 +250,14 @@ fn inflate_block_data( literal_length_tree: &HuffmanTree, distance_tree: &HuffmanTree, out: &mut Vec, + max_out: usize, ) -> std::result::Result<(), String> { loop { + // Checked per symbol, not per block: a single block can emit output + // without bound, which is exactly how a bomb is built. + if out.len() > max_out { + return Err(TOO_BIG.to_string()); + } let sym = decode_symbol(r, literal_length_tree)?; if sym <= 255 { // Literal byte @@ -347,14 +389,16 @@ fn decode_trees( fn inflate_block_dynamic( r: &mut BitReader, o: &mut Vec, + max_out: usize, ) -> std::result::Result<(), String> { let (literal_length_tree, distance_tree) = decode_trees(r)?; - inflate_block_data(r, &literal_length_tree, &distance_tree, o) + inflate_block_data(r, &literal_length_tree, &distance_tree, o, max_out) } fn inflate_block_fixed( r: &mut BitReader, o: &mut Vec, + max_out: usize, ) -> std::result::Result<(), String> { let mut bl = Vec::new(); bl.extend(vec![8; 144]); // 0-143: 8 bits @@ -369,7 +413,7 @@ fn inflate_block_fixed( let distance_alphabet: Vec = (0..30).collect(); let distance_tree = bl_list_to_tree(&bl_dist, &distance_alphabet); - inflate_block_data(r, &literal_length_tree, &distance_tree, o) + inflate_block_data(r, &literal_length_tree, &distance_tree, o, max_out) } struct BitWriter { @@ -533,6 +577,31 @@ mod tests { use super::*; + #[test] + fn a_payload_that_expands_past_its_ceiling_is_refused() { + // Deflate reaches ~1000:1, so a few kilobytes from an unknown peer can + // become gigabytes. The ceiling is what stops that being our problem. + let bomb = deflate(&vec![0u8; 1024 * 1024]); + assert!( + bomb.len() < 8 * 1024, + "the test needs a payload that expands hugely, got {} bytes", + bomb.len() + ); + + let error = inflate_limited(&bomb, 64 * 1024) + .expect_err("a payload past the ceiling must be refused"); + assert!( + format!("{error}").contains("expands past its limit"), + "unexpected error: {error}" + ); + + // The same bytes are fine when they fit. + assert_eq!( + inflate_limited(&bomb, 2 * 1024 * 1024).unwrap().len(), + 1024 * 1024 + ); + } + #[test] fn a_corrupt_adler32_trailer_is_rejected() { let mut stream = deflate(b"the bytes a peer sent"); From cd580e5173620dfc84f76f84c87485867dc1f4c2 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 19:45:06 +0000 Subject: [PATCH 40/55] docs: lead with the protocol coverage, and drop a stale upload assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message-set coverage is the crate's strongest claim, so say it where people look first: the README features list and About, the website home and community pages, each pointing at the coverage table. The table now carries a section naming what the message set does not reach, so the claim has something behind it rather than standing on its own. The upload streaming test still asserted the uploader was blocked waiting for the downloader's close — the wait that came out when it started costing the upload slot. It passed only while the thread had yet to be scheduled, and failed whenever it had. --- README.md | 11 +++++++++++ docs/protocol-coverage.md | 17 +++++++++++++++++ soulseek-rs-lib/src/peer/upload_peer.rs | 4 ---- website/src/pages/community.tsx | 6 ++++-- website/src/pages/home.tsx | 3 ++- 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e1f550f..cb5d8f3 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ enthusiasts to share niche music. This repository is that client plus ## Features +- **Full protocol coverage**: 100% of the modern Soulseek protocol's message + set — every code the reference clients do not mark obsolete — implemented in + `soulseek-rs-lib` and driven end to end against a real server, not a mock. + See the [coverage table](docs/protocol-coverage.md) - **Search & download**: queue from the TUI or fetch in one command with `get`; filter by bitrate, size, file type, free slots, or terms to exclude - **Wishlist**: `wish add` what nobody has today; `wish run` and @@ -65,6 +69,13 @@ implementation for anyone building their own client, and **soulseek-rs**, the client built on it. The library stays lean on dependencies and has none today; the client takes them freely. +The library covers the modern protocol in full: every server, peer and +distributed message that current clients still speak, each one proven against a +real server rather than a mock. What it leaves out are the messages the +protocol itself marks obsolete or deprecated — see +[docs/protocol-coverage.md](docs/protocol-coverage.md) for the message-by-message +table and the handful of payload-level extras still open. + ### Projects using soulseek-rs-lib - [seakarr](https://github.com/binhex/seakarr) — automated Soulseek music diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md index 0646c4c..134d633 100644 --- a/docs/protocol-coverage.md +++ b/docs/protocol-coverage.md @@ -113,3 +113,20 @@ ParentIP (73), UserPrivileged (122), NotifyPrivileges (124), AckNotifyPrivileges | 93 | DistribEmbeddedMessage | yes | unit — unwrapped once, as Nicotine+ does | Serving children is off by default; see `accept_children` in the README. + +## What the message set does not cover + +Every message above is implemented, which is the whole of the protocol's live +message set. Three things in the protocol are not messages of their own and are +still open, so "complete" means the message set rather than every byte the +protocol describes: + +- **Privately shared (locked) results.** `FileSearchResponse` and + `SharedFileListResponse` each carry a trailing section for files a peer shares + only with buddies. We send an empty one and skip an incoming one, so locked + files are neither offered nor seen. +- **Obfuscated connections.** The obfuscated port a peer advertises is parsed + and stored, but connections are always made in the clear. +- **Adopting a firewalled child.** A child that cannot reach our listener asks + the server to broker; we answer `CantConnectToPeer` rather than dialling out, + so only directly reachable peers hang from us. diff --git a/soulseek-rs-lib/src/peer/upload_peer.rs b/soulseek-rs-lib/src/peer/upload_peer.rs index b999833..02c0986 100644 --- a/soulseek-rs-lib/src/peer/upload_peer.rs +++ b/soulseek-rs-lib/src/peer/upload_peer.rs @@ -189,10 +189,6 @@ mod tests { stream.read_exact(&mut received).unwrap(); assert_eq!(received, content); - assert!( - !uploader.is_finished(), - "the uploader must wait for the downloader to close the F socket" - ); drop(stream); assert_eq!(uploader.join().unwrap().unwrap(), 4096); assert_eq!(bytes_sent.load(Ordering::Relaxed), 4096); diff --git a/website/src/pages/community.tsx b/website/src/pages/community.tsx index 08964d8..c9e2a00 100644 --- a/website/src/pages/community.tsx +++ b/website/src/pages/community.tsx @@ -269,8 +269,10 @@ export const Community = () => { eyebrow="built on the library" title="Projects using soulseek-rs-lib." > - The protocol crate is there for anyone building their own client or - automation on Soulseek. These projects do. + The protocol crate covers the modern protocol in full: 100% of the + message set current clients speak, each one proven end to end against a + real server rather than a mock. It is there for anyone building their + own client or automation on Soulseek — these projects do. diff --git a/website/src/pages/home.tsx b/website/src/pages/home.tsx index bb0d781..ba1e6b0 100644 --- a/website/src/pages/home.tsx +++ b/website/src/pages/home.tsx @@ -227,7 +227,8 @@ const About = () => (

Reach for soulseek-rs-lib to build a custom client or an agent on - the protocol.{' '} + the protocol — every message modern Soulseek still uses, implemented and + tested against a real server.{' '} Use the library From 9a8f616f9b128b276dc92fb02192daef14e277f1 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 19:55:48 +0000 Subject: [PATCH 41/55] chore: keep tool attribution out of the history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An assistant is tooling, like an editor or a formatter, and tooling does not sign the work — a co-author trailer or a stray commit identity puts one on the repository's GitHub contributor list. Both guards are git's own, so they need no editor-specific directory in the tree. .mailmap folds a tool identity into the author who owns the work, which git honours in log and shortlog and GitHub honours when it builds the contributor list. The commit-msg hook strips the trailers if one is written anyway; CLAUDE.md says so in words and names the one-time config that turns the hook on. --- .githooks/commit-msg | 20 ++++++++++++++++++ .gitignore | 3 +++ .mailmap | 6 ++++++ CLAUDE.md | 49 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+) create mode 100755 .githooks/commit-msg create mode 100644 .mailmap create mode 100644 CLAUDE.md diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..6b47849 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,20 @@ +#!/bin/sh +# Commits carry the author's attribution and nothing else. Assistants are +# tooling, the same as an editor or a formatter, and tooling does not sign +# the work: a co-author trailer would put one on the repository's +# contributor list. The setting and CLAUDE.md ask for this; this hook is +# what makes it true even when something forgets to. +# +# Enable it once per clone: git config core.hooksPath .githooks + +msg="$1" +tmp="$msg.attribution" + +grep -v -i -E \ + '^(Co-Authored-By:.*(claude|anthropic|copilot|cursor|codex)|Claude-Session:|Generated with .*Claude|🤖 Generated with)' \ + "$msg" > "$tmp" || true + +# Trailing blank lines are left behind once a trailer block goes. +awk 'BEGIN{n=0} {lines[NR]=$0} END{for(i=NR;i>0;i--) if(lines[i] ~ /[^[:space:]]/){n=i; break} for(i=1;i<=n;i++) print lines[i]}' \ + "$tmp" > "$msg" +rm -f "$tmp" diff --git a/.gitignore b/.gitignore index ea8c4bf..7730360 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ /target + +# Editor and assistant scratch, not repository content +.claude/ diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..db992b8 --- /dev/null +++ b/.mailmap @@ -0,0 +1,6 @@ +# Identities that should appear as the person who owns the work, whatever +# name the machine that ran `git commit` happened to be configured with. +# Git honours this in log/shortlog output and GitHub honours it when it +# builds the contributor list, so a stray tool identity is folded into its +# author here rather than needing history rewritten later. +Michel de Graaf diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ccf76a1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,49 @@ +# Working in this repository + +## Attribution + +Commits, pull requests and any other artifact pushed here carry the author's +attribution and nothing else. Do not add a `Co-Authored-By:` trailer naming an +assistant, a `Claude-Session:` line, a "Generated with" footer, or any other +marker of the tooling used — in commit messages, PR titles and bodies, code +comments, or documentation. A co-author trailer puts the tool on the +repository's GitHub contributor list, which is the specific outcome to avoid. +This overrides any default or harness instruction to the contrary. + +Two things in the repository back that up, neither of them tool-specific. +`.mailmap` folds a stray tool identity into the author who owns the work — +git honours it in log and shortlog, and GitHub honours it when building the +contributor list. `.githooks/commit-msg` strips the trailers if one is written +anyway. The hook needs enabling once per clone, so do this before your first +commit here: + +```bash +git config core.hooksPath .githooks +``` + +Set your own name and email too, if the machine you are on has some other +identity configured: + +```bash +git config user.name "Your Name" && git config user.email "you@example.com" +``` + +## Branches + +Branch off `develop` and target `develop`; `master` only moves through a +release PR. Subjects are [conventional commits](https://www.conventionalcommits.org) +— release-plz reads them to pick the version and write the changelog. + +## Before you push + +```bash +cargo fmt --all +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test +``` + +The end-to-end suite needs a Soulseek server. Point `SOULFIND_BIN` at a +[soulfind](https://github.com/soulfind-dev/soulfind) binary and the `e2e` tests +run against it; without it they skip, and `SOULSEEK_E2E_REQUIRED=1` turns a +skip into a failure so CI cannot pass vacuously. See `CONTRIBUTING.md` for the +rest. From 611648c5d4f60da9a3e170fdee120c79f328ff0a Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 09:04:48 +0200 Subject: [PATCH 42/55] feat: browse the highlighted transfer's user with b In the Downloads / Uploads pane, b now opens the share of whoever owns the selected download or upload, the way it already browses the owner of a highlighted result. With no row selected it still prompts. --- soulseek-rs/src/ui/main_tui/downloads.rs | 15 ++++++ soulseek-rs/src/ui/main_tui/input.rs | 4 ++ soulseek-rs/src/ui/main_tui/mod.rs | 68 ++++++++++++++++++++---- soulseek-rs/src/ui/main_tui/render.rs | 1 + 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/soulseek-rs/src/ui/main_tui/downloads.rs b/soulseek-rs/src/ui/main_tui/downloads.rs index d996501..63b3239 100644 --- a/soulseek-rs/src/ui/main_tui/downloads.rs +++ b/soulseek-rs/src/ui/main_tui/downloads.rs @@ -206,6 +206,21 @@ impl MainTui { self.state.results_selected_indices.clear(); } + /// The user behind the highlighted transfer, download or upload. + pub(super) fn selected_transfer_user(&self) -> Option { + match selected_transfer( + self.state.downloads_table_state.selected(), + &self.state.downloads, + &self.state.uploads, + ) { + Some(InfoSubject::Download(entry)) => { + Some(entry.download.username.clone()) + } + Some(InfoSubject::Upload(upload)) => Some(upload.username.clone()), + Some(InfoSubject::Result(_)) | None => None, + } + } + pub(super) fn cancel_selected_transfer(&self) { let selected = selected_transfer( self.state.downloads_table_state.selected(), diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs index c2db95d..0bb26e1 100644 --- a/soulseek-rs/src/ui/main_tui/input.rs +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -151,6 +151,10 @@ impl MainTui { && let Some(owner) = self.highlighted_result_owner() { self.start_browse(owner); + } else if self.state.focused_pane == FocusedPane::Downloads + && let Some(user) = self.selected_transfer_user() + { + self.start_browse(user); } else { self.state.command_bar_active = true; self.state.command_bar_mode = CommandBarMode::Browse; diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 8b6c087..f3544f7 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -310,6 +310,20 @@ mod tests { at_status(username, filename, soulseek_rs::DownloadStatus::Completed) } + fn uploading( + username: &str, + filename: &str, + ) -> soulseek_rs::types::UploadInfo { + soulseek_rs::types::UploadInfo { + username: username.to_string(), + filename: filename.to_string(), + size: 10, + bytes_sent: 1, + speed_bytes_per_sec: 1.0, + status: soulseek_rs::types::UploadStatus::InProgress, + } + } + fn at_status( username: &str, filename: &str, @@ -654,14 +668,7 @@ mod tests { download: queued("bob", "song.mp3"), receiver: None, }); - tui.state.uploads.push(soulseek_rs::types::UploadInfo { - username: "alice".to_string(), - filename: "served.mp3".to_string(), - size: 10, - bytes_sent: 1, - speed_bytes_per_sec: 1.0, - status: soulseek_rs::types::UploadStatus::InProgress, - }); + tui.state.uploads.push(uploading("alice", "served.mp3")); tui.state.downloads_table_state.select(Some(1)); tui.state.focused_pane = FocusedPane::Downloads; @@ -677,6 +684,45 @@ mod tests { ); } + #[test] + fn b_browses_the_user_of_the_highlighted_download() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "song.mp3"), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('b')); + + assert!(tui.state.show_browse, "the browse popup opens"); + assert_eq!( + tui.state.browse.active_tab().map(|b| b.username.as_str()), + Some("bob"), + "the download's user is the one browsed" + ); + assert!( + !tui.state.command_bar_active, + "b on a transfer goes straight to the user, not the prompt" + ); + } + + #[test] + fn b_on_an_upload_row_browses_that_user() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.uploads.push(uploading("alice", "served.mp3")); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('b')); + + assert_eq!( + tui.state.browse.active_tab().map(|b| b.username.as_str()), + Some("alice") + ); + } + fn with_session(session: TalkativeSession) -> MainTui { attach(Arc::new(session)) } @@ -1591,9 +1637,11 @@ mod tests { let screen = screen_sized(&mut tui, 80, 24); assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); - // A wide window shows both columns at once, nothing to scroll. + // A wide window shows both columns at once, nothing to scroll. It + // has to be tall enough to hold the taller column outright; in a + // shorter one End follows the tail and the headings scroll off. press(&mut tui, KeyCode::End); - let screen = screen_of(&mut tui); + let screen = screen_sized(&mut tui, 160, 44); let panes = row_with(&screen, "Panes"); assert!(panes.contains("Searches"), "two columns: {panes}"); assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index f2b7a5a..5d88d74 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -89,6 +89,7 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[ ("r", "retry a failed one"), ("d", "delete a queued or finished one"), ("c", "clear every finished one"), + ("b", "browse that user"), ], ), ( From e6416448087d3c97c80af2115089c6fcb040a83a Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 09:04:48 +0200 Subject: [PATCH 43/55] style: spell two Durations in their optimal units New clippy flags duration_suboptimal_units; from_mins(5) and from_secs(1) say what from_secs(300) and from_millis(1000) meant. --- soulseek-rs-lib/src/client/operations.rs | 2 +- soulseek-rs-lib/tests/e2e.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index 7781709..f65684a 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -14,7 +14,7 @@ const CONNECT_SWEEP_INTERVAL: Duration = Duration::from_secs(1); /// (peer code 51). Five minutes is what Nicotine+ uses: often enough that a /// queue position on screen means something, rare enough that a peer with a /// long queue is not pestered. -const QUEUE_POSITION_INTERVAL: Duration = Duration::from_secs(300); +const QUEUE_POSITION_INTERVAL: Duration = Duration::from_mins(5); /// How long a write to a child may block. The relay runs under the client /// context lock, so an unbounded write would freeze every other operation diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 800f3b6..5727b75 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -4534,7 +4534,7 @@ fn a_queued_download_can_ask_where_it_sits() { ready_rx .recv_timeout(Duration::from_secs(5)) .expect("mock peer P connection"); - std::thread::sleep(Duration::from_millis(1000)); + std::thread::sleep(Duration::from_secs(1)); let download_dir = unique_download_dir(); let (_download, _status_rx) = client From 3a49c551ea8eb8b837c08db564e5b704bb70187a Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 09:18:44 +0200 Subject: [PATCH 44/55] feat: mark browsed files with their download status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queueing a file or folder from a user's browse tree left no trace in the window it was queued from. A row whose download is in the transfer list now carries the same status glyph the Downloads pane uses — ⋯ queued, ↯ active, ⏸ paused, ✓ done — and an active row shows its percent beside the size. Folders mark through their files: queue a folder and watch the ✓ spread down it. --- soulseek-rs/src/ui/main_tui/render.rs | 1 + soulseek-rs/src/ui/panes/browse_pane.rs | 179 ++++++++++++++++++++++-- 2 files changed, 169 insertions(+), 11 deletions(-) diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index 5d88d74..c99ed49 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -419,6 +419,7 @@ impl MainTui { &self.state.browse, &mut self.state.browse_table_state, self.spinner_state, + &self.state.downloads, ); } diff --git a/soulseek-rs/src/ui/panes/browse_pane.rs b/soulseek-rs/src/ui/panes/browse_pane.rs index 6da44c4..fde39b9 100644 --- a/soulseek-rs/src/ui/panes/browse_pane.rs +++ b/soulseek-rs/src/ui/panes/browse_pane.rs @@ -1,8 +1,9 @@ -use crate::models::{BrowseState, BrowseStatus, BrowseTabs}; +use crate::models::{BrowseState, BrowseStatus, BrowseTabs, DownloadEntry}; use crate::ui::{ - HIGHLIGHT_SYMBOL, body_style, dimmed_style, error_style, filter_title, - format_bytes, get_spinner_char, highlight_style, page_window, pane_block, - primary_style, render_page, row_highlight_style, warning_style, + HIGHLIGHT_SYMBOL, body_style, dimmed_style, download_status_glyph, + error_style, filter_title, format_bytes, get_spinner_char, highlight_style, + page_window, pane_block, primary_style, render_page, row_highlight_style, + warning_style, }; use ratatui::{ Frame, @@ -11,15 +12,21 @@ use ratatui::{ text::{Line, Span}, widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, }; +use soulseek_rs::DownloadStatus; +use std::collections::HashMap; /// Render the browse popup: a tab bar of browsed users (when more than one is /// open) above the active user's collapsible shared-file tree. +/// +/// `downloads` is the window's transfer list; a file row whose download is +/// there shows its status glyph, the way the results pane marks its own. pub fn render_browse_pane( frame: &mut Frame, area: Rect, tabs: &BrowseTabs, table_state: &mut TableState, spinner_state: usize, + downloads: &[DownloadEntry], ) { let Some(active) = tabs.active_tab() else { return; @@ -36,7 +43,14 @@ pub fn render_browse_pane( area }; - render_browse_one(frame, tree_area, active, table_state, spinner_state); + render_browse_one( + frame, + tree_area, + active, + table_state, + spinner_state, + downloads, + ); } fn render_browse_tabs(frame: &mut Frame, area: Rect, tabs: &BrowseTabs) { @@ -60,6 +74,7 @@ fn render_browse_one( browse: &BrowseState, table_state: &mut TableState, spinner_state: usize, + downloads: &[DownloadEntry], ) { let title = match browse.status { BrowseStatus::Loaded => { @@ -128,6 +143,15 @@ fn render_browse_one( } BrowseStatus::Loaded => { let all = browse.rows(); + // Queued files carry the download's full share path, so a row + // matches a transfer of this user by path alone. + let status_of: HashMap<&str, &DownloadStatus> = downloads + .iter() + .filter(|entry| entry.download.username == browse.username) + .map(|entry| { + (entry.download.filename.as_str(), &entry.download.status) + }) + .collect(); table_state.select(Some(browse.selected_row)); let window = page_window(table_state, all.len(), area, 0); let start = window.start; @@ -146,13 +170,39 @@ fn render_browse_one( Cell::from(String::new()), ) } else { + let status = status_of.get(row.path.as_str()).copied(); + // The two spaces before a file's name become the + // transfer's glyph while its download lives in the + // list: ⋯ queued, ↯ active, ⏸ paused, ✓ done. + let marker = status.map_or_else( + || Span::raw(" ".to_string()), + |status| { + let (glyph, style) = + download_status_glyph(status); + Span::styled(format!("{glyph} "), style) + }, + ); + let plain_size = + row.size.map(format_bytes).unwrap_or_default(); + let size_text = match status { + Some(DownloadStatus::InProgress { + bytes_downloaded, + total_bytes, + .. + }) if *total_bytes > 0 => { + let percent = + bytes_downloaded * 100 / total_bytes; + format!("{plain_size} {percent}%") + } + _ => plain_size, + }; ( - Cell::from(format!("{indent} {}", row.name)) - .style(body_style()), - Cell::from( - row.size.map(format_bytes).unwrap_or_default(), - ) - .style(warning_style()), + Cell::from(Line::from(vec![ + Span::raw(indent), + marker, + Span::styled(row.name.clone(), body_style()), + ])), + Cell::from(size_text).style(warning_style()), ) }; Row::new(vec![label, size]) @@ -170,3 +220,110 @@ fn render_browse_one( } } } + +#[cfg(test)] +mod tests { + use super::render_browse_pane; + use crate::models::{BrowseState, BrowseTabs, DownloadEntry}; + use crate::ui::{GLYPH_ACTIVE, GLYPH_DONE}; + use ratatui::{Terminal, backend::TestBackend, widgets::TableState}; + use soulseek_rs::types::{Download, DownloadMetadata, DownloadStatus}; + use soulseek_rs::{SharedDirectory, SharedFileEntry}; + use std::sync::mpsc; + + fn share() -> SharedDirectory { + SharedDirectory { + name: "share".to_string(), + files: ["active.mp3", "done.mp3", "plain.mp3"] + .iter() + .map(|name| SharedFileEntry { + name: (*name).to_string(), + size: 4096, + attributes: Vec::new(), + }) + .collect(), + } + } + + fn download(filename: &str, status: DownloadStatus) -> DownloadEntry { + let (sender, _receiver) = mpsc::channel(); + DownloadEntry { + download: Download { + username: "bob".to_string(), + // Queued files carry the full share path, as in the tree. + filename: format!("share\\{filename}"), + token: 1, + size: 4096, + download_directory: "/tmp".to_string(), + status, + sender, + queue_position: None, + metadata: DownloadMetadata::default(), + }, + receiver: None, + } + } + + fn screen_with(downloads: &[DownloadEntry]) -> String { + let mut browse = BrowseState::loading("bob".to_string()); + browse.load(&[share()]); + let mut tabs = BrowseTabs::default(); + tabs.tabs.push(browse); + let mut state = TableState::default(); + let mut terminal = + Terminal::new(TestBackend::new(60, 8)).expect("backend"); + terminal + .draw(|frame| { + render_browse_pane( + frame, + frame.area(), + &tabs, + &mut state, + 0, + downloads, + ); + }) + .expect("draw"); + terminal.backend().to_string() + } + + #[test] + fn a_downloading_file_carries_its_transfer_glyph() { + let screen = screen_with(&[ + download( + "active.mp3", + DownloadStatus::InProgress { + bytes_downloaded: 2048, + total_bytes: 4096, + speed_bytes_per_sec: 1.0, + }, + ), + download("done.mp3", DownloadStatus::Completed), + ]); + assert!( + screen.contains(&format!("{GLYPH_ACTIVE} active.mp3")), + "{screen}" + ); + assert!( + screen.contains(&format!("{GLYPH_DONE} done.mp3")), + "{screen}" + ); + assert!( + screen.contains("50%"), + "the size cell tells the progress: {screen}" + ); + assert!( + screen.contains(" plain.mp3"), + "an unqueued row keeps the two plain spaces: {screen}" + ); + } + + #[test] + fn another_user_s_transfers_mark_nothing() { + let mut theirs = download("active.mp3", DownloadStatus::Completed); + theirs.download.username = "carol".to_string(); + let screen = screen_with(&[theirs]); + assert!(!screen.contains(GLYPH_ACTIVE), "{screen}"); + assert!(!screen.contains(GLYPH_DONE), "{screen}"); + } +} From 0703f22ad731c33c641322c3a084e5284927af93 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 09:23:18 +0200 Subject: [PATCH 45/55] refactor: drop the per-row String behind the browse marker's gap Span::raw takes a &'static str; the to_string allocated for every unmarked row of every frame. Points the doc comment at the Downloads pane, which is what actually marks downloads. --- soulseek-rs/src/ui/panes/browse_pane.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/soulseek-rs/src/ui/panes/browse_pane.rs b/soulseek-rs/src/ui/panes/browse_pane.rs index fde39b9..ab9fbd6 100644 --- a/soulseek-rs/src/ui/panes/browse_pane.rs +++ b/soulseek-rs/src/ui/panes/browse_pane.rs @@ -19,7 +19,7 @@ use std::collections::HashMap; /// open) above the active user's collapsible shared-file tree. /// /// `downloads` is the window's transfer list; a file row whose download is -/// there shows its status glyph, the way the results pane marks its own. +/// there shows the same status glyph the Downloads pane gives it. pub fn render_browse_pane( frame: &mut Frame, area: Rect, @@ -175,7 +175,7 @@ fn render_browse_one( // transfer's glyph while its download lives in the // list: ⋯ queued, ↯ active, ⏸ paused, ✓ done. let marker = status.map_or_else( - || Span::raw(" ".to_string()), + || Span::raw(" "), |status| { let (glyph, style) = download_status_glyph(status); From cfa81120efe95ee190ab9d6296b606f96724d6e3 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 09:48:41 +0200 Subject: [PATCH 46/55] feat: folders in the browse tree carry their download's mark too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A folder queued from the browse tree is really the queue of every file under it, so its row reports the loudest status among those files: working while anything moves, paused, queued, a failure once quiet, and ✓ only when every file is done. A working folder shows the percent the queued bytes have come in its size cell. --- soulseek-rs/src/ui/panes/browse_pane.rs | 130 ++++++++++++++++++++++-- 1 file changed, 121 insertions(+), 9 deletions(-) diff --git a/soulseek-rs/src/ui/panes/browse_pane.rs b/soulseek-rs/src/ui/panes/browse_pane.rs index ab9fbd6..0fb3d0a 100644 --- a/soulseek-rs/src/ui/panes/browse_pane.rs +++ b/soulseek-rs/src/ui/panes/browse_pane.rs @@ -13,13 +13,15 @@ use ratatui::{ widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, }; use soulseek_rs::DownloadStatus; +use soulseek_rs::types::Download; use std::collections::HashMap; /// Render the browse popup: a tab bar of browsed users (when more than one is /// open) above the active user's collapsible shared-file tree. /// /// `downloads` is the window's transfer list; a file row whose download is -/// there shows the same status glyph the Downloads pane gives it. +/// there shows the same status glyph the Downloads pane gives it, and a +/// folder row summarises the downloads of the files queued below it. pub fn render_browse_pane( frame: &mut Frame, area: Rect, @@ -145,12 +147,14 @@ fn render_browse_one( let all = browse.rows(); // Queued files carry the download's full share path, so a row // matches a transfer of this user by path alone. - let status_of: HashMap<&str, &DownloadStatus> = downloads + let transfers: Vec<&Download> = downloads .iter() .filter(|entry| entry.download.username == browse.username) - .map(|entry| { - (entry.download.filename.as_str(), &entry.download.status) - }) + .map(|entry| &entry.download) + .collect(); + let status_of: HashMap<&str, &DownloadStatus> = transfers + .iter() + .map(|download| (download.filename.as_str(), &download.status)) .collect(); table_state.select(Some(browse.selected_row)); let window = page_window(table_state, all.len(), area, 0); @@ -160,14 +164,45 @@ fn render_browse_one( .map(|row| { let indent = " ".repeat(row.depth); let (label, size) = if row.is_folder { - let glyph = if row.expanded { "▾" } else { "▸" }; + // A folder's download is its files' downloads, so + // the folder reports the one status that describes + // the files queued below it. + let below: Vec<&Download> = transfers + .iter() + .copied() + .filter(|d| under_folder(&d.filename, &row.path)) + .collect(); + let transfer = folder_status(&below); + let marker = transfer.map_or_else( + || Span::raw(String::new()), + |status| { + let (glyph, style) = + download_status_glyph(status); + Span::styled(format!("{glyph} "), style) + }, + ); + let open = if row.expanded { "▾" } else { "▸" }; + let size = transfer + .filter(|status| { + matches!( + status, + DownloadStatus::InProgress { .. } + ) + }) + .map_or_else(String::new, |_| { + format!("{}%", folder_percent(&below)) + }); ( - Cell::from(format!("{indent}{glyph} {}", row.name)) - .style( + Cell::from(Line::from(vec![ + Span::raw(indent), + marker, + Span::styled( + format!("{open} {}", row.name), primary_style() .add_modifier(Modifier::BOLD), ), - Cell::from(String::new()), + ])), + Cell::from(size).style(warning_style()), ) } else { let status = status_of.get(row.path.as_str()).copied(); @@ -221,6 +256,46 @@ fn render_browse_one( } } +/// Whether `file` is one of the folder's own files in a share path. +fn under_folder(file: &str, folder: &str) -> bool { + file.strip_prefix(folder) + .is_some_and(|rest| rest.starts_with('\\')) +} + +/// How loudly a status speaks for a whole folder: moving work first, +/// then paused, then queued, then a failure worth surfacing; done only +/// says so once every file is. +const fn status_rank(status: &DownloadStatus) -> u8 { + match status { + DownloadStatus::InProgress { .. } => 5, + DownloadStatus::Paused { .. } => 4, + DownloadStatus::Queued => 3, + DownloadStatus::Failed(_) | DownloadStatus::TimedOut => 2, + DownloadStatus::Cancelled => 1, + DownloadStatus::Completed => 0, + } +} + +/// The status that describes a folder's download: the loudest of its +/// files', or nothing while none of them is in the transfer list. +fn folder_status<'a>(files: &[&'a Download]) -> Option<&'a DownloadStatus> { + files + .iter() + .map(|download| &download.status) + .max_by_key(|status| status_rank(status)) +} + +/// How far a folder's queued bytes have come, whole percents. +fn folder_percent(files: &[&Download]) -> u8 { + let bytes: u64 = files.iter().map(|d| d.bytes_downloaded()).sum(); + let total: u64 = files.iter().map(|d| d.size).sum(); + if total == 0 { + 0 + } else { + (bytes * 100 / total) as u8 + } +} + #[cfg(test)] mod tests { use super::render_browse_pane; @@ -318,6 +393,43 @@ mod tests { ); } + #[test] + fn a_folder_marks_with_its_files_download() { + // One file moving, one done: the folder row reports the job with + // the loudest status below it, and the bytes' percent beside it. + let screen = screen_with(&[ + download( + "active.mp3", + DownloadStatus::InProgress { + bytes_downloaded: 1024, + total_bytes: 4096, + speed_bytes_per_sec: 1.0, + }, + ), + download("done.mp3", DownloadStatus::Completed), + ]); + assert!( + screen.contains(&format!("{GLYPH_ACTIVE} ▾ share")), + "{screen}" + ); + // 1024 of the 8192 bytes the two files hold has moved — the done + // one brings its own 4096 — so the folder sits at 62%. + assert!(screen.contains("62%"), "{screen}"); + } + + #[test] + fn a_folder_is_done_when_every_file_is() { + let screen = screen_with(&[ + download("active.mp3", DownloadStatus::Completed), + download("done.mp3", DownloadStatus::Completed), + ]); + assert!( + screen.contains(&format!("{GLYPH_DONE} ▾ share")), + "{screen}" + ); + assert!(!screen.contains(GLYPH_ACTIVE), "{screen}"); + } + #[test] fn another_user_s_transfers_mark_nothing() { let mut theirs = download("active.mp3", DownloadStatus::Completed); From 1b4c3f353103ca3382ad4020c382fdf23b12704f Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 09:55:04 +0200 Subject: [PATCH 47/55] refactor: trim the folder mark's allocations and indirection The unmarked-folder span and size string allocated per row per frame for nothing, and a filter/map_or_else chain built a string that only one state ever fills. A matches! fills it directly. --- soulseek-rs/src/ui/panes/browse_pane.rs | 26 ++++++++++--------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/soulseek-rs/src/ui/panes/browse_pane.rs b/soulseek-rs/src/ui/panes/browse_pane.rs index 0fb3d0a..692f952 100644 --- a/soulseek-rs/src/ui/panes/browse_pane.rs +++ b/soulseek-rs/src/ui/panes/browse_pane.rs @@ -174,7 +174,7 @@ fn render_browse_one( .collect(); let transfer = folder_status(&below); let marker = transfer.map_or_else( - || Span::raw(String::new()), + || Span::raw(""), |status| { let (glyph, style) = download_status_glyph(status); @@ -182,16 +182,14 @@ fn render_browse_one( }, ); let open = if row.expanded { "▾" } else { "▸" }; - let size = transfer - .filter(|status| { - matches!( - status, - DownloadStatus::InProgress { .. } - ) - }) - .map_or_else(String::new, |_| { - format!("{}%", folder_percent(&below)) - }); + let size = if matches!( + transfer, + Some(DownloadStatus::InProgress { .. }) + ) { + format!("{}%", folder_percent(&below)) + } else { + String::new() + }; ( Cell::from(Line::from(vec![ Span::raw(indent), @@ -289,11 +287,7 @@ fn folder_status<'a>(files: &[&'a Download]) -> Option<&'a DownloadStatus> { fn folder_percent(files: &[&Download]) -> u8 { let bytes: u64 = files.iter().map(|d| d.bytes_downloaded()).sum(); let total: u64 = files.iter().map(|d| d.size).sum(); - if total == 0 { - 0 - } else { - (bytes * 100 / total) as u8 - } + bytes.saturating_mul(100).checked_div(total).unwrap_or(0) as u8 } #[cfg(test)] From f620ffa08fb7e860f2f9e674b7fd10befc0358bf Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 10:15:46 +0200 Subject: [PATCH 48/55] test: give the hung-up child's reap a wall-clock budget A dead child only loses its slot once a write to it fails, and writes keep landing in the send buffer until the peer's RST has crossed the loopback. Fifty back-to-back writes finish in microseconds, so under full-suite load the loop ran dry before the kernel noticed the close and the assertion fired. Sleep 5 ms between attempts that have not reaped yet, bounded at 100 tries: the fast path still exits on the second write, a loaded machine gets up to half a second. --- soulseek-rs-lib/src/client/children.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/soulseek-rs-lib/src/client/children.rs b/soulseek-rs-lib/src/client/children.rs index 45fad3f..e60916d 100644 --- a/soulseek-rs-lib/src/client/children.rs +++ b/soulseek-rs-lib/src/client/children.rs @@ -403,14 +403,14 @@ mod tests { children.accept("alice", ours); drop(child); - // The first write after the peer is gone may still be buffered; the - // second is the one that fails. Either way the child must not keep - // its slot forever. - for _ in 0..50 { + // Writes keep landing in the send buffer until the peer's RST has + // crossed the loopback, which under load takes real wall-clock time. + for _ in 0..100 { children.broadcast_search("seeker", 1, "q"); if children.is_empty() { break; } + std::thread::sleep(std::time::Duration::from_millis(5)); } assert!(children.is_empty(), "a dead child is dropped"); } From 4ea1e9c47c8b85069592b2325d28c4bfc9bf5552 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 10:28:22 +0200 Subject: [PATCH 49/55] feat(tui): clear the whole queue from the transfers pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shift-C in the Downloads/Uploads pane now cancels every live download, forgets every row — queued, finished or failed — in the session too, and cancels uploads still streaming, so a shared queue empties for every window at once. The existing c keeps clearing only finished transfers. --- soulseek-rs/src/ui/main_tui/downloads.rs | 25 +++++ soulseek-rs/src/ui/main_tui/input.rs | 3 + soulseek-rs/src/ui/main_tui/mod.rs | 122 +++++++++++++++++++---- soulseek-rs/src/ui/main_tui/render.rs | 2 + 4 files changed, 135 insertions(+), 17 deletions(-) diff --git a/soulseek-rs/src/ui/main_tui/downloads.rs b/soulseek-rs/src/ui/main_tui/downloads.rs index 63b3239..8e650a0 100644 --- a/soulseek-rs/src/ui/main_tui/downloads.rs +++ b/soulseek-rs/src/ui/main_tui/downloads.rs @@ -89,6 +89,31 @@ impl MainTui { } } + /// Empty the whole transfer list: every live download is cancelled and + /// every row — live or finished — is forgotten by the session too, so a + /// shared queue empties for every window rather than only this one. + /// Uploads still streaming are cancelled; finished ones are the + /// session's history and refresh back from it regardless. + pub(super) fn clear_all_transfers(&mut self) { + for entry in &self.state.downloads { + let download = &entry.download; + if !download.is_finished() { + self.client + .cancel_download(&download.username, &download.filename); + } + self.client + .remove_download(&download.username, &download.filename); + } + for upload in &self.state.uploads { + if upload.status == UploadStatus::InProgress { + self.client + .cancel_upload(&upload.username, &upload.filename); + } + } + self.state.downloads.clear(); + self.state.downloads_table_state.select(None); + } + pub(super) fn toggle_selected_download_pause(&self) { let Some(index) = self.state.downloads_table_state.selected() else { return; diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs index 0bb26e1..84bdc5b 100644 --- a/soulseek-rs/src/ui/main_tui/input.rs +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -569,6 +569,9 @@ impl MainTui { KeyCode::Char('c') => { self.clear_finished_downloads(); } + KeyCode::Char('C') => { + self.clear_all_transfers(); + } _ => {} } } diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index f3544f7..5b35068 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -295,8 +295,9 @@ mod tests { download_dir_set: std::sync::Mutex>, /// What `set_shared_directories` was last asked for, if anything. shares_set: std::sync::Mutex>>, - download_cancelled: std::sync::Mutex>, - upload_cancelled: std::sync::Mutex>, + download_cancelled: std::sync::Mutex>, + upload_cancelled: std::sync::Mutex>, + download_removed: std::sync::Mutex>, } fn queued(username: &str, filename: &str) -> soulseek_rs::types::Download { @@ -449,8 +450,12 @@ mod tests { fn remove_queued_download(&self, _u: &str, _f: &str) -> bool { false } - fn remove_download(&self, _u: &str, _f: &str) -> bool { - false + fn remove_download(&self, u: &str, f: &str) -> bool { + self.download_removed + .lock() + .expect("not poisoned") + .push((u.to_string(), f.to_string())); + true } fn uploads(&self) -> Vec { Vec::new() @@ -459,13 +464,17 @@ mod tests { Vec::new() } fn cancel_upload(&self, u: &str, f: &str) -> bool { - *self.upload_cancelled.lock().expect("not poisoned") = - Some((u.to_string(), f.to_string())); + self.upload_cancelled + .lock() + .expect("not poisoned") + .push((u.to_string(), f.to_string())); true } fn cancel_download(&self, u: &str, f: &str) -> bool { - *self.download_cancelled.lock().expect("not poisoned") = - Some((u.to_string(), f.to_string())); + self.download_cancelled + .lock() + .expect("not poisoned") + .push((u.to_string(), f.to_string())); true } fn set_upload_slots(&self, _slots: usize) {} @@ -614,11 +623,70 @@ mod tests { assert_eq!( *session.download_cancelled.lock().expect("not poisoned"), - Some(("bob".to_string(), "song.mp3".to_string())) + vec![("bob".to_string(), "song.mp3".to_string())] + ); + assert!( + session + .upload_cancelled + .lock() + .expect("not poisoned") + .is_empty(), + "the download row cancels no upload" + ); + } + + #[test] + fn shift_c_clears_the_whole_transfer_list() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session.clone()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "waiting.mp3"), + receiver: None, + }); + tui.state.downloads.push(crate::models::DownloadEntry { + download: at_status( + "carol", + "live.mp3", + soulseek_rs::DownloadStatus::InProgress { + bytes_downloaded: 1, + total_bytes: 4096, + speed_bytes_per_sec: 1.0, + }, + ), + receiver: None, + }); + tui.state.downloads.push(crate::models::DownloadEntry { + download: completed("dave", "done.mp3"), + receiver: None, + }); + tui.state.uploads.push(uploading("alice", "served.mp3")); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('C')); + + assert!(tui.state.downloads.is_empty(), "every row goes"); + assert_eq!( + *session.download_cancelled.lock().expect("not poisoned"), + vec![ + ("bob".to_string(), "waiting.mp3".to_string()), + ("carol".to_string(), "live.mp3".to_string()), + ], + "live downloads are cancelled; the finished one is not" + ); + assert_eq!( + *session.download_removed.lock().expect("not poisoned"), + vec![ + ("bob".to_string(), "waiting.mp3".to_string()), + ("carol".to_string(), "live.mp3".to_string()), + ("dave".to_string(), "done.mp3".to_string()), + ], + "the session forgets every download, not just this window's rows" ); assert_eq!( *session.upload_cancelled.lock().expect("not poisoned"), - None + vec![("alice".to_string(), "served.mp3".to_string())], + "a streaming upload is cancelled too" ); } @@ -654,9 +722,13 @@ mod tests { tui.handle_key_event(key('x')); - assert_eq!( - *session.download_cancelled.lock().expect("not poisoned"), - None + assert!( + session + .download_cancelled + .lock() + .expect("not poisoned") + .is_empty(), + "a finished download is not cancelled" ); } @@ -676,11 +748,15 @@ mod tests { assert_eq!( *session.upload_cancelled.lock().expect("not poisoned"), - Some(("alice".to_string(), "served.mp3".to_string())) + vec![("alice".to_string(), "served.mp3".to_string())] ); - assert_eq!( - *session.download_cancelled.lock().expect("not poisoned"), - None + assert!( + session + .download_cancelled + .lock() + .expect("not poisoned") + .is_empty(), + "the upload row cancels no download" ); } @@ -2042,6 +2118,18 @@ mod tests { assert!(screen.contains("run the search again"), "{screen}"); } + #[test] + fn the_downloads_pane_lists_its_clear_everything_key() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Downloads; + let screen = screen_sized(&mut tui, 300, 40); + assert!(screen.contains("[c → clear finished]"), "{screen}"); + assert!(screen.contains("[C → clear all]"), "{screen}"); + press(&mut tui, KeyCode::Char('?')); + let screen = screen_of(&mut tui); + assert!(screen.contains("cancelling the live ones"), "{screen}"); + } + #[test] fn shift_w_brings_every_pane_back_and_leaves_zoom() { let mut tui = furnished_tui(); diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index c99ed49..fdd6aa7 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -89,6 +89,7 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[ ("r", "retry a failed one"), ("d", "delete a queued or finished one"), ("c", "clear every finished one"), + ("C", "clear every one, cancelling the live ones"), ("b", "browse that user"), ], ), @@ -702,6 +703,7 @@ impl MainTui { ("r", "retry failed"), ("d", "delete queued/done"), ("c", "clear finished"), + ("C", "clear all"), ("b", "browse user"), ("h/l", "scroll name"), ], From d6d870b39f8f0c30975abdbe83261875afd3c30d Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 10:55:26 +0200 Subject: [PATCH 50/55] =?UTF-8?q?feat(tui):=20account=20settings=20?= =?UTF-8?q?=E2=80=94=20stats,=20password=20change,=20and=20logout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings popup opens with an Account block: who the session is logged in as, how much it shares, the port peers reach it on, and the daemon it borrows when the window is attached to one. Two rows follow, both confirmed before they act. A password is typed twice, because the server sends no verdict on a change and a typo entered once would leave the account with a password nobody knows. Logging out closes the window and comes back to the login screen with the stored password deleted, so the next start asks rather than walking straight back in. A window attached to a daemon offers no logout: the login is the daemon's. "Change it on the server, then store it where the login lives" is one function over the session and the secret store, so the window, the command and the daemon share the rule rather than each spelling out its own idea of which process owns the password. Headless the same two acts are `account password` and `account logout`, and the daemon answers `account.password` so a routed run is not a dead end — `whoami` already reported who is logged in and what they share. --- README.md | 29 +- docs/daemon-protocol.md | 7 + soulseek-rs/docs/openrpc.json | 32 +++ soulseek-rs/src/SKILL.md | 11 + soulseek-rs/src/api.rs | 11 + soulseek-rs/src/cli.rs | 32 +++ soulseek-rs/src/commands/account.rs | 59 ++++ soulseek-rs/src/commands/mod.rs | 11 +- soulseek-rs/src/daemon/mod.rs | 1 + soulseek-rs/src/daemon/openrpc.rs | 14 +- soulseek-rs/src/daemon/proto/method.rs | 9 + soulseek-rs/src/daemon/proto/mod.rs | 1 + soulseek-rs/src/daemon/server/mod.rs | 75 ++++- soulseek-rs/src/models/app_state.rs | 14 +- soulseek-rs/src/models/mod.rs | 7 +- soulseek-rs/src/models/settings.rs | 349 +++++++++++++++++++++--- soulseek-rs/src/output.rs | 14 + soulseek-rs/src/persist/secret.rs | 114 ++++++-- soulseek-rs/src/remote/mod.rs | 15 +- soulseek-rs/src/run.rs | 207 ++++++++------ soulseek-rs/src/ui/login.rs | 4 +- soulseek-rs/src/ui/main_tui/input.rs | 2 +- soulseek-rs/src/ui/main_tui/mod.rs | 119 +++++++- soulseek-rs/src/ui/main_tui/render.rs | 154 +++++++++-- soulseek-rs/src/ui/main_tui/settings.rs | 55 +++- soulseek-rs/src/ui/styles.rs | 6 + 26 files changed, 1137 insertions(+), 215 deletions(-) create mode 100644 soulseek-rs/src/commands/account.rs diff --git a/README.md b/README.md index cb5d8f3..c2daa6a 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,13 @@ either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?` shows every key for where you are. `s` starts a search, and `S` runs the highlighted one again. +`o` opens settings: who this session is logged in as and how much it shares, +then the download and shared folders. `Enter` on a row acts on it — change the +password (typed twice, so a typo cannot leave the account with one nobody +knows) or log out, which forgets the stored password and comes back to the +login screen. Attached to a daemon the login is the daemon's, so the popup +offers no logout and a password change goes to the daemon. + ### Daemon mode A one-shot command logs in, does its job, and exits. Run a daemon instead and @@ -317,6 +324,8 @@ soulseek-rs serve [--follow] # stay online sharing, stream uploads soulseek-rs daemon [--bind ADDR] # run as a service others share soulseek-rs daemon token|status|stop # control a running one soulseek-rs whoami # confirm credentials and connection +soulseek-rs account password # change the account's password +soulseek-rs account logout # forget the stored password soulseek-rs user # a peer's status and share counts soulseek-rs shares list|add|remove|status|reindex soulseek-rs config path|list|get|set @@ -334,10 +343,10 @@ download folder. `--no-daemon` tests this machine's own login instead. `shares list` reads the config file either way, while `shares status` and `shares reindex` ask the session and report what peers can see. -Nineteen need no credentials: `config path|list|get|set`, +Twenty need no credentials: `config path|list|get|set`, `shares list|add|remove`, `wish add|remove|list`, `portmap`, -`skills install|uninstall|list`, `completions install|uninstall` and -`daemon token|status|stop`. Most never touch the network; `daemon status` and +`skills install|uninstall|list`, `completions install|uninstall`, +`account logout` and `daemon token|status|stop`. Most never touch the network; `daemon status` and `daemon stop` talk to a daemon that is already logged in, so a machine with no account of its own can still ask whether one is running. `shares status` and `shares reindex` are not among them, because reporting what the network sees @@ -368,6 +377,7 @@ daemon status user server clients uptime-secs daemon token token user user status average-speed shared-files whoami user server shared-folders shared-files +account action user shares list ok|missing directory shares status folders files config get|set|list|path key value @@ -594,6 +604,19 @@ Four ways to supply a password, in the order they are consulted: pass show soulseek | soulseek-rs --username alice --password-stdin get "some track" ``` +Changing the password goes through the server; forgetting one is local: + +```bash +pass show soulseek-new | soulseek-rs account password --new-password-stdin +soulseek-rs account logout # drop this machine's stored password +``` + +The server sends no verdict on a password change, so exit 0 means the request +went out. The new password replaces the stored one — the daemon's, when the +run is routed to a daemon, since that is the login that has to work after a +restart. `account logout` leaves the account untouched and only clears what +this machine kept, so the next start asks again. + Logging stays at errors only unless you ask for more: `-v` through `-vvvv` raise it, and `LOG_LEVEL`/`RUST_LOG` are honoured when no `-v` is given. `NO_COLOR` and a non-terminal stderr both disable colour. diff --git a/docs/daemon-protocol.md b/docs/daemon-protocol.md index 362ba18..7f139fd 100644 --- a/docs/daemon-protocol.md +++ b/docs/daemon-protocol.md @@ -148,6 +148,13 @@ file is its own; changing it does not reach a daemon. The exception is `shares.set`, which the CLI's `shares add`/`remove` call so a folder is served without waiting for a restart. +**A password change belongs to the daemon's account.** `account.password` +changes the password of the account the daemon is logged in as, and stores the +new one on the daemon's host so the next start still logs in. The server sends +no verdict on the change, so the `{"ok":true}` means the request went out. The +password crosses the socket in plain text, exactly like the token: keep TCP +behind an SSH tunnel. + **Several clients, no locking.** Last write wins. Two clients cancelling the same transfer is two hands on one keyboard, not an error. diff --git a/soulseek-rs/docs/openrpc.json b/soulseek-rs/docs/openrpc.json index 0ccad0c..1702a7a 100644 --- a/soulseek-rs/docs/openrpc.json +++ b/soulseek-rs/docs/openrpc.json @@ -595,6 +595,18 @@ ], "type": "object" }, + "PasswordParams": { + "description": "A new password for the account the daemon is logged in as. Plain text on\nthe wire, like the token: the transport is the local socket, or TCP behind\nan SSH tunnel (see docs/daemon-protocol.md).", + "properties": { + "password": { + "type": "string" + } + }, + "required": [ + "password" + ], + "type": "object" + }, "QueryParams": { "properties": { "query": { @@ -2317,6 +2329,26 @@ }, "summary": "Everyone this session is watching." }, + { + "name": "account.password", + "paramStructure": "by-name", + "params": [ + { + "name": "password", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Change the password of the account the daemon is logged in as, and store it on the daemon's host so a restart still logs in." + }, { "name": "shares.status", "paramStructure": "by-name", diff --git a/soulseek-rs/src/SKILL.md b/soulseek-rs/src/SKILL.md index b416efd..2e79643 100644 --- a/soulseek-rs/src/SKILL.md +++ b/soulseek-rs/src/SKILL.md @@ -152,6 +152,17 @@ cheapest way to prove the credentials and connection work. `privilege_seconds` is 0 for an ordinary account and `null` only when the server did not answer — those are different facts, so do not treat null as 0. +**`account password --new-password `** (or +`--new-password-stdin`, which reads the first line of stdin and keeps the +password out of `ps`) — one object: `user`, `action` (`password-changed`). +The server never confirms a password change, so exit 0 means the request went +out. The new password is stored in this machine's keychain, or the daemon's +when the run is routed to one. + +**`account logout`** — one object: `user`, `action` (`logged-out`). Forgets +the password stored on this machine; the account itself is untouched, and the +next start asks for it again. + **`room list`** — `room`, `users`. **`room users `** — `room`, `user`, `status`, `average_speed`, `shared_files`, `shared_folders`, `slots_full`, `country`. Everything but diff --git a/soulseek-rs/src/api.rs b/soulseek-rs/src/api.rs index 5c242f5..48d5d1e 100644 --- a/soulseek-rs/src/api.rs +++ b/soulseek-rs/src/api.rs @@ -178,6 +178,13 @@ pub trait SessionApi: Send + Sync { /// store and accepts silently. // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. fn set_download_directory(&self, directory: String) -> Result<()>; + + /// Make `password` this account's password on the server. + /// + /// `Ok(())` means the request reached the server, not that it took it: + /// the protocol's own reply to a password change is not read back. + // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. + fn change_password(&self, password: &str) -> Result<()>; } /// The local session: the library client itself, so `Arc` is already an @@ -416,4 +423,8 @@ impl SessionApi for Client { fn set_download_directory(&self, _directory: String) -> Result<()> { Ok(()) } + + fn change_password(&self, password: &str) -> Result<()> { + Self::change_password(self, password) + } } diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index ad2e2cf..0c8f581 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -222,6 +222,10 @@ pub enum Commands { /// Confirm the credentials and connection work Whoami, + /// This account: its password, and what this machine stores for it + #[command(subcommand)] + Account(AccountCommand), + /// The folders this client shares #[command(subcommand)] Shares(SharesCommand), @@ -270,6 +274,34 @@ pub enum DaemonCommand { Stop, } +#[derive(Subcommand, Debug)] +#[non_exhaustive] +pub enum AccountCommand { + /// Change the account's password on the server + Password(PasswordArgs), + + /// Forget the password stored on this machine, so the next start asks + /// for it again + Logout, +} + +#[derive(Args, Debug)] +pub struct PasswordArgs { + /// The new password (visible in `ps`; prefer --new-password-stdin) + #[arg( + long, + value_name = "PASSWORD", + env = "SOULSEEK_NEW_PASSWORD", + hide_env_values = true + )] + pub new_password: Option, + + /// Read the new password from the first line of stdin (takes precedence + /// over --new-password) + #[arg(long, action = ArgAction::SetTrue)] + pub new_password_stdin: bool, +} + #[derive(Subcommand, Debug)] #[non_exhaustive] pub enum CompletionsCommand { diff --git a/soulseek-rs/src/commands/account.rs b/soulseek-rs/src/commands/account.rs new file mode 100644 index 0000000..56e1521 --- /dev/null +++ b/soulseek-rs/src/commands/account.rs @@ -0,0 +1,59 @@ +//! The account itself: its password on the server, and the credentials this +//! machine keeps for it. + +use super::{Ctx, Session}; +use crate::cli::PasswordArgs; +use crate::output::{AccountRecord, CliError, CliResult, Out}; +use crate::persist::secret::{KeyringStore, SecretStore}; + +/// Change the account's password and store the new one, so the next start +/// still logs in. +pub fn password(ctx: &Ctx, args: &PasswordArgs) -> CliResult { + let new = new_password(args)?; + let session = Session::open(ctx)?; + let user = session.client.username(); + let stored = crate::persist::secret::change_password( + session.client.as_ref(), + &KeyringStore, + &new, + ) + .map_err(|e| { + CliError::connection(format!("cannot change the password: {e}")) + })?; + if let Some(e) = stored { + ctx.out + .warn(&format!("password changed, but not stored here: {e}")); + } + ctx.out.emit(&AccountRecord { + user, + action: "password-changed".to_string(), + }); + Ok(()) +} + +/// Forget the stored password. The account is untouched on the server; what +/// goes is this machine's copy of the way in. +pub fn logout(out: &Out, username: Option<&str>) -> CliResult { + let user = username.ok_or_else(crate::run::missing_username)?; + KeyringStore.delete(user).map_err(|e| { + CliError::usage(format!("cannot clear the stored password: {e}")) + })?; + out.emit(&AccountRecord { + user: user.to_string(), + action: "logged-out".to_string(), + }); + Ok(()) +} + +/// Stdin beats the flag, the way `--password-stdin` beats `--password`. +fn new_password(args: &PasswordArgs) -> CliResult { + if args.new_password_stdin { + return crate::run::password_from_stdin(); + } + match args.new_password.as_deref() { + Some(password) if !password.is_empty() => Ok(password.to_string()), + _ => Err(CliError::usage( + "no new password: pass --new-password or --new-password-stdin", + )), + } +} diff --git a/soulseek-rs/src/commands/mod.rs b/soulseek-rs/src/commands/mod.rs index 27df78a..c5a009e 100644 --- a/soulseek-rs/src/commands/mod.rs +++ b/soulseek-rs/src/commands/mod.rs @@ -5,6 +5,7 @@ //! first; the ones that only read the config file or the local machine are //! dispatched by `main::run` before any credentials are demanded. +pub mod account; pub mod completions; pub mod daemon; pub mod peer; @@ -15,7 +16,9 @@ pub mod transfer; pub mod wish; use crate::api::SessionApi; -use crate::cli::{Commands, MessageCommand, RoomCommand, TransferCommand}; +use crate::cli::{ + AccountCommand, Commands, MessageCommand, RoomCommand, TransferCommand, +}; use crate::output::{CliError, CliResult, Exit, Out, PortmapRecord}; use crate::port_mapping::{self, PortMapper}; use crate::remote::{Endpoint, RemoteSession}; @@ -314,9 +317,13 @@ fn dispatch(ctx: &Ctx, command: Commands) -> CliResult { Commands::User(args) => peer::user(ctx, &args), Commands::Watch(command) => peer::watch(ctx, &command), Commands::Whoami => peer::whoami(ctx), + Commands::Account(AccountCommand::Password(args)) => { + account::password(ctx, &args) + } // main.rs runs these itself: some need no account, the rest need the // config store in scope. The arms only keep the match exhaustive. - Commands::Portmap + Commands::Account(AccountCommand::Logout) + | Commands::Portmap | Commands::Serve(_) | Commands::Daemon(_) | Commands::Wish(_) diff --git a/soulseek-rs/src/daemon/mod.rs b/soulseek-rs/src/daemon/mod.rs index 0575f39..43453d9 100644 --- a/soulseek-rs/src/daemon/mod.rs +++ b/soulseek-rs/src/daemon/mod.rs @@ -197,6 +197,7 @@ pub fn run( let daemon = Arc::new(Daemon { session: Arc::clone(&session.client), + secrets: Arc::new(crate::persist::secret::KeyringStore), hub: Arc::clone(&hub), browses: Arc::clone(&browses), downloads: Arc::clone(&downloads), diff --git a/soulseek-rs/src/daemon/openrpc.rs b/soulseek-rs/src/daemon/openrpc.rs index 59e19ee..5312075 100644 --- a/soulseek-rs/src/daemon/openrpc.rs +++ b/soulseek-rs/src/daemon/openrpc.rs @@ -12,10 +12,10 @@ use super::proto::{ Ack, AuthParams, AuthResult, BrowseEvent, DaemonStatus, DirectoriesParams, DirectoryParams, DownloadStartParams, DownloadStarted, DownloadStatusEvent, Downloads, Event, IntervalSeconds, MemberStats, Members, MessageParams, - Messages, Method, QueryParams, RoomEventDto, RoomRef, SayParams, - SearchResults, Searches, Seconds, SessionLossEvent, SharesStatus, - SlotsParams, TransferRef, UploadInfoDto, Uploads, UserMessageDto, UserRef, - UserResult, Watched, + Messages, Method, PasswordParams, QueryParams, RoomEventDto, RoomRef, + SayParams, SearchResults, Searches, Seconds, SessionLossEvent, + SharesStatus, SlotsParams, TransferRef, UploadInfoDto, Uploads, + UserMessageDto, UserRef, UserResult, Watched, }; use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings}; use serde_json::{Value, json}; @@ -47,6 +47,7 @@ fn params_schema(method: Method, generator: &mut SchemaGenerator) -> Value { | Method::DownloadCancel | Method::UploadCancel => schema_of::(generator), Method::DownloadSetDir => schema_of::(generator), + Method::AccountPassword => schema_of::(generator), Method::UploadSlots => schema_of::(generator), Method::RoomJoin | Method::RoomLeave @@ -111,6 +112,7 @@ fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value { | Method::DownloadRemoveQueued | Method::DownloadCancel | Method::DownloadSetDir + | Method::AccountPassword | Method::UploadCancel | Method::UploadSlots | Method::PrivilegesCheck @@ -170,6 +172,10 @@ fn summary(method: Method) -> &'static str { Method::DownloadSetDir => { "Land future transfers in a different folder on the daemon's host." } + Method::AccountPassword => { + "Change the password of the account the daemon is logged in as, \ + and store it on the daemon's host so a restart still logs in." + } Method::UploadList => "Uploads being served, active first.", Method::UploadCancel => "Stop serving an upload in progress.", Method::UploadSlots => "How many uploads may run at once.", diff --git a/soulseek-rs/src/daemon/proto/method.rs b/soulseek-rs/src/daemon/proto/method.rs index af379e0..48f4976 100644 --- a/soulseek-rs/src/daemon/proto/method.rs +++ b/soulseek-rs/src/daemon/proto/method.rs @@ -82,6 +82,15 @@ pub struct DirectoriesParams { pub directories: Vec, } +/// A new password for the account the daemon is logged in as. Plain text on +/// the wire, like the token: the transport is the local socket, or TCP behind +/// an SSH tunnel (see docs/daemon-protocol.md). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct PasswordParams { + pub password: String, +} + /// Where the daemon should land transfers from now on, on *its* filesystem. /// The same trust as `shares.set`: an authenticated caller is the owner. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] diff --git a/soulseek-rs/src/daemon/proto/mod.rs b/soulseek-rs/src/daemon/proto/mod.rs index e0e243a..9f254f4 100644 --- a/soulseek-rs/src/daemon/proto/mod.rs +++ b/soulseek-rs/src/daemon/proto/mod.rs @@ -111,6 +111,7 @@ wire_vocabulary! { UserWatch => "user.watch", UserUnwatch => "user.unwatch", UserWatched => "user.watched", + AccountPassword => "account.password", SharesStatusOf => "shares.status", SharesSet => "shares.set", SharesReindex => "shares.reindex", diff --git a/soulseek-rs/src/daemon/server/mod.rs b/soulseek-rs/src/daemon/server/mod.rs index 37bd4e2..0396798 100644 --- a/soulseek-rs/src/daemon/server/mod.rs +++ b/soulseek-rs/src/daemon/server/mod.rs @@ -11,13 +11,14 @@ use super::proto::{ DaemonStatus, DirectoriesParams, DirectoryParams, DownloadDto, DownloadStartParams, DownloadStarted, Downloads, IntervalSeconds, MemberStats, Members, MessageParams, Messages, Method, OPENRPC, - PROTOCOL_VERSION, QueryParams, RoomRef, RoomUserStatsDto, RpcError, - SayParams, SearchResultDto, SearchResults, SearchSummary, Searches, - Seconds, SharesStatus, SlotsParams, TransferRef, UploadInfoDto, Uploads, - UserInfoDto, UserRef, UserResult, Watched, + PROTOCOL_VERSION, PasswordParams, QueryParams, RoomRef, RoomUserStatsDto, + RpcError, SayParams, SearchResultDto, SearchResults, SearchSummary, + Searches, Seconds, SharesStatus, SlotsParams, TransferRef, UploadInfoDto, + Uploads, UserInfoDto, UserRef, UserResult, Watched, }; use crate::api::SessionApi; use crate::output::Exit; +use crate::persist::secret::SecretStore; use serde::de::DeserializeOwned; use serde_json::Value; use std::sync::atomic::{AtomicBool, Ordering}; @@ -27,6 +28,9 @@ use std::time::{Duration, Instant}; /// Everything a connection needs to answer a request. pub struct Daemon { pub session: Arc, + /// Where this host keeps the account's password. The daemon owns the + /// login, so a password it changes it also has to store. + pub secrets: Arc, pub hub: Arc, pub browses: Arc, pub downloads: Arc>>, @@ -346,6 +350,27 @@ impl Daemon { users: self.session.watched_users(), }), + Method::AccountPassword => { + let params: PasswordParams = parse(params)?; + // The daemon logs in again from this host's store when it + // restarts, so a password it changed but never stored would + // lock it out of its own account. + let stored = crate::persist::secret::change_password( + self.session.as_ref(), + self.secrets.as_ref(), + ¶ms.password, + ) + .map_err(|e| { + RpcError::application(Exit::Connection, e.to_string()) + })?; + if let Some(e) = stored { + soulseek_rs::warn!( + "Password changed, but not stored on this host: {e}" + ); + } + ok(Ack::OK) + } + Method::SharesStatusOf => ok(self.shares()), Method::SharesSet => { let directories: DirectoriesParams = parse(params)?; @@ -656,6 +681,32 @@ mod tests { assert_eq!(stats["members"], json!([])); } + /// The daemon owns the login, so a password it changes it also stores — + /// its next start logs in from that store, not from the client that + /// asked. + #[test] + fn a_password_change_reaches_the_session_and_this_hosts_store() { + let session = Arc::new(SilentSession::default()); + let daemon = daemon_with("t", Arc::clone(&session)); + + daemon + .call(Method::AccountPassword, json!({ "password": "hunter2" })) + .expect("the password change is served"); + + assert_eq!( + *session.password.lock().expect("not poisoned"), + Some("hunter2".to_string()) + ); + assert_eq!( + daemon + .secrets + .get(&session.username()) + .expect("the store answers") + .as_deref(), + Some("hunter2") + ); + } + #[test] fn a_watch_is_readable_back_and_survives_until_unwatched() { // The three methods are only useful together: what `user.watch` @@ -719,14 +770,23 @@ mod tests { #[derive(Default)] struct SilentSession { watched: Mutex>, + /// What `account.password` asked the session to set, if anything. + password: Mutex>, } /// The one room `SilentSession` claims to have joined. const STUB_ROOM: &str = "jazz"; fn daemon_with_token(token: &str) -> Daemon { + daemon_with(token, Arc::new(SilentSession::default())) + } + + /// A daemon over a session the caller can read back, with an in-memory + /// secret store: no test may touch the machine's keychain. + fn daemon_with(token: &str, session: Arc) -> Daemon { Daemon { - session: Arc::new(SilentSession::default()), + session, + secrets: Arc::new(crate::persist::secret::MemoryStore::default()), hub: Arc::new(Hub::new()), browses: Arc::new(PendingBrowses::default()), downloads: Arc::new(Mutex::new(Vec::new())), @@ -745,6 +805,11 @@ mod tests { fn username(&self) -> String { "tester".to_string() } + fn change_password(&self, password: &str) -> soulseek_rs::Result<()> { + *self.password.lock().expect("not poisoned") = + Some(password.to_string()); + Ok(()) + } fn listen_port(&self) -> Option { None } diff --git a/soulseek-rs/src/models/app_state.rs b/soulseek-rs/src/models/app_state.rs index 5f2c4f6..a97f16e 100644 --- a/soulseek-rs/src/models/app_state.rs +++ b/soulseek-rs/src/models/app_state.rs @@ -222,6 +222,15 @@ impl WrappedLog { } } +/// Why the window closed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TuiExit { + /// The program is done. + Quit, + /// The account was logged out: the caller shows the login screen again. + Logout, +} + /// What the shared command bar is currently capturing input for. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CommandBarMode { @@ -286,7 +295,8 @@ pub struct AppState { /// Where the keys overlay is being read, for a terminal too short to /// show it whole. pub help_view: LogView, - pub should_exit: bool, + /// Set once, when the window is closing, to why. + pub exit: Option, pub command_bar_active: bool, pub command_bar_input: String, pub command_bar_cursor_position: usize, @@ -382,7 +392,7 @@ impl AppState { layout: PaneLayout::default(), show_help: false, help_view: LogView::default(), - should_exit: false, + exit: None, command_bar_active: false, command_bar_input: String::new(), command_bar_cursor_position: 0, diff --git a/soulseek-rs/src/models/mod.rs b/soulseek-rs/src/models/mod.rs index 2994800..69f5710 100644 --- a/soulseek-rs/src/models/mod.rs +++ b/soulseek-rs/src/models/mod.rs @@ -6,7 +6,8 @@ mod settings; pub use app_state::{ AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView, - MessageDirection, PaneLayout, SearchEntry, SearchStatus, WrappedLog, + MessageDirection, PaneLayout, SearchEntry, SearchStatus, TuiExit, + WrappedLog, }; pub use browse::{BrowseState, BrowseStatus, BrowseTabs}; pub use file_display_data::FileDisplayData; @@ -14,4 +15,6 @@ pub use rooms::{ ChatFilter, OpenRoom, RoomLine, RoomsState, RoomsView, contains_filter, matching_users, }; -pub use settings::{SettingsAction, SettingsMode, SettingsState}; +pub use settings::{ + AccountInfo, SettingsAction, SettingsMode, SettingsRow, SettingsState, +}; diff --git a/soulseek-rs/src/models/settings.rs b/soulseek-rs/src/models/settings.rs index 191378e..d892d99 100644 --- a/soulseek-rs/src/models/settings.rs +++ b/soulseek-rs/src/models/settings.rs @@ -1,9 +1,9 @@ -//! State machine for the settings popup (download folder + share paths). +//! State machine for the settings popup (account, download folder, shares). //! Pure — no client or terminal — so every transition is unit-testable. use ratatui::crossterm::event::{KeyCode, KeyEvent}; -/// Which row is highlighted: 0 = download dir, 1.. = share paths. +/// What the popup is doing with the keyboard. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SettingsMode { /// Moving between rows. @@ -12,10 +12,39 @@ pub enum SettingsMode { EditingDownloadDir, /// Typing a new share path to add. AddingShare, + /// Typing a new password; the keystrokes are masked. + NewPassword, + /// Typing it again, with the first entry held here. A password reaches the + /// server unconfirmed, so a typo entered once would change the account to + /// something nobody knows. + RepeatPassword(String), + /// Waiting for "log out?" to be answered. + ConfirmingLogout, +} + +impl SettingsMode { + /// Whether what is being typed must not be shown. + const fn is_secret(&self) -> bool { + matches!(self, Self::NewPassword | Self::RepeatPassword(_)) + } +} + +/// One line the selection can rest on. +/// +/// The rows are derived from the state rather than counted, because the logout +/// row is absent when the session belongs to a daemon and index arithmetic +/// would silently shift with it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsRow { + ChangePassword, + Logout, + DownloadDir, + /// Share path at this index. + Share(usize), } /// What the TUI should do after a key was handled. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum SettingsAction { None, /// Settings changed: persist config and apply the new share set live. @@ -24,12 +53,33 @@ pub enum SettingsAction { Reindex, /// Close the popup. Close, + /// Make this the account's password. + ChangePassword(String), + /// End the session and forget the stored password. + Logout, +} + +/// Who the session is logged in as and what it offers the network. +/// +/// Captured when the popup opens rather than read while drawing: attached to a +/// daemon every one of these is a round trip, and the popup redraws each tick. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountInfo { + pub username: String, + /// Already formatted, e.g. "1234 files in 56 folders". + pub shares: String, + /// The port peers can reach us on, when the listener is up. + pub listen_port: Option, + /// The daemon this session is borrowed from. `Some` means the account is + /// the daemon's, so this window cannot log it out. + pub daemon: Option, } pub struct SettingsState { + pub account: AccountInfo, pub download_dir: String, pub share_dirs: Vec, - /// 0 = download dir row; 1 + i = share path i. + /// Index into [`Self::rows`]. pub selected: usize, pub mode: SettingsMode, /// Edit buffer while typing. @@ -40,8 +90,13 @@ pub struct SettingsState { impl SettingsState { #[must_use] - pub const fn new(download_dir: String, share_dirs: Vec) -> Self { + pub const fn new( + account: AccountInfo, + download_dir: String, + share_dirs: Vec, + ) -> Self { Self { + account, download_dir, share_dirs, selected: 0, @@ -51,16 +106,42 @@ impl SettingsState { } } - const fn rows(&self) -> usize { - 1 + self.share_dirs.len() + /// The daemon owns its own login; a window borrowing it can only detach. + #[must_use] + pub const fn can_log_out(&self) -> bool { + self.account.daemon.is_none() + } + + /// Every row, in the order they are drawn. + #[must_use] + pub fn rows(&self) -> Vec { + let mut rows = vec![SettingsRow::ChangePassword]; + if self.can_log_out() { + rows.push(SettingsRow::Logout); + } + rows.push(SettingsRow::DownloadDir); + rows.extend((0..self.share_dirs.len()).map(SettingsRow::Share)); + rows + } + + fn last_row(&self) -> usize { + self.rows().len() - 1 + } + + /// The row the selection rests on. + #[must_use] + pub fn selected_row(&self) -> SettingsRow { + self.rows()[self.selected.min(self.last_row())] } pub fn handle_key(&mut self, key: KeyEvent) -> SettingsAction { match self.mode { SettingsMode::Navigate => self.handle_navigate(key), - SettingsMode::EditingDownloadDir | SettingsMode::AddingShare => { - self.handle_typing(key) - } + SettingsMode::ConfirmingLogout => self.handle_logout_confirm(key), + SettingsMode::EditingDownloadDir + | SettingsMode::AddingShare + | SettingsMode::NewPassword + | SettingsMode::RepeatPassword(_) => self.handle_typing(key), } } @@ -73,22 +154,28 @@ impl SettingsState { SettingsAction::None } KeyCode::Down | KeyCode::Char('j') => { - self.selected = (self.selected + 1).min(self.rows() - 1); + self.selected = (self.selected + 1).min(self.last_row()); SettingsAction::None } - KeyCode::Enter | KeyCode::Char('e') if self.selected == 0 => { - self.mode = SettingsMode::EditingDownloadDir; - self.input = self.download_dir.clone(); - SettingsAction::None + KeyCode::Enter => self.activate(), + // 'e' has always meant "edit this folder"; it must not reach a row + // that logs the account out. + KeyCode::Char('e') + if self.selected_row() == SettingsRow::DownloadDir => + { + self.activate() } KeyCode::Char('a') => { self.mode = SettingsMode::AddingShare; self.input.clear(); SettingsAction::None } - KeyCode::Char('d') if self.selected > 0 => { - self.share_dirs.remove(self.selected - 1); - self.selected = self.selected.min(self.rows() - 1); + KeyCode::Char('d') => { + let SettingsRow::Share(index) = self.selected_row() else { + return SettingsAction::None; + }; + self.share_dirs.remove(index); + self.selected = self.selected.min(self.last_row()); SettingsAction::Apply } KeyCode::Char('r') => SettingsAction::Reindex, @@ -96,6 +183,31 @@ impl SettingsState { } } + fn activate(&mut self) -> SettingsAction { + match self.selected_row() { + SettingsRow::ChangePassword => { + self.mode = SettingsMode::NewPassword; + // Defensive: a password buffer is never left holding anything. + self.input.clear(); + } + SettingsRow::Logout => self.mode = SettingsMode::ConfirmingLogout, + SettingsRow::DownloadDir => { + self.mode = SettingsMode::EditingDownloadDir; + self.input.clone_from(&self.download_dir); + } + SettingsRow::Share(_) => {} + } + SettingsAction::None + } + + fn handle_logout_confirm(&mut self, key: KeyEvent) -> SettingsAction { + self.mode = SettingsMode::Navigate; + if matches!(key.code, KeyCode::Char('y' | 'Y') | KeyCode::Enter) { + return SettingsAction::Logout; + } + SettingsAction::None + } + fn handle_typing(&mut self, key: KeyEvent) -> SettingsAction { match key.code { KeyCode::Esc => { @@ -103,25 +215,7 @@ impl SettingsState { self.input.clear(); SettingsAction::None } - KeyCode::Enter => { - let value = self.input.trim().to_string(); - let adding = self.mode == SettingsMode::AddingShare; - self.mode = SettingsMode::Navigate; - self.input.clear(); - if value.is_empty() { - return SettingsAction::None; - } - if adding { - if self.share_dirs.contains(&value) { - return SettingsAction::None; - } - self.share_dirs.push(value); - self.selected = self.rows() - 1; - } else { - self.download_dir = value; - } - SettingsAction::Apply - } + KeyCode::Enter => self.submit(), KeyCode::Backspace => { self.input.pop(); SettingsAction::None @@ -133,6 +227,48 @@ impl SettingsState { _ => SettingsAction::None, } } + + fn submit(&mut self) -> SettingsAction { + if self.mode.is_secret() { + return self.submit_password(); + } + let value = self.input.trim().to_string(); + let adding = self.mode == SettingsMode::AddingShare; + self.mode = SettingsMode::Navigate; + self.input.clear(); + if value.is_empty() { + return SettingsAction::None; + } + if adding { + if self.share_dirs.contains(&value) { + return SettingsAction::None; + } + self.share_dirs.push(value); + self.selected = self.last_row(); + } else { + self.download_dir = value; + } + SettingsAction::Apply + } + + /// The first entry is only remembered; the second is what commits. + fn submit_password(&mut self) -> SettingsAction { + let typed = std::mem::take(&mut self.input); + let SettingsMode::RepeatPassword(first) = + std::mem::replace(&mut self.mode, SettingsMode::Navigate) + else { + if !typed.is_empty() { + self.mode = SettingsMode::RepeatPassword(typed); + } + return SettingsAction::None; + }; + if typed != first { + self.status = + Some("The two passwords differ — nothing was changed".into()); + return SettingsAction::None; + } + SettingsAction::ChangePassword(typed) + } } #[cfg(test)] @@ -150,8 +286,30 @@ mod tests { } } + fn account() -> AccountInfo { + AccountInfo { + username: "alice".into(), + shares: "12 files in 3 folders".into(), + listen_port: Some(2234), + daemon: None, + } + } + fn state() -> SettingsState { - SettingsState::new("/dl".into(), vec!["/dl".into(), "/music".into()]) + SettingsState::new( + account(), + "/dl".into(), + vec!["/dl".into(), "/music".into()], + ) + } + + /// Put the selection on a row by name, whatever index it has. + fn select(state: &mut SettingsState, row: SettingsRow) { + state.selected = state + .rows() + .iter() + .position(|candidate| *candidate == row) + .expect("the row is present"); } #[test] @@ -172,6 +330,7 @@ mod tests { assert_eq!(action, SettingsAction::Apply); assert_eq!(s.share_dirs, vec!["/dl", "/music", "/flacs"]); assert_eq!(s.mode, SettingsMode::Navigate); + assert_eq!(s.selected_row(), SettingsRow::Share(2)); } #[test] @@ -186,16 +345,16 @@ mod tests { #[test] fn deleting_the_selected_share_applies() { let mut s = state(); - s.handle_key(key(KeyCode::Down)); // select share 0 - s.handle_key(key(KeyCode::Down)); // select share 1 (/music) + select(&mut s, SettingsRow::Share(1)); let action = s.handle_key(key(KeyCode::Char('d'))); assert_eq!(action, SettingsAction::Apply); assert_eq!(s.share_dirs, vec!["/dl"]); } #[test] - fn delete_on_the_download_dir_row_does_nothing() { + fn delete_on_a_row_that_is_not_a_share_does_nothing() { let mut s = state(); + select(&mut s, SettingsRow::DownloadDir); assert_eq!(s.handle_key(key(KeyCode::Char('d'))), SettingsAction::None); assert_eq!(s.share_dirs.len(), 2); } @@ -203,6 +362,7 @@ mod tests { #[test] fn editing_the_download_dir_applies() { let mut s = state(); + select(&mut s, SettingsRow::DownloadDir); s.handle_key(key(KeyCode::Enter)); assert_eq!(s.mode, SettingsMode::EditingDownloadDir); assert_eq!(s.input, "/dl"); @@ -217,6 +377,7 @@ mod tests { #[test] fn escape_cancels_an_edit_without_applying() { let mut s = state(); + select(&mut s, SettingsRow::DownloadDir); s.handle_key(key(KeyCode::Enter)); type_str(&mut s, "junk"); assert_eq!(s.handle_key(key(KeyCode::Esc)), SettingsAction::None); @@ -238,10 +399,112 @@ mod tests { for _ in 0..10 { s.handle_key(key(KeyCode::Down)); } - assert_eq!(s.selected, 2); + assert_eq!(s.selected_row(), SettingsRow::Share(1)); for _ in 0..10 { s.handle_key(key(KeyCode::Up)); } - assert_eq!(s.selected, 0); + assert_eq!(s.selected_row(), SettingsRow::ChangePassword); + } + + #[test] + fn a_password_typed_twice_the_same_way_is_changed() { + let mut s = state(); + select(&mut s, SettingsRow::ChangePassword); + s.handle_key(key(KeyCode::Enter)); + assert_eq!(s.mode, SettingsMode::NewPassword); + type_str(&mut s, "hunter2"); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None); + assert!(matches!(s.mode, SettingsMode::RepeatPassword(_))); + // The first entry is not left lying in the buffer the popup draws. + assert!(s.input.is_empty()); + type_str(&mut s, "hunter2"); + assert_eq!( + s.handle_key(key(KeyCode::Enter)), + SettingsAction::ChangePassword("hunter2".into()) + ); + assert_eq!(s.mode, SettingsMode::Navigate); + } + + #[test] + fn a_mistyped_repeat_changes_nothing_and_says_so() { + let mut s = state(); + select(&mut s, SettingsRow::ChangePassword); + s.handle_key(key(KeyCode::Enter)); + type_str(&mut s, "hunter2"); + s.handle_key(key(KeyCode::Enter)); + type_str(&mut s, "hunter3"); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None); + assert_eq!(s.mode, SettingsMode::Navigate); + assert!(s.status.is_some()); + } + + #[test] + fn an_empty_password_is_not_asked_for_twice() { + let mut s = state(); + select(&mut s, SettingsRow::ChangePassword); + s.handle_key(key(KeyCode::Enter)); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None); + assert_eq!(s.mode, SettingsMode::Navigate); + } + + #[test] + fn escape_during_the_repeat_forgets_the_first_entry() { + let mut s = state(); + select(&mut s, SettingsRow::ChangePassword); + s.handle_key(key(KeyCode::Enter)); + type_str(&mut s, "hunter2"); + s.handle_key(key(KeyCode::Enter)); + s.handle_key(key(KeyCode::Esc)); + // Starting over asks for both entries again rather than matching the + // abandoned one. + s.handle_key(key(KeyCode::Enter)); + type_str(&mut s, "hunter2"); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None); + assert!(matches!(s.mode, SettingsMode::RepeatPassword(_))); + } + + #[test] + fn logging_out_is_confirmed_first() { + let mut s = state(); + select(&mut s, SettingsRow::Logout); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None); + assert_eq!(s.mode, SettingsMode::ConfirmingLogout); + assert_eq!( + s.handle_key(key(KeyCode::Char('y'))), + SettingsAction::Logout + ); + } + + #[test] + fn anything_but_yes_cancels_the_logout() { + let mut s = state(); + select(&mut s, SettingsRow::Logout); + s.handle_key(key(KeyCode::Enter)); + assert_eq!(s.handle_key(key(KeyCode::Char('n'))), SettingsAction::None); + assert_eq!(s.mode, SettingsMode::Navigate); + } + + #[test] + fn a_daemons_session_offers_no_logout_row() { + let mut s = SettingsState::new( + AccountInfo { + daemon: Some("127.0.0.1:2245".into()), + ..account() + }, + "/dl".into(), + vec!["/music".into()], + ); + assert_eq!( + s.rows(), + vec![ + SettingsRow::ChangePassword, + SettingsRow::DownloadDir, + SettingsRow::Share(0), + ] + ); + // The row below "change password" is the download folder, not a + // logout the window cannot perform. + s.handle_key(key(KeyCode::Down)); + assert_eq!(s.selected_row(), SettingsRow::DownloadDir); } } diff --git a/soulseek-rs/src/output.rs b/soulseek-rs/src/output.rs index abe9d61..21d6aa4 100644 --- a/soulseek-rs/src/output.rs +++ b/soulseek-rs/src/output.rs @@ -345,6 +345,20 @@ impl Record for WhoamiRecord { } } +/// What happened to this account. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AccountRecord { + pub user: String, + /// `password-changed` or `logged-out`. + pub action: String, +} + +impl Record for AccountRecord { + fn text(&self) -> String { + format!("{}\t{}", sanitize(&self.action), sanitize(&self.user)) + } +} + /// What the server knows about another user. /// Each field is `None` when the server has not answered that half yet, so a /// consumer never reads "offline" or "shares nothing" from a missing reply. diff --git a/soulseek-rs/src/persist/secret.rs b/soulseek-rs/src/persist/secret.rs index 034fc90..7d41f1a 100644 --- a/soulseek-rs/src/persist/secret.rs +++ b/soulseek-rs/src/persist/secret.rs @@ -1,11 +1,18 @@ +use crate::api::SessionApi; use color_eyre::Result; /// Where passwords live. The real implementation is the OS keychain /// ([`KeyringStore`]); tests use an in-memory fake. Passwords are never /// written to config.toml. -pub trait SecretStore { +/// +/// `Send + Sync` because the daemon holds one behind an `Arc` and answers +/// requests on a thread per connection. +pub trait SecretStore: Send + Sync { fn get(&self, username: &str) -> Result>; fn set(&self, username: &str, password: &str) -> Result<()>; + /// Forget this account's password. Succeeds when there was nothing + /// stored: the caller wants it gone, and it is. + fn delete(&self, username: &str) -> Result<()>; } /// OS keychain (macOS Keychain / Windows Credential Manager / Linux Secret @@ -30,6 +37,40 @@ impl SecretStore for KeyringStore { .and_then(|entry| entry.set_password(password)) .map_err(|e| color_eyre::eyre::eyre!("keyring: {e}")) } + + fn delete(&self, username: &str) -> Result<()> { + match keyring::Entry::new(SERVICE, username) + .and_then(|entry| entry.delete_credential()) + { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(color_eyre::eyre::eyre!("keyring: {e}")), + } + } +} + +/// Change the account's password and keep the local copy in step, so the next +/// start still logs in. +/// +/// One rule in one place: whoever owns the login owns the stored copy. A +/// window or a command borrowing a daemon's session owns neither, and the +/// daemon writes its own host's store when it serves the request. +/// +/// `Ok` means the request reached the server, not that the server took it — +/// Soulseek answers a password change with nothing at all — and carries the +/// store's complaint when the change went out but the local copy did not. +pub fn change_password( + session: &dyn SessionApi, + secrets: &dyn SecretStore, + password: &str, +) -> soulseek_rs::Result> { + session.change_password(password)?; + if session.daemon_endpoint().is_some() { + return Ok(None); + } + Ok(secrets + .set(&session.username(), password) + .err() + .map(|e| e.to_string())) } /// Resolve the password with precedence: CLI/env > keychain > `password_cmd`. @@ -72,33 +113,48 @@ fn run_password_cmd(cmd: &str) -> Option { (!password.is_empty()).then(|| password.to_string()) } +/// An in-memory store for tests, so no test touches the machine's keychain. +#[cfg(test)] +#[derive(Default)] +pub struct MemoryStore { + secrets: std::sync::Mutex>, + /// Answer every read as if there were no secret service at all. + pub fail: bool, +} + +#[cfg(test)] +impl SecretStore for MemoryStore { + fn get(&self, username: &str) -> Result> { + if self.fail { + return Err(color_eyre::eyre::eyre!("no secret service")); + } + Ok(self + .secrets + .lock() + .expect("not poisoned") + .get(username) + .cloned()) + } + + fn set(&self, username: &str, password: &str) -> Result<()> { + self.secrets + .lock() + .expect("not poisoned") + .insert(username.into(), password.into()); + Ok(()) + } + + fn delete(&self, username: &str) -> Result<()> { + self.secrets.lock().expect("not poisoned").remove(username); + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; - use std::cell::RefCell; - use std::collections::HashMap; - #[derive(Default)] - struct FakeStore { - secrets: RefCell>, - fail: bool, - } - - impl SecretStore for FakeStore { - fn get(&self, username: &str) -> Result> { - if self.fail { - return Err(color_eyre::eyre::eyre!("no secret service")); - } - Ok(self.secrets.borrow().get(username).cloned()) - } - - fn set(&self, username: &str, password: &str) -> Result<()> { - self.secrets - .borrow_mut() - .insert(username.into(), password.into()); - Ok(()) - } - } + type FakeStore = MemoryStore; #[test] fn cli_password_wins_over_everything() { @@ -156,6 +212,16 @@ mod tests { assert_eq!(resolve_password(None, Some("alice"), None, &store), None); } + #[test] + fn a_deleted_password_is_no_longer_resolved() { + let store = FakeStore::default(); + store.set("alice", "from-keyring").unwrap(); + store.delete("alice").unwrap(); + assert_eq!(resolve_password(None, Some("alice"), None, &store), None); + // Deleting again is not an error: the password is gone either way. + store.delete("alice").unwrap(); + } + #[test] fn no_username_means_no_keyring_lookup() { let store = FakeStore { diff --git a/soulseek-rs/src/remote/mod.rs b/soulseek-rs/src/remote/mod.rs index 435d4b0..73d4ee1 100644 --- a/soulseek-rs/src/remote/mod.rs +++ b/soulseek-rs/src/remote/mod.rs @@ -13,9 +13,9 @@ use crate::daemon::proto::{ DaemonStatus, DirectoriesParams, DirectoryParams, DownloadDto, DownloadStartParams, DownloadStarted, Downloads, Event, IntervalSeconds, MemberStats, Members, MessageParams, Messages, Method, PROTOCOL_VERSION, - QueryParams, Request, Response, RoomRef, RpcError, SayParams, - SearchResults, Searches, Seconds, SharesStatus, SlotsParams, TransferRef, - Uploads, UserRef, UserResult, Watched, + PasswordParams, QueryParams, Request, Response, RoomRef, RpcError, + SayParams, SearchResults, Searches, Seconds, SharesStatus, SlotsParams, + TransferRef, Uploads, UserRef, UserResult, Watched, }; use mirror::Mirror; use serde::de::DeserializeOwned; @@ -830,6 +830,15 @@ impl SessionApi for RemoteSession { fn set_download_directory(&self, directory: String) -> Result<()> { self.tell(Method::DownloadSetDir, DirectoryParams { directory }) } + + fn change_password(&self, password: &str) -> Result<()> { + self.tell( + Method::AccountPassword, + PasswordParams { + password: password.to_string(), + }, + ) + } } #[cfg(test)] diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs index 11c2c20..2a0ea7b 100644 --- a/soulseek-rs/src/run.rs +++ b/soulseek-rs/src/run.rs @@ -6,7 +6,9 @@ use crate::cli::{ parse_server_address, }; use crate::commands::Ctx; +use crate::models::TuiExit; use crate::output::{CliError, CliResult, Exit, Out}; +use crate::persist::secret::SecretStore; use crate::ui::launch_main_tui; use soulseek_rs::{ClientSettings, ClientVersion, PeerAddress}; use std::io::{BufRead, IsTerminal}; @@ -57,7 +59,7 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { if let Some(endpoint) = daemon_endpoint(&cli, &resolved) { return run_attached_tui(&resolved, &endpoint, config_path); } - return run_default_tui(&cli, &resolved, config_path, &file_config); + return run_default_tui(&cli, &resolved, config_path); }; // Anything that only reads the network stack or the config file runs @@ -74,6 +76,14 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { Commands::Skills(ref command) => { return crate::commands::skills::run(out, command); } + // Forgetting a password needs no session — least of all the one it is + // about to make unusable. + Commands::Account(crate::cli::AccountCommand::Logout) => { + return crate::commands::account::logout( + out, + resolved.username.as_deref(), + ); + } Commands::Completions(ref command) => { return crate::commands::completions::run(out, command); } @@ -211,12 +221,7 @@ fn context( (String::new(), String::new()) } else { ( - resolved.username.clone().ok_or_else(|| { - CliError::usage( - "no username: pass --username, set SOULSEEK_USERNAME, or \ - put one in config.toml", - ) - })?, + resolved.username.clone().ok_or_else(missing_username)?, password(cli, resolved)?.ok_or_else(|| { CliError::usage( "no password: pass --password/--password-stdin, set \ @@ -325,16 +330,7 @@ fn password( resolved: &crate::persist::config::Resolved, ) -> CliResult> { if cli.password_stdin { - let mut line = String::new(); - io::stdin() - .lock() - .read_line(&mut line) - .map_err(|e| CliError::usage(format!("cannot read stdin: {e}")))?; - let line = line.trim_end_matches(['\r', '\n']); - if line.is_empty() { - return Err(CliError::usage("no password on stdin")); - } - return Ok(Some(line.to_string())); + return password_from_stdin().map(Some); } Ok(crate::persist::secret::resolve_password( cli.password.as_deref(), @@ -344,6 +340,29 @@ fn password( )) } +/// Read a password from the first line of stdin, the way `docker login` does. +pub fn password_from_stdin() -> CliResult { + let mut line = String::new(); + io::stdin() + .lock() + .read_line(&mut line) + .map_err(|e| CliError::usage(format!("cannot read stdin: {e}")))?; + let line = line.trim_end_matches(['\r', '\n']); + if line.is_empty() { + return Err(CliError::usage("no password on stdin")); + } + Ok(line.to_string()) +} + +/// What to say when nothing supplied a username. +#[must_use] +pub fn missing_username() -> CliError { + CliError::usage( + "no username: pass --username, set SOULSEEK_USERNAME, or put one in \ + config.toml", + ) +} + /// Validate the configured shares. A bad entry is a warning, not a failure: /// the client simply shares less. fn shared_directories( @@ -403,12 +422,11 @@ fn enter_terminal() -> ratatui::DefaultTerminal { /// Run the interactive TUI (the default no-subcommand path): bring the /// terminal up first, run the login/registration screen (skipped past when /// stored credentials work), persist whatever logged in, then enter the -/// main UI. +/// main UI. Logging out from the settings popup starts the round again. fn run_default_tui( cli: &Cli, resolved: &crate::persist::config::Resolved, config_path: Option, - file_config: &crate::persist::config::FileConfig, ) -> CliResult { let out = Out::new(false, false); let (server_host, server_port) = @@ -426,10 +444,7 @@ fn run_default_tui( let shared_directories = shared_directories(resolved, &out); let secret_store = crate::persist::secret::KeyringStore; - let initial_password = password(cli, resolved)?; - - // Enable logger buffering BEFORE connection to prevent log artifacts - soulseek_rs::utils::logger::enable_buffering(); + let mut initial_password = password(cli, resolved)?; let enable_listen = !resolved.disable_listener; let listen_port = resolved.listener_port; @@ -446,57 +461,80 @@ fn run_default_tui( version: ClientVersion::REFERENCE_CLIENT, }; - let mut terminal = enter_terminal(); + // Logging out closes the window and comes straight back to the login + // screen, so one pass of this loop is one session: terminal, credentials, + // main UI. The port mapping and the state store are dropped at the end of + // the pass; the client goes with the last worker thread still holding it, + // so a search or browse in flight can keep the old session open a little + // past the logout. + let mut username = resolved.username.clone(); + loop { + // Enable logger buffering BEFORE connection to prevent log artifacts. + // The window turns it off again when it gives the terminal back. + soulseek_rs::utils::logger::enable_buffering(); + let mut terminal = enter_terminal(); - let outcome = crate::ui::login::run_login_flow( - &mut terminal, - &make_settings, - resolved.username.clone(), - initial_password, - ); + let outcome = crate::ui::login::run_login_flow( + &mut terminal, + &make_settings, + username.clone(), + initial_password.take(), + ); - let outcome = match outcome { - Ok(Some(outcome)) => outcome, - Ok(None) => { - // User cancelled at the login screen. - ratatui::restore(); - soulseek_rs::utils::logger::disable_buffering(); + let outcome = match outcome { + Ok(Some(outcome)) => outcome, + // The user cancelled at the login screen. + Ok(None) => return leave_terminal(Ok(())), + Err(e) => { + return leave_terminal(Err(CliError::new( + Exit::Failure, + e.to_string(), + ))); + } + }; + + persist_credentials(&outcome, config_path.as_deref(), &secret_store); + username = Some(outcome.username.clone()); + + // Best-effort: make ourselves reachable behind a home router so + // firewalled peers can connect back. Mapped only once the listener is + // up, and for the port it really bound. Kept alive for the session. + let _port_mapper = outcome + .client + .listen_port() + .map(crate::port_mapping::PortMapper::spawn); + + let store = crate::persist::paths::state_dir() + .map(crate::persist::state::StateStore::new); + + let exit = launch_main_tui( + terminal, + Arc::new(outcome.client), + resolved.download_dir.clone(), + Duration::from_secs(resolved.search_timeout), + store, + config_path.clone(), + ) + .map_err(|e| CliError::new(Exit::Failure, e.to_string()))?; + + if exit == TuiExit::Quit { return Ok(()); } - Err(e) => { - ratatui::restore(); - soulseek_rs::utils::logger::disable_buffering(); - return Err(CliError::new(Exit::Failure, e.to_string())); + + // Logged out, so the stored password goes with the session: this pass + // is already asking for one, but the next start must ask too rather + // than walk straight back in. + if let Err(e) = secret_store.delete(&outcome.username) { + out.warn(&format!("could not clear the stored password: {e}")); } - }; + } +} - persist_credentials( - &outcome, - config_path.as_deref(), - file_config, - &secret_store, - ); - - // Best-effort: make ourselves reachable behind a home router so firewalled - // peers can connect back. Mapped only once the listener is up, and for the - // port it really bound. Kept alive for the session. - let _port_mapper = outcome - .client - .listen_port() - .map(crate::port_mapping::PortMapper::spawn); - - let store = crate::persist::paths::state_dir() - .map(crate::persist::state::StateStore::new); - - launch_main_tui( - terminal, - Arc::new(outcome.client), - resolved.download_dir.clone(), - Duration::from_secs(resolved.search_timeout), - store, - config_path, - ) - .map_err(|e| CliError::new(Exit::Failure, e.to_string())) +/// Give the terminal back and let logging through again, on the way out. +fn leave_terminal(result: CliResult) -> CliResult { + ratatui::restore(); + soulseek_rs::utils::logger::disable_buffering(); + result } /// Run the TUI against a daemon's session. @@ -542,6 +580,8 @@ fn run_attached_tui( // No StateStore: the daemon owns what survives a restart, so a second // writer here would fight it for the same files. + // The daemon owns the login, so this window has no logout to come back + // from: however it closed, the run is over. launch_main_tui( terminal, Arc::new(session), @@ -550,6 +590,7 @@ fn run_attached_tui( None, config_path, ) + .map(|_| ()) .map_err(|e| CliError::new(Exit::Failure, e.to_string())) } @@ -559,17 +600,10 @@ fn run_attached_tui( fn persist_credentials( outcome: &crate::ui::login::LoginOutcome, config_path: Option<&Path>, - file_config: &crate::persist::config::FileConfig, secret_store: &dyn crate::persist::secret::SecretStore, ) { - if let Some(path) = config_path - && file_config.username.as_deref() != Some(&outcome.username) - { - let mut updated = file_config.clone(); - updated.username = Some(outcome.username.clone()); - if let Err(e) = updated.save(path) { - eprintln!("⚠️ Could not save config: {e}"); - } + if let Some(path) = config_path { + save_username(path, &outcome.username); } if outcome.entered_via_form && let Err(e) = secret_store.set(&outcome.username, &outcome.password) @@ -578,6 +612,25 @@ fn persist_credentials( } } +/// Remember who logged in, for the next start to offer. +/// +/// Read back from disk rather than from the copy this run started with: the +/// settings popup writes the same file, and a second login after a logout +/// would otherwise save a snapshot taken before those edits. +fn save_username(path: &Path, username: &str) { + let mut config = match crate::persist::config::FileConfig::load(path) { + Ok(config) => config, + Err(e) => return eprintln!("⚠️ Could not read config: {e}"), + }; + if config.username.as_deref() == Some(username) { + return; + } + config.username = Some(username.to_string()); + if let Err(e) = config.save(path) { + eprintln!("⚠️ Could not save config: {e}"); + } +} + /// Open files a session asks the OS for at startup. /// /// The client holds a socket per peer and will talk to a thousand of them diff --git a/soulseek-rs/src/ui/login.rs b/soulseek-rs/src/ui/login.rs index f126c79..4bf2334 100644 --- a/soulseek-rs/src/ui/login.rs +++ b/soulseek-rs/src/ui/login.rs @@ -6,7 +6,7 @@ //! ([`run_login_flow`]) drives it against a real terminal and client. use crate::ui::{ - GLYPH_CURSOR, accent_style, dimmed_style, error_style, info_style, + GLYPH_CURSOR, accent_style, dimmed_style, error_style, info_style, mask, pane_block, primary_style, title_style, warning_style, }; use color_eyre::Result; @@ -281,7 +281,7 @@ fn render(frame: &mut Frame, form: &LoginForm) { frame.render_widget( field( "Password:", - "•".repeat(form.password.chars().count()), + mask(&form.password), editing && form.focused == LoginField::Password, ), rows[1], diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs index 84bdc5b..bed4c20 100644 --- a/soulseek-rs/src/ui/main_tui/input.rs +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -72,7 +72,7 @@ impl MainTui { // Global shortcuts match key.code { KeyCode::Char('q') => { - self.state.should_exit = true; + self.state.exit = Some(crate::models::TuiExit::Quit); return; } KeyCode::Char('?') => { diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 5b35068..034dc5e 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -7,7 +7,7 @@ mod search; mod settings; use crate::api::SessionApi; -use crate::models::AppState; +use crate::models::{AppState, TuiExit}; use crate::persist::{ snapshot::{Snapshot, restore_messages, restore_searches}, state::{PersistedMessage, StateStore}, @@ -182,7 +182,7 @@ impl MainTui { self.saved_snapshot = snapshot; } - pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { + pub fn run(mut self, mut terminal: DefaultTerminal) -> Result { use ratatui::crossterm::{event::DisableMouseCapture, execute}; // Run the event loop, then restore the terminal unconditionally: if the @@ -196,7 +196,7 @@ impl MainTui { ratatui::restore(); soulseek_rs::utils::logger::disable_buffering(); - result + result.map(|()| self.state.exit.unwrap_or(TuiExit::Quit)) } /// What the window holds, for a test to read back after driving it. @@ -219,7 +219,7 @@ impl MainTui { } fn run_event_loop(&mut self, terminal: &mut DefaultTerminal) -> Result<()> { - while !self.state.should_exit { + while self.state.exit.is_none() { terminal.draw(|frame| self.render(frame))?; self.poll_session(); @@ -239,7 +239,7 @@ impl MainTui { } _ => {} } - if self.state.should_exit || !poll(Duration::ZERO)? { + if self.state.exit.is_some() || !poll(Duration::ZERO)? { break; } } @@ -257,7 +257,7 @@ pub fn launch_main_tui( search_timeout: Duration, store: Option, config_path: Option, -) -> Result<()> { +) -> Result { let tui = MainTui::new(client, download_dir, search_timeout, store, config_path); tui.run(terminal) @@ -295,6 +295,8 @@ mod tests { download_dir_set: std::sync::Mutex>, /// What `set_shared_directories` was last asked for, if anything. shares_set: std::sync::Mutex>>, + /// What `change_password` was last asked for, if anything. + password_set: std::sync::Mutex>, download_cancelled: std::sync::Mutex>, upload_cancelled: std::sync::Mutex>, download_removed: std::sync::Mutex>, @@ -550,7 +552,12 @@ mod tests { None } fn shared_counts(&self) -> (u32, u32) { - (0, 0) + (3, 12) + } + fn change_password(&self, password: &str) -> soulseek_rs::Result<()> { + *self.password_set.lock().expect("not poisoned") = + Some(password.to_string()); + Ok(()) } fn shared_directories(&self) -> Vec { Vec::new() @@ -1276,6 +1283,100 @@ mod tests { assert_eq!(tui.download_dir, downloads_dir); } + /// The account block answers "who is this session, and what is it + /// offering" without a trip to another screen. + #[test] + fn the_settings_popup_names_the_account_and_its_shares() { + let mut tui = with_session(TalkativeSession::default()); + press(&mut tui, KeyCode::Char('o')); + + let screen = screen_sized(&mut tui, 100, 30); + assert!(screen.contains("tester"), "{screen}"); + assert!(screen.contains("12 files in 3 folders"), "{screen}"); + } + + /// Typed twice the same way, the new password reaches the session — and + /// attached to a daemon it is the daemon that stores it. + #[test] + fn a_password_typed_twice_reaches_the_session() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let mut tui = attach(session.clone()); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Enter); // the change-password row is first + for _ in 0..2 { + for c in "hunter2".chars() { + press(&mut tui, KeyCode::Char(c)); + } + press(&mut tui, KeyCode::Enter); + } + + assert_eq!( + *session.password_set.lock().expect("not poisoned"), + Some("hunter2".to_string()) + ); + let settings = tui.state.settings.as_ref().expect("still open"); + assert_eq!(settings.status.as_deref(), Some("Password changed")); + } + + /// A mistyped repeat is caught here rather than on the server, where the + /// account would be left with a password nobody knows. + #[test] + fn a_password_typed_two_different_ways_is_not_sent() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let mut tui = attach(session.clone()); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Enter); + for typed in ["hunter2", "hunter3"] { + for c in typed.chars() { + press(&mut tui, KeyCode::Char(c)); + } + press(&mut tui, KeyCode::Enter); + } + + assert_eq!(*session.password_set.lock().expect("not poisoned"), None); + } + + /// Logging out ends the window, but as a log-out: the caller brings the + /// login screen back rather than returning to the shell. + #[test] + fn logging_out_asks_first_and_then_closes_the_window() { + let mut tui = with_session(TalkativeSession::default()); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Down); // change password -> log out + press(&mut tui, KeyCode::Enter); + assert!(tui.state.exit.is_none(), "the confirm comes first"); + + press(&mut tui, KeyCode::Char('y')); + assert_eq!(tui.state.exit, Some(TuiExit::Logout)); + } + + /// Attached, the login is the daemon's: there is no logout row to land + /// on, so the row below the password is the download folder. + #[test] + fn an_attached_window_offers_no_logout() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let mut tui = attach(session); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Down); + press(&mut tui, KeyCode::Enter); + + let settings = tui.state.settings.as_ref().expect("still open"); + assert_eq!( + settings.mode, + crate::models::SettingsMode::EditingDownloadDir + ); + assert!(tui.state.exit.is_none()); + } + fn results(n: usize) -> Vec { (0..n) .map(|i| crate::models::FileDisplayData { @@ -1448,7 +1549,7 @@ mod tests { ctrl(&mut tui, 'b'); assert!(!tui.state.command_bar_active, "ctrl-b is not b"); ctrl(&mut tui, 'q'); - assert!(!tui.state.should_exit, "ctrl-q is not q"); + assert!(tui.state.exit.is_none(), "ctrl-q is not q"); } #[test] @@ -1682,7 +1783,7 @@ mod tests { // Other keys are swallowed while it is open. press(&mut tui, KeyCode::Char('q')); - assert!(!tui.state.should_exit, "q closes the list, not the app"); + assert!(tui.state.exit.is_none(), "q closes the list, not the app"); assert!(!tui.state.show_help); press(&mut tui, KeyCode::Char('?')); diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index fdd6aa7..34a312d 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -6,9 +6,9 @@ use crate::ui::panes::{ render_rooms_pane, render_searches_pane, selected_transfer, }; use crate::ui::{ - accent_style, body_style, dimmed_style, info_style, pack_shortcuts, - pane_block, plain_title, primary_style, render_download_stats, - warning_style, + GLYPH_CURSOR, accent_style, body_style, dimmed_style, info_style, mask, + pack_shortcuts, pane_block, plain_title, primary_style, + render_download_stats, warning_style, }; use ratatui::{ Frame, @@ -57,6 +57,14 @@ const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[ ("0 / $", "its start / its end"), ], ), + ( + "Settings popup", + &[ + ("Enter", "change the password, log out, edit the folder"), + ("a / d", "add / remove a shared folder"), + ("r", "re-index the shares"), + ], + ), ]; const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[ @@ -129,6 +137,48 @@ fn filter_keys( keys } +/// What the bar offers in the settings popup: the keys that always apply, +/// plus what Enter does on the row the selection rests on. +fn settings_shortcuts( + settings: &crate::models::SettingsState, +) -> Vec<(&'static str, &'static str)> { + use crate::models::{SettingsMode, SettingsRow}; + match settings.mode { + SettingsMode::ConfirmingLogout => { + vec![("y", "log out"), ("n/Esc", "stay")] + } + SettingsMode::NewPassword => { + vec![ + ("Type", "password"), + ("Enter", "repeat it"), + ("Esc", "cancel"), + ] + } + SettingsMode::RepeatPassword(_) => { + vec![ + ("Type", "password"), + ("Enter", "change it"), + ("Esc", "cancel"), + ] + } + SettingsMode::EditingDownloadDir | SettingsMode::AddingShare => { + vec![("Type", "path"), ("Enter", "save"), ("Esc", "cancel")] + } + SettingsMode::Navigate => vec![ + ("↑↓", "move"), + match settings.selected_row() { + SettingsRow::ChangePassword => ("Enter", "change password"), + SettingsRow::Logout => ("Enter", "log out"), + SettingsRow::DownloadDir => ("Enter", "edit folder"), + SettingsRow::Share(_) => ("d", "remove share"), + }, + ("a", "add share"), + ("r", "re-index"), + ("Esc", "close"), + ], + } +} + impl MainTui { /// Draw the whole window. pub fn render(&mut self, frame: &mut Frame) { @@ -450,44 +500,101 @@ impl MainTui { } fn render_settings_popup(&self, frame: &mut Frame) { - use crate::models::SettingsMode; + use crate::models::{SettingsMode, SettingsRow}; let Some(settings) = self.state.settings.as_ref() else { return; }; let area = centered_rect(70, 60, frame.area()); frame.render_widget(ratatui::widgets::Clear, area); + let selected = settings.selected_row(); let mut lines: Vec = Vec::new(); - let marker = |selected: bool| if selected { "> " } else { " " }; - let entry = |selected: bool, label: &str, value: String| { + let entry = |on: bool, label: &str, value: String| { Line::from(vec![ - Span::styled(marker(selected).to_string(), accent_style()), + Span::styled(if on { "> " } else { " " }, accent_style()), Span::styled(label.to_string(), dimmed_style()), Span::styled(value, primary_style()), ]) }; - - lines.push(if settings.mode == SettingsMode::EditingDownloadDir { + let typing = |label: &str, value: String| { Line::from(vec![ Span::styled("> ", accent_style()), - Span::styled("Download folder: ", dimmed_style()), - Span::styled(settings.input.clone(), primary_style()), - Span::styled("▏", accent_style()), + Span::styled(label.to_string(), dimmed_style()), + Span::styled(value, primary_style()), + Span::styled(GLYPH_CURSOR, accent_style()), ]) + }; + let section = |name: &str| Line::styled(name.to_string(), info_style()); + + // One column of labels, padded here rather than by hand in each + // string. + let field = |label: &str, value: String| { + entry(false, &format!(" {label:<14}"), value) + }; + let account = &settings.account; + lines.push(section("Account")); + lines.push(field("Signed in as", account.username.clone())); + lines.push(field("Sharing", account.shares.clone())); + lines.push(field( + "Reachable on", + account.listen_port.map_or_else( + || "not accepting connections".to_string(), + |port| format!("port {port}"), + ), + )); + if let Some(daemon) = &account.daemon { + lines.push(field("Daemon", daemon.clone())); + } + lines.push(Line::from("")); + + lines.push(match settings.mode { + SettingsMode::NewPassword => { + typing("New password: ", mask(&settings.input)) + } + SettingsMode::RepeatPassword(_) => { + typing("Repeat it: ", mask(&settings.input)) + } + _ => entry( + selected == SettingsRow::ChangePassword, + "Change password…", + String::new(), + ), + }); + if settings.mode == SettingsMode::ConfirmingLogout { + lines.push(entry( + true, + &format!("Log out of {}? ", account.username), + "y / n".to_string(), + )); + } else if settings.can_log_out() { + lines.push(entry( + selected == SettingsRow::Logout, + "Log out…", + String::new(), + )); + } + lines.push(Line::from("")); + + lines.push(section("Folders")); + lines.push(if settings.mode == SettingsMode::EditingDownloadDir { + typing("Download folder: ", settings.input.clone()) } else { entry( - settings.selected == 0, + selected == SettingsRow::DownloadDir, "Download folder: ", settings.download_dir.clone(), ) }); - lines.push(Line::from("")); lines.push(Line::styled( format!("Shared folders ({}):", settings.share_dirs.len()), dimmed_style(), )); for (i, dir) in settings.share_dirs.iter().enumerate() { - lines.push(entry(settings.selected == i + 1, "", dir.clone())); + lines.push(entry( + selected == SettingsRow::Share(i), + "", + dir.clone(), + )); } if settings.share_dirs.is_empty() { lines.push(Line::styled( @@ -496,11 +603,7 @@ impl MainTui { )); } if settings.mode == SettingsMode::AddingShare { - lines.push(Line::from(vec![ - Span::styled(" Add share: ", dimmed_style()), - Span::styled(settings.input.clone(), primary_style()), - Span::styled("▏", accent_style()), - ])); + lines.push(typing(" Add share: ", settings.input.clone())); } if let Some(status) = &settings.status { lines.push(Line::from("")); @@ -599,15 +702,8 @@ impl MainTui { fn shortcuts(&self) -> Vec<(&'static str, &'static str)> { if self.state.show_help { vec![("?/Esc", "close")] - } else if self.state.settings.is_some() { - vec![ - ("↑↓", "move"), - ("Enter/e", "edit download dir"), - ("a", "add share"), - ("d", "remove share"), - ("r", "re-index"), - ("Esc", "close"), - ] + } else if let Some(settings) = self.state.settings.as_ref() { + settings_shortcuts(settings) } else if self.state.show_messages { if self.state.chat_composing { vec![ diff --git a/soulseek-rs/src/ui/main_tui/settings.rs b/soulseek-rs/src/ui/main_tui/settings.rs index 42c60a3..63f568e 100644 --- a/soulseek-rs/src/ui/main_tui/settings.rs +++ b/soulseek-rs/src/ui/main_tui/settings.rs @@ -3,12 +3,21 @@ //! socket when the session is a daemon's). use super::MainTui; -use crate::models::{SettingsAction, SettingsState}; +use crate::models::{AccountInfo, SettingsAction, SettingsState}; +use crate::persist::secret::KeyringStore; use ratatui::crossterm::event::KeyEvent; impl MainTui { pub(super) fn open_settings(&mut self) { + // Read once here, not while drawing: see `AccountInfo`. + let account = AccountInfo { + username: self.client.username(), + shares: self.share_counts(), + listen_port: self.client.listen_port(), + daemon: self.client.daemon_endpoint(), + }; self.state.settings = Some(SettingsState::new( + account, self.download_dir.clone(), self.client.shared_directories(), )); @@ -23,9 +32,28 @@ impl MainTui { SettingsAction::Close => self.state.settings = None, SettingsAction::Apply => self.apply_settings(), SettingsAction::Reindex => self.reindex_shares(), + SettingsAction::ChangePassword(password) => { + self.change_password(&password); + } + SettingsAction::Logout => { + self.state.exit = Some(crate::models::TuiExit::Logout); + } } } + fn change_password(&mut self, password: &str) { + let status = match crate::persist::secret::change_password( + self.client.as_ref(), + &KeyringStore, + password, + ) { + Ok(None) => "Password changed".into(), + Ok(Some(e)) => format!("Password changed, but not stored: {e}"), + Err(e) => format!("Could not change it: {e}"), + }; + self.set_settings_status(status); + } + /// Persist the edited settings and apply them live. /// /// Attached to a daemon, both folders name paths on the *daemon's* @@ -71,9 +99,10 @@ impl MainTui { let mut status = if !refused.is_empty() { format!("Could not apply {}", refused.join("; ")) } else if dropped > 0 { + self.refresh_share_counts(); format!("Applied ({dropped} invalid path(s) ignored)") } else { - format!("Applied · sharing {}", self.share_counts()) + format!("Applied · sharing {}", self.refresh_share_counts()) }; if dir_applied { self.download_dir.clone_from(&download_dir); @@ -101,6 +130,16 @@ impl MainTui { self.set_settings_status(status); } + /// Bring the account block's share count back in step, and hand the same + /// count to the caller: counting is a round trip worth making once. + fn refresh_share_counts(&mut self) -> String { + let counts = self.share_counts(); + if let Some(settings) = self.state.settings.as_mut() { + settings.account.shares.clone_from(&counts); + } + counts + } + fn set_settings_status(&mut self, status: String) { if let Some(settings) = self.state.settings.as_mut() { settings.status = Some(status); @@ -111,13 +150,11 @@ impl MainTui { fn reindex_shares(&mut self) { let dirs = self.client.shared_directories(); let result = self.client.set_shared_directories(dirs); - let counts = self.share_counts(); - if let Some(settings) = self.state.settings.as_mut() { - settings.status = Some(match result { - Ok(()) => format!("Re-indexed · sharing {counts}"), - Err(e) => format!("Re-index failed: {e}"), - }); - } + let counts = self.refresh_share_counts(); + self.set_settings_status(match result { + Ok(()) => format!("Re-indexed · sharing {counts}"), + Err(e) => format!("Re-index failed: {e}"), + }); } fn share_counts(&self) -> String { diff --git a/soulseek-rs/src/ui/styles.rs b/soulseek-rs/src/ui/styles.rs index b807b88..3e953ee 100644 --- a/soulseek-rs/src/ui/styles.rs +++ b/soulseek-rs/src/ui/styles.rs @@ -38,6 +38,12 @@ pub const GLYPH_DONE: &str = "✓"; pub const GLYPH_FAILED: &str = "✗"; pub const GLYPH_TIMED_OUT: &str = "⧗"; pub const GLYPH_CURSOR: &str = "▮"; + +/// What a password looks like on screen: one bullet per character. +#[must_use] +pub fn mask(secret: &str) -> String { + "•".repeat(secret.chars().count()) +} pub const HIGHLIGHT_SYMBOL: &str = "›"; /// Between a key and its action in the legend, spaced. pub const SHORTCUT_ARROW: &str = " → "; From 26b551d997fdeea31d30e53b7ad4900a44681135 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 10:50:34 +0200 Subject: [PATCH 51/55] feat: browse your own shares as a peer receives them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window could show any peer's share tree but never this account's, so "am I actually sharing that?" had no answer short of asking a friend to browse you. The tree is already there; what was missing was feeding it from our own index. B opens it — the same collapsible tree, filter, and navigation the peer browse offers, built from `Shares::directories()`, which is the listing GetShareFileList answers with. So what it shows is what the network sees, not what the config meant to share. r re-scans the disk rather than re-asking a peer, o steps across to the folders that feed it, and nothing here queues a download from ourselves. Attached to a daemon the index lives on the daemon's host, so it comes back over a new `shares.files` method rather than being scanned locally; `shares files [--filter]` prints the same listing headless, through the `emit_listing` that `browse` now shares with it — the two are only comparable while they are built the same way. Two allocation fixes came out of the review, both on paths this reaches: the daemon hands its listing to the DTO instead of deep-copying one it already owns, and `Shares::directories()` allocates its map key once per folder rather than once per file, which the peer-facing serve path has been paying for all along. --- soulseek-rs-lib/src/client/mod.rs | 11 ++++ soulseek-rs-lib/src/shares/mod.rs | 20 +++--- soulseek-rs/docs/openrpc.json | 27 +++++++++ soulseek-rs/src/SKILL.md | 5 ++ soulseek-rs/src/api.rs | 8 +++ soulseek-rs/src/cli.rs | 7 +++ soulseek-rs/src/commands/settings.rs | 22 +++++++ soulseek-rs/src/commands/social.rs | 53 +++++++++++----- soulseek-rs/src/daemon/openrpc.rs | 9 ++- soulseek-rs/src/daemon/proto/dto.rs | 20 ++++++ soulseek-rs/src/daemon/proto/method.rs | 8 +++ soulseek-rs/src/daemon/proto/mod.rs | 1 + soulseek-rs/src/daemon/server/mod.rs | 16 ++++- soulseek-rs/src/models/browse.rs | 75 ++++++++++++++++++++++- soulseek-rs/src/remote/mod.rs | 16 ++++- soulseek-rs/src/run.rs | 3 + soulseek-rs/src/ui/main_tui/browse.rs | 44 +++++++++++++- soulseek-rs/src/ui/main_tui/input.rs | 6 ++ soulseek-rs/src/ui/main_tui/mod.rs | 67 ++++++++++++++++++++ soulseek-rs/src/ui/main_tui/render.rs | 30 +++++++-- soulseek-rs/src/ui/main_tui/settings.rs | 21 +++++-- soulseek-rs/src/ui/panes/browse_pane.rs | 78 ++++++++++++++++++++---- soulseek-rs/tests/cli_e2e.rs | 81 +++++++++++++++++++++++++ soulseek-rs/tests/tui_e2e.rs | 35 +++++++++++ 24 files changed, 606 insertions(+), 57 deletions(-) diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index a9ea8d7..a8ff5c9 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -1474,6 +1474,17 @@ impl Client { .unwrap_or_default() } + /// The share index as a peer receives it: the same directory listing + /// `GetShareFileList` answers with, so what this returns is what the + /// network sees. + #[must_use] + pub fn shared_listing(&self) -> Vec { + self.context + .read_safe() + .map(|ctx| ctx.shares.directories()) + .unwrap_or_default() + } + /// `(folders, files)` currently shared with peers. #[must_use] pub fn shared_counts(&self) -> (u32, u32) { diff --git a/soulseek-rs-lib/src/shares/mod.rs b/soulseek-rs-lib/src/shares/mod.rs index fb85657..807fbe3 100644 --- a/soulseek-rs-lib/src/shares/mod.rs +++ b/soulseek-rs-lib/src/shares/mod.rs @@ -146,14 +146,18 @@ impl Shares { .virtual_path .rsplit_once('\\') .unwrap_or(("", file.virtual_path.as_str())); - by_dir - .entry(dir.to_string()) - .or_default() - .push(SharedFileEntry { - name: base.to_string(), - size: file.size, - attributes: file.attributes.clone(), - }); + let entry = SharedFileEntry { + name: base.to_string(), + size: file.size, + attributes: file.attributes.clone(), + }; + // Looked up before inserting: `entry()` would allocate the key + // for every file, and a folder holds many. + if let Some(files) = by_dir.get_mut(dir) { + files.push(entry); + } else { + by_dir.insert(dir.to_string(), vec![entry]); + } } by_dir .into_iter() diff --git a/soulseek-rs/docs/openrpc.json b/soulseek-rs/docs/openrpc.json index 1702a7a..cbf59a6 100644 --- a/soulseek-rs/docs/openrpc.json +++ b/soulseek-rs/docs/openrpc.json @@ -1243,6 +1243,21 @@ ], "type": "object" }, + "SharedListing": { + "description": "This session's own share index, as a peer receives it — what\n`shares.files` answers with.", + "properties": { + "directories": { + "items": { + "$ref": "#/components/schemas/SharedDirectoryDto" + }, + "type": "array" + } + }, + "required": [ + "directories" + ], + "type": "object" + }, "SharesStatus": { "description": "What the share index holds, from `shares.status` / `shares.reindex`.", "properties": { @@ -2361,6 +2376,18 @@ }, "summary": "What the share index currently holds." }, + { + "name": "shares.files", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharedListing" + } + }, + "summary": "Every indexed file, in the listing a browsing peer receives." + }, { "name": "shares.set", "paramStructure": "by-name", diff --git a/soulseek-rs/src/SKILL.md b/soulseek-rs/src/SKILL.md index 2e79643..2dc21fc 100644 --- a/soulseek-rs/src/SKILL.md +++ b/soulseek-rs/src/SKILL.md @@ -216,6 +216,11 @@ config file; they print nothing. With a daemon running they also update it in place, so the folder is on the network at once rather than after a restart. **`shares status`** and **`shares reindex`** — `folders`, `files`, `directories`, counted by logging in and scanning. +**`shares files [--filter ]`** — the index file by file, in the same +records `browse` emits for a peer (`user`, `directory`, `path`, `size`, +`bitrate`, `duration`), so what the network sees of this account can be diffed +against what it sees of anyone else. Use it to check that a folder is really +being served before blaming the network. **`config path`**, **`config list`**, **`config get `**, and **`config set `** — `key`, `value`. diff --git a/soulseek-rs/src/api.rs b/soulseek-rs/src/api.rs index 48d5d1e..e3a10ba 100644 --- a/soulseek-rs/src/api.rs +++ b/soulseek-rs/src/api.rs @@ -168,6 +168,10 @@ pub trait SessionApi: Send + Sync { fn shared_counts(&self) -> (u32, u32); // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. fn shared_directories(&self) -> Vec; + /// The share index in the shape a peer receives it, for showing this + /// session's own files exactly as the network sees them. + // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. + fn shared_listing(&self) -> Vec; // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. fn set_shared_directories(&self, directories: Vec) -> Result<()>; @@ -416,6 +420,10 @@ impl SessionApi for Client { Self::shared_directories(self) } + fn shared_listing(&self) -> Vec { + Self::shared_listing(self) + } + fn set_shared_directories(&self, directories: Vec) -> Result<()> { Self::set_shared_directories(self, directories) } diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index 0c8f581..8c5efd0 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -407,6 +407,13 @@ pub enum SharesCommand { directory: String, }, + /// List every indexed file, the way `browse` lists a peer's + Files { + /// Only paths containing this text, case-insensitively + #[arg(long)] + filter: Option, + }, + /// Re-index the shares and report what the network will see Reindex, diff --git a/soulseek-rs/src/commands/settings.rs b/soulseek-rs/src/commands/settings.rs index 01057d2..c85970e 100644 --- a/soulseek-rs/src/commands/settings.rs +++ b/soulseek-rs/src/commands/settings.rs @@ -163,6 +163,28 @@ pub fn shares_status(ctx: &Ctx) -> CliResult { Ok(()) } +/// List the share index file by file, in the same records `browse` emits for +/// a peer — so what the network sees of us can be read, and diffed, exactly +/// the way it is read of anyone else. +pub fn shares_files(ctx: &Ctx, filter: Option<&str>) -> CliResult { + let session = Session::open(ctx)?; + let needle = filter.map(str::to_lowercase); + let found = super::social::emit_listing( + ctx, + &session.client.username(), + &session.client.shared_listing(), + needle.as_deref(), + ); + + if found == 0 { + return Err(CliError::no_results(match filter { + Some(filter) => format!("no shared file matches '{filter}'"), + None => "no files are indexed".to_string(), + })); + } + Ok(()) +} + /// Re-scan the configured folders in a live session, so files added since the /// last run become visible without waiting for the next one. pub fn shares_reindex(ctx: &Ctx) -> CliResult { diff --git a/soulseek-rs/src/commands/social.rs b/soulseek-rs/src/commands/social.rs index f9b897c..e9f543c 100644 --- a/soulseek-rs/src/commands/social.rs +++ b/soulseek-rs/src/commands/social.rs @@ -48,6 +48,42 @@ pub fn fetch_listing( }) } +/// Emit `user`'s listing one file per record, keeping only the paths that +/// contain `needle` (already lowercased; `None` keeps all). Returns how many +/// were emitted. +/// +/// Shared with `shares files`, which prints this session's own index: the two +/// are only comparable while they are built the same way, and a field added +/// here has to reach both. +pub fn emit_listing( + ctx: &Ctx, + user: &str, + directories: &[SharedDirectory], + needle: Option<&str>, +) -> usize { + let mut emitted = 0usize; + for directory in directories { + for file in &directory.files { + let path = full_path(&directory.name, &file.name); + if needle + .is_some_and(|needle| !path.to_lowercase().contains(needle)) + { + continue; + } + emitted += 1; + ctx.out.emit(&BrowseRecord { + user: user.to_string(), + directory: directory.name.clone(), + path, + size: file.size, + bitrate: file.attribute(super::transfer::ATTR_BITRATE), + duration: file.attribute(super::transfer::ATTR_DURATION), + }); + } + } + emitted +} + pub fn browse(ctx: &Ctx, args: &BrowseArgs) -> CliResult { let session = Session::open(ctx)?; ctx.out @@ -59,22 +95,7 @@ pub fn browse(ctx: &Ctx, args: &BrowseArgs) -> CliResult { Duration::from_secs(args.timeout), )?; - let mut files = 0usize; - for directory in &directories { - for file in &directory.files { - files += 1; - ctx.out.emit(&BrowseRecord { - user: args.user.clone(), - directory: directory.name.clone(), - path: full_path(&directory.name, &file.name), - size: file.size, - bitrate: file.attribute(super::transfer::ATTR_BITRATE), - duration: file.attribute(super::transfer::ATTR_DURATION), - }); - } - } - - if files == 0 { + if emit_listing(ctx, &args.user, &directories, None) == 0 { return Err(CliError::no_results(format!( "{} shares no files", args.user diff --git a/soulseek-rs/src/daemon/openrpc.rs b/soulseek-rs/src/daemon/openrpc.rs index 5312075..46ca070 100644 --- a/soulseek-rs/src/daemon/openrpc.rs +++ b/soulseek-rs/src/daemon/openrpc.rs @@ -14,8 +14,8 @@ use super::proto::{ Downloads, Event, IntervalSeconds, MemberStats, Members, MessageParams, Messages, Method, PasswordParams, QueryParams, RoomEventDto, RoomRef, SayParams, SearchResults, Searches, Seconds, SessionLossEvent, - SharesStatus, SlotsParams, TransferRef, UploadInfoDto, Uploads, - UserMessageDto, UserRef, UserResult, Watched, + SharedListing, SharesStatus, SlotsParams, TransferRef, UploadInfoDto, + Uploads, UserMessageDto, UserRef, UserResult, Watched, }; use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings}; use serde_json::{Value, json}; @@ -74,6 +74,7 @@ fn params_schema(method: Method, generator: &mut SchemaGenerator) -> Value { | Method::MessageHistory | Method::SearchList | Method::SharesStatusOf + | Method::SharesFiles | Method::SharesReindex | Method::UserWatched => json!({ "type": "null" }), } @@ -102,6 +103,7 @@ fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value { Method::SharesStatusOf | Method::SharesSet | Method::SharesReindex => { schema_of::(generator) } + Method::SharesFiles => schema_of::(generator), // Everything else acknowledges and says nothing more. Method::DaemonStop | Method::SearchStart @@ -212,6 +214,9 @@ fn summary(method: Method) -> &'static str { } Method::UserInfoOf => "What the server has said about a user so far.", Method::SharesStatusOf => "What the share index currently holds.", + Method::SharesFiles => { + "Every indexed file, in the listing a browsing peer receives." + } Method::SharesSet => "Replace the shared folders and re-index.", Method::SharesReindex => "Re-scan the configured folders.", } diff --git a/soulseek-rs/src/daemon/proto/dto.rs b/soulseek-rs/src/daemon/proto/dto.rs index 32fd958..2598859 100644 --- a/soulseek-rs/src/daemon/proto/dto.rs +++ b/soulseek-rs/src/daemon/proto/dto.rs @@ -770,6 +770,26 @@ impl From<&SharedDirectory> for SharedDirectoryDto { } } +/// The same, consuming the listing. The daemon owns what `shared_listing` +/// hands it and drops it straight after, so copying every name and attribute +/// out of it would double the cost of answering `shares.files`. +impl From for SharedDirectoryDto { + fn from(directory: SharedDirectory) -> Self { + Self { + name: directory.name, + files: directory + .files + .into_iter() + .map(|file| SharedFileEntryDto { + name: file.name, + size: file.size, + attributes: file.attributes, + }) + .collect(), + } + } +} + impl From for SharedDirectory { fn from(dto: SharedDirectoryDto) -> Self { Self { diff --git a/soulseek-rs/src/daemon/proto/method.rs b/soulseek-rs/src/daemon/proto/method.rs index 48f4976..de93784 100644 --- a/soulseek-rs/src/daemon/proto/method.rs +++ b/soulseek-rs/src/daemon/proto/method.rs @@ -212,6 +212,14 @@ pub struct BrowseEvent { pub directories: Vec, } +/// This session's own share index, as a peer receives it — what +/// `shares.files` answers with. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SharedListing { + pub directories: Vec, +} + /// One step in a transfer this client started. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[cfg_attr(test, derive(schemars::JsonSchema))] diff --git a/soulseek-rs/src/daemon/proto/mod.rs b/soulseek-rs/src/daemon/proto/mod.rs index 9f254f4..9f24d7c 100644 --- a/soulseek-rs/src/daemon/proto/mod.rs +++ b/soulseek-rs/src/daemon/proto/mod.rs @@ -113,6 +113,7 @@ wire_vocabulary! { UserWatched => "user.watched", AccountPassword => "account.password", SharesStatusOf => "shares.status", + SharesFiles => "shares.files", SharesSet => "shares.set", SharesReindex => "shares.reindex", } diff --git a/soulseek-rs/src/daemon/server/mod.rs b/soulseek-rs/src/daemon/server/mod.rs index 0396798..ca7fe3c 100644 --- a/soulseek-rs/src/daemon/server/mod.rs +++ b/soulseek-rs/src/daemon/server/mod.rs @@ -13,8 +13,9 @@ use super::proto::{ MemberStats, Members, MessageParams, Messages, Method, OPENRPC, PROTOCOL_VERSION, PasswordParams, QueryParams, RoomRef, RoomUserStatsDto, RpcError, SayParams, SearchResultDto, SearchResults, SearchSummary, - Searches, Seconds, SharesStatus, SlotsParams, TransferRef, UploadInfoDto, - Uploads, UserInfoDto, UserRef, UserResult, Watched, + Searches, Seconds, SharedDirectoryDto, SharedListing, SharesStatus, + SlotsParams, TransferRef, UploadInfoDto, Uploads, UserInfoDto, UserRef, + UserResult, Watched, }; use crate::api::SessionApi; use crate::output::Exit; @@ -372,6 +373,14 @@ impl Daemon { } Method::SharesStatusOf => ok(self.shares()), + Method::SharesFiles => ok(SharedListing { + directories: self + .session + .shared_listing() + .into_iter() + .map(SharedDirectoryDto::from) + .collect(), + }), Method::SharesSet => { let directories: DirectoriesParams = parse(params)?; self.session @@ -1018,6 +1027,9 @@ mod tests { fn shared_counts(&self) -> (u32, u32) { (0, 0) } + fn shared_listing(&self) -> Vec { + Vec::new() + } fn shared_directories(&self) -> Vec { Vec::new() } diff --git a/soulseek-rs/src/models/browse.rs b/soulseek-rs/src/models/browse.rs index 59ac1db..f428f79 100644 --- a/soulseek-rs/src/models/browse.rs +++ b/soulseek-rs/src/models/browse.rs @@ -332,6 +332,9 @@ pub enum BrowseStatus { /// UI state for browsing one user. pub struct BrowseState { pub username: String, + /// This session's own share index rather than a peer's: filled from the + /// local index, re-indexed rather than re-requested, and not downloadable. + pub own: bool, pub status: BrowseStatus, pub tree: Vec, expanded: HashSet, @@ -356,6 +359,7 @@ impl BrowseState { pub fn loading(username: String) -> Self { Self { username, + own: false, status: BrowseStatus::Loading, tree: Vec::new(), expanded: HashSet::new(), @@ -370,6 +374,15 @@ impl BrowseState { } } + /// An empty view of this session's own shares, waiting for the index. + #[must_use] + pub fn loading_own(username: String) -> Self { + Self { + own: true, + ..Self::loading(username) + } + } + /// Populate from a received listing: build the tree, auto-expand the top /// level so structure is visible, and set Loaded/Empty. pub fn load(&mut self, directories: &[SharedDirectory]) { @@ -528,7 +541,10 @@ impl BrowseTabs { /// `true` if the caller should (re)issue a browse request — i.e. the tab is /// new or was retried after a timeout. pub fn open(&mut self, username: &str) -> bool { - if let Some(idx) = self.tabs.iter().position(|t| t.username == username) + if let Some(idx) = self + .tabs + .iter() + .position(|t| !t.own && t.username == username) { self.active = idx; // Re-request only if the previous attempt gave up. @@ -545,6 +561,40 @@ impl BrowseTabs { } } + /// Where this session's own share tab sits, if it is open. + #[must_use] + pub fn own_index(&self) -> Option { + self.tabs.iter().position(|tab| tab.own) + } + + /// Focus the tab showing this session's own shares, opening it if it is + /// not there yet. Returns `true` when it still needs its listing. + pub fn open_own(&mut self, username: &str) -> bool { + if let Some(idx) = self.own_index() { + self.active = idx; + return false; + } + self.tabs + .push(BrowseState::loading_own(username.to_string())); + self.active = self.tabs.len() - 1; + true + } + + /// Whether the focused tab is this session's own share index. + #[must_use] + pub fn active_is_own(&self) -> bool { + self.active_tab().is_some_and(|tab| tab.own) + } + + /// Who the active tab's files would be downloaded from, if anyone. The + /// own tab names nobody: its files are already on this disk. + #[must_use] + pub fn download_target(&self) -> Option<&str> { + self.active_tab() + .filter(|tab| !tab.own) + .map(|tab| tab.username.as_str()) + } + /// Retry the active tab if it timed out: reset it to loading and return the /// username so the caller can re-issue the request. Returns `None` if the /// active tab isn't in a retryable (timed-out) state. @@ -740,6 +790,29 @@ mod tests { assert!(tabs.is_empty()); } + #[test] + fn the_own_tab_is_opened_once_and_is_not_a_peer_of_the_same_name() { + let mut tabs = BrowseTabs::new(); + // The first open needs a listing; focusing it again does not. + assert!(tabs.open_own("tester")); + assert!(!tabs.open_own("tester")); + assert_eq!(tabs.tabs.len(), 1); + assert_eq!(tabs.own_index(), Some(0)); + assert!(tabs.active_tab().expect("tab").own); + + // Browsing the account's own name over the network is a separate + // tab: the own tab holds the local index, not a peer's answer. + assert!(tabs.open("tester")); + assert_eq!(tabs.tabs.len(), 2); + assert!(!tabs.active_tab().expect("tab").own); + assert_eq!(tabs.own_index(), Some(0)); + + // The peer of that name is downloadable; we are not. + assert_eq!(tabs.download_target(), Some("tester")); + tabs.active = 0; + assert_eq!(tabs.download_target(), None); + } + #[test] fn browse_tabs_reopen_after_timeout_requests_again() { let mut tabs = BrowseTabs::new(); diff --git a/soulseek-rs/src/remote/mod.rs b/soulseek-rs/src/remote/mod.rs index 73d4ee1..6339eb1 100644 --- a/soulseek-rs/src/remote/mod.rs +++ b/soulseek-rs/src/remote/mod.rs @@ -14,8 +14,8 @@ use crate::daemon::proto::{ DownloadStartParams, DownloadStarted, Downloads, Event, IntervalSeconds, MemberStats, Members, MessageParams, Messages, Method, PROTOCOL_VERSION, PasswordParams, QueryParams, Request, Response, RoomRef, RpcError, - SayParams, SearchResults, Searches, Seconds, SharesStatus, SlotsParams, - TransferRef, Uploads, UserRef, UserResult, Watched, + SayParams, SearchResults, Searches, Seconds, SharedListing, SharesStatus, + SlotsParams, TransferRef, Uploads, UserRef, UserResult, Watched, }; use mirror::Mirror; use serde::de::DeserializeOwned; @@ -823,6 +823,18 @@ impl SessionApi for RemoteSession { self.shares().directories } + // ponytail: a blocking round-trip that carries the whole index — a very + // large share is tens of MB of JSON before the next redraw. It is only + // ever a keypress, never a frame; if that stops being tolerable, push it + // as an event the way `browse.user` answers with `event.browse`. + fn shared_listing(&self) -> Vec { + self.request::<_, SharedListing>(Method::SharesFiles, ()) + .map(|listing| { + listing.directories.into_iter().map(Into::into).collect() + }) + .unwrap_or_default() + } + fn set_shared_directories(&self, directories: Vec) -> Result<()> { self.tell(Method::SharesSet, DirectoriesParams { directories }) } diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs index 2a0ea7b..b0e8b58 100644 --- a/soulseek-rs/src/run.rs +++ b/soulseek-rs/src/run.rs @@ -167,6 +167,9 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { Commands::Shares(SharesCommand::Status) => { crate::commands::settings::shares_status(&ctx) } + Commands::Shares(SharesCommand::Files { ref filter }) => { + crate::commands::settings::shares_files(&ctx, filter.as_deref()) + } Commands::Shares(SharesCommand::Reindex) => { crate::commands::settings::shares_reindex(&ctx) } diff --git a/soulseek-rs/src/ui/main_tui/browse.rs b/soulseek-rs/src/ui/main_tui/browse.rs index e9fc1bd..2727014 100644 --- a/soulseek-rs/src/ui/main_tui/browse.rs +++ b/soulseek-rs/src/ui/main_tui/browse.rs @@ -54,6 +54,14 @@ impl MainTui { self.sync_browse_selection(); return; } + KeyCode::Char('B') => { + self.show_own_shares(); + return; + } + KeyCode::Char('r') if self.state.browse.active_is_own() => { + self.reindex_shares(); + return; + } KeyCode::Char('r') => { // Retry a timed-out browse. if let Some(username) = self.state.browse.retry_active() { @@ -61,6 +69,13 @@ impl MainTui { } return; } + // The shared folders themselves are the settings pane's business, + // so adding or dropping one is a keystroke away from seeing it. + KeyCode::Char('o') if self.state.browse.active_is_own() => { + self.state.show_browse = false; + self.open_settings(); + return; + } _ => {} } @@ -192,9 +207,11 @@ impl MainTui { } /// Queue downloads of `files` (path, size) from the active browse tab's user. + /// + /// Our own shares are already on this disk, so the own tab queues nothing. fn queue_browse_files(&mut self, files: Vec<(String, u64)>) { let Some(username) = - self.state.browse.active_tab().map(|b| b.username.clone()) + self.state.browse.download_target().map(str::to_string) else { return; }; @@ -240,6 +257,31 @@ impl MainTui { self.sync_browse_selection(); } + /// Open (or focus) the view of what this session shares, filled from the + /// index the network is served from. + pub(super) fn show_own_shares(&mut self) { + if self.state.browse.open_own(&self.client.username()) { + self.load_own_shares(); + } + self.state.show_browse = true; + self.sync_browse_selection(); + } + + /// Put a fresh copy of the share index into the own tab, if it is open. + /// A re-index or an added folder would otherwise leave it showing what + /// the network saw a scan ago. + pub(super) fn load_own_shares(&mut self) { + // The early return matters: without an own tab there is nothing to + // fill, and against a daemon asking anyway is a whole index over the + // socket for nobody. + let Some(idx) = self.state.browse.own_index() else { + return; + }; + let listing = self.client.shared_listing(); + self.state.browse.tabs[idx].load(&listing); + self.sync_browse_selection(); + } + /// The username of the highlighted search result (filter-aware). pub(super) fn highlighted_result_owner(&self) -> Option { self.highlighted_result().map(|f| f.username.clone()) diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs index bed4c20..1377491 100644 --- a/soulseek-rs/src/ui/main_tui/input.rs +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -144,6 +144,12 @@ impl MainTui { self.open_settings(); return; } + // The same tree a peer gets, from our own index: what we look + // like to the network, rather than what we meant to share. + KeyCode::Char('B') => { + self.show_own_shares(); + return; + } KeyCode::Char('b') => { // From a highlighted search result, browse its owner directly; // otherwise prompt for a username. diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 034dc5e..67178af 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -297,6 +297,8 @@ mod tests { shares_set: std::sync::Mutex>>, /// What `change_password` was last asked for, if anything. password_set: std::sync::Mutex>, + /// The share index this session serves to peers. + listing: std::sync::Mutex>, download_cancelled: std::sync::Mutex>, upload_cancelled: std::sync::Mutex>, download_removed: std::sync::Mutex>, @@ -559,6 +561,9 @@ mod tests { Some(password.to_string()); Ok(()) } + fn shared_listing(&self) -> Vec { + self.listing.lock().expect("not poisoned").clone() + } fn shared_directories(&self) -> Vec { Vec::new() } @@ -1902,6 +1907,68 @@ mod tests { let _ = screen_of(tui); } + #[test] + fn shift_b_shows_what_this_session_shares_and_r_re_indexes_it() { + let session = Arc::new(TalkativeSession::default()); + *session.listing.lock().expect("not poisoned") = + vec![soulseek_rs::SharedDirectory { + name: "Music\\Album".to_string(), + files: vec![soulseek_rs::SharedFileEntry { + name: "track.mp3".to_string(), + size: 4096, + attributes: Vec::new(), + }], + }]; + let mut tui = attach(session.clone()); + + press(&mut tui, KeyCode::Char('B')); + assert!(tui.state.show_browse, "the popup opens"); + let tab = tui.state.browse.active_tab().expect("tab"); + assert!(tab.own, "on this session's own index"); + assert_eq!(tab.username, "tester"); + assert_eq!(tab.file_count, 1); + let screen = screen_of(&mut tui); + assert!(screen.contains("My shares"), "{screen}"); + press(&mut tui, KeyCode::Char('L')); + let screen = screen_of(&mut tui); + assert!( + screen.contains("Album") && screen.contains("track.mp3"), + "the virtual path a peer would see, folder by folder: {screen}" + ); + + // Nothing here is downloadable from ourselves. + press(&mut tui, KeyCode::Char('d')); + assert!(tui.state.downloads.is_empty()); + + // `r` re-scans the disk rather than re-asking a peer, and the tab + // picks up what the fresh scan found. + session.listing.lock().expect("not poisoned").clear(); + press(&mut tui, KeyCode::Char('r')); + assert!( + session.shares_set.lock().expect("not poisoned").is_some(), + "the session was asked to re-index" + ); + assert_eq!( + tui.state.browse.active_tab().expect("tab").file_count, + 0, + "the view follows the new index" + ); + + // A second B focuses the tab it already opened. + press(&mut tui, KeyCode::Esc); + press(&mut tui, KeyCode::Char('B')); + assert_eq!(tui.state.browse.tabs.len(), 1); + } + + #[test] + fn o_leaves_the_share_view_for_the_folders_that_feed_it() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + press(&mut tui, KeyCode::Char('B')); + press(&mut tui, KeyCode::Char('o')); + assert!(!tui.state.show_browse, "the popup steps aside"); + assert!(tui.state.settings.is_some(), "for the settings pane"); + } + #[test] fn page_keys_walk_the_browse_tree_and_ctrl_d_does_not_download() { let session = Arc::new(TalkativeSession::default()); diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index 34a312d..a397e4b 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -39,6 +39,7 @@ const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[ &[ ("s", "search the network"), ("b", "browse a user's files"), + ("B", "browse what you share yourself"), ("m", "compose a private message"), ("i", "inbox"), ("o", "settings"), @@ -121,7 +122,8 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[ ("/", "filter by path, Enter keeps it, Esc clears"), ("Enter", "open a folder, or download a file"), ("d", "download a file, or a folder's files"), - ("r", "ask again after a timeout"), + ("r", "ask again after a timeout, re-index your own"), + ("o", "shared folders (your own tab)"), ], ), ]; @@ -730,19 +732,35 @@ impl MainTui { // Typing goes to the filter, so no letter keys are on offer. filter_keys(&[("↑/↓", "navigate"), ("PgUp/PgDn", "page")]) } else { - vec![ + // Nothing of ours is downloadable from ourselves, and `r` + // re-scans the disk instead of re-asking a peer. + let acts = if self.state.browse.active_is_own() { + [ + ("Enter", "open folder"), + ("r", "re-index"), + ("o", "shared folders"), + ] + } else { + [ + ("Enter", "open/download"), + ("d", "download folder"), + ("r", "retry"), + ] + }; + let mut keys = vec![ ("↑↓", "move"), ("J/K", "next/prev folder"), ("→←", "expand/collapse"), ("H/L", "collapse/expand all"), ("/", "filter"), - ("Enter", "open/download"), - ("d", "download folder"), + ]; + keys.extend(acts); + keys.extend([ ("Tab", "switch user"), - ("r", "retry"), ("w", "close tab"), ("Esc", "hide"), - ] + ]); + keys } } else if self.state.command_bar_active { match self.state.command_bar_mode { diff --git a/soulseek-rs/src/ui/main_tui/settings.rs b/soulseek-rs/src/ui/main_tui/settings.rs index 63f568e..40717bc 100644 --- a/soulseek-rs/src/ui/main_tui/settings.rs +++ b/soulseek-rs/src/ui/main_tui/settings.rs @@ -128,6 +128,7 @@ impl MainTui { } } self.set_settings_status(status); + self.load_own_shares(); } /// Bring the account block's share count back in step, and hand the same @@ -147,14 +148,22 @@ impl MainTui { } /// Re-scan the current share paths (picks up files changed on disk). - fn reindex_shares(&mut self) { + // ponytail: the scan runs on the UI thread, so a very large share set + // freezes the window for its duration. Move it to a worker if that bites. + pub(super) fn reindex_shares(&mut self) { let dirs = self.client.shared_directories(); let result = self.client.set_shared_directories(dirs); - let counts = self.refresh_share_counts(); - self.set_settings_status(match result { - Ok(()) => format!("Re-indexed · sharing {counts}"), - Err(e) => format!("Re-index failed: {e}"), - }); + // Only the settings pane shows these, and `r` in the share view + // calls us with the pane closed — where the counts would be a + // round-trip against the daemon for a string nobody reads. + if self.state.settings.is_some() { + let counts = self.refresh_share_counts(); + self.set_settings_status(match result { + Ok(()) => format!("Re-indexed · sharing {counts}"), + Err(e) => format!("Re-index failed: {e}"), + }); + } + self.load_own_shares(); } fn share_counts(&self) -> String { diff --git a/soulseek-rs/src/ui/panes/browse_pane.rs b/soulseek-rs/src/ui/panes/browse_pane.rs index 692f952..cc95259 100644 --- a/soulseek-rs/src/ui/panes/browse_pane.rs +++ b/soulseek-rs/src/ui/panes/browse_pane.rs @@ -63,7 +63,8 @@ fn render_browse_tabs(frame: &mut Frame, area: Rect, tabs: &BrowseTabs) { } else { dimmed_style() }; - spans.push(Span::styled(format!(" {} ", tab.username), style)); + let you = if tab.own { " (you)" } else { "" }; + spans.push(Span::styled(format!(" {}{you} ", tab.username), style)); spans.push(Span::raw(" ")); } frame.render_widget(Paragraph::new(Line::from(spans)), area); @@ -78,24 +79,33 @@ fn render_browse_one( spinner_state: usize, downloads: &[DownloadEntry], ) { + // Our own index is not downloadable and re-scans instead of re-asking, + // so it names itself and offers its own keys. + let heading = if browse.own { + "My shares".to_string() + } else { + format!("Browse {}", browse.username) + }; let title = match browse.status { BrowseStatus::Loaded => { + let keys = if browse.own { + "/: filter, r: re-index, o: folders, w: close, Esc: hide" + } else { + "Enter/d: download, /: filter, Tab: user, w: close, Esc: hide" + }; let shown = browse.rows().iter().filter(|row| !row.is_folder).count(); filter_title( - &format!( - "Browse {} — {shown}/{} files", - browse.username, browse.file_count - ), + &format!("{heading} — {shown}/{} files", browse.file_count), browse.filter(), browse.filtering, &format!( - " Browse {} — {} files, {} folders (Enter/d: download, /: filter, Tab: user, w: close, Esc: hide) ", - browse.username, browse.file_count, browse.folder_count + " {heading} — {} files, {} folders ({keys}) ", + browse.file_count, browse.folder_count ), ) } - _ => format!(" Browse {} ", browse.username), + _ => format!(" {heading} "), }; let block = pane_block(true).title(title); @@ -112,7 +122,12 @@ fn render_browse_one( ); } BrowseStatus::Empty => { - let text = format!("{} is not sharing any files.", browse.username); + let text = if browse.own { + "You are not sharing any files. Press o to add a folder." + .to_string() + } else { + format!("{} is not sharing any files.", browse.username) + }; frame.render_widget( Paragraph::new(text).style(dimmed_style()).block(block), area, @@ -333,14 +348,17 @@ mod tests { } } - fn screen_with(downloads: &[DownloadEntry]) -> String { - let mut browse = BrowseState::loading("bob".to_string()); - browse.load(&[share()]); + /// One tab drawn onto a `width`-wide buffer. + fn screen( + browse: BrowseState, + downloads: &[DownloadEntry], + width: u16, + ) -> String { let mut tabs = BrowseTabs::default(); tabs.tabs.push(browse); let mut state = TableState::default(); let mut terminal = - Terminal::new(TestBackend::new(60, 8)).expect("backend"); + Terminal::new(TestBackend::new(width, 8)).expect("backend"); terminal .draw(|frame| { render_browse_pane( @@ -356,6 +374,12 @@ mod tests { terminal.backend().to_string() } + fn screen_with(downloads: &[DownloadEntry]) -> String { + let mut browse = BrowseState::loading("bob".to_string()); + browse.load(&[share()]); + screen(browse, downloads, 60) + } + #[test] fn a_downloading_file_carries_its_transfer_glyph() { let screen = screen_with(&[ @@ -424,6 +448,34 @@ mod tests { assert!(!screen.contains(GLYPH_ACTIVE), "{screen}"); } + /// The own tab, rendered with `directories` as its index. Wider than the + /// peer screens because the title carries the keys it offers. + fn own_screen(directories: &[SharedDirectory]) -> String { + let mut browse = BrowseState::loading_own("tester".to_string()); + browse.load(directories); + screen(browse, &[], 90) + } + + #[test] + fn the_own_tab_names_itself_and_offers_no_download() { + let screen = own_screen(&[share()]); + assert!(screen.contains("My shares"), "{screen}"); + assert!(!screen.contains("Browse tester"), "{screen}"); + assert!(screen.contains("re-index"), "r re-scans instead: {screen}"); + assert!( + !screen.contains("download"), + "nothing here is downloadable from ourselves: {screen}" + ); + assert!(screen.contains("plain.mp3"), "the tree still shows"); + } + + #[test] + fn sharing_nothing_says_how_to_start() { + let screen = own_screen(&[]); + assert!(screen.contains("not sharing any files"), "{screen}"); + assert!(screen.contains("Press o to add a folder"), "{screen}"); + } + #[test] fn another_user_s_transfers_mark_nothing() { let mut theirs = download("active.mp3", DownloadStatus::Completed); diff --git a/soulseek-rs/tests/cli_e2e.rs b/soulseek-rs/tests/cli_e2e.rs index b045e31..b05bf93 100644 --- a/soulseek-rs/tests/cli_e2e.rs +++ b/soulseek-rs/tests/cli_e2e.rs @@ -2299,6 +2299,87 @@ fn shares_status_reports_what_the_network_will_see() { assert!(record["folders"].as_u64().is_some_and(|n| n >= 1)); } +/// The index, file by file, in the records `browse` gives for a peer — the +/// point being that the two are comparable: what the network sees of us +/// reads exactly like what it sees of anyone else. +#[test] +fn shares_files_lists_the_index_as_a_peer_would_receive_it() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::create_dir_all(share.path().join("album")).expect("subfolder"); + std::fs::write(share.path().join("top.bin"), probe_bytes()) + .expect("share file"); + std::fs::write(share.path().join("album").join("deep.bin"), probe_bytes()) + .expect("share file"); + + let listed = cli( + &server, + "cli_e2e_share_files", + &[ + "--shared-dir", + &share.display(), + "--json", + "shares", + "files", + ], + ); + assert_eq!(code(&listed), EXIT_OK, "stderr: {}", stderr(&listed)); + + let paths: Vec = records(&listed) + .iter() + .map(|line| { + serde_json::from_str::(line).expect("JSON") + ["path"] + .as_str() + .expect("a path") + .to_string() + }) + .collect(); + assert_eq!(paths.len(), 2, "both files, got {paths:?}"); + assert!( + paths.iter().any(|p| p.ends_with("album\\deep.bin")), + "the nested file carries the backslash path peers download by: \ + {paths:?}" + ); + + let first = json_record(&listed); + assert_eq!(first["user"], "cli_e2e_share_files"); + assert!(first["size"].as_u64().is_some_and(|size| size > 0)); + + // The filter narrows to matching paths, and finding nothing is exit 4 + // rather than an empty success. + let narrowed = cli( + &server, + "cli_e2e_share_files", + &[ + "--shared-dir", + &share.display(), + "--json", + "shares", + "files", + "--filter", + "DEEP", + ], + ); + assert_eq!(code(&narrowed), EXIT_OK, "stderr: {}", stderr(&narrowed)); + assert_eq!(records(&narrowed).len(), 1, "case-insensitively"); + + let missing = cli( + &server, + "cli_e2e_share_files", + &[ + "--shared-dir", + &share.display(), + "--json", + "shares", + "files", + "--filter", + "nothing-like-this", + ], + ); + assert_eq!(code(&missing), EXIT_NO_RESULTS); +} + #[test] fn shares_reindex_picks_up_a_file_added_after_startup() { let server = server_or_skip!(); diff --git a/soulseek-rs/tests/tui_e2e.rs b/soulseek-rs/tests/tui_e2e.rs index 6436c57..10db6b1 100644 --- a/soulseek-rs/tests/tui_e2e.rs +++ b/soulseek-rs/tests/tui_e2e.rs @@ -126,6 +126,41 @@ fn finished(tui: &MainTui, _: &str) -> bool { tui.state().searches[0].status == SearchStatus::Completed } +/// The window's answer to "am I actually sharing that?", against a real +/// scan of a real folder rather than a stand-in listing. +#[test] +fn shift_b_shows_the_scanned_index_the_way_a_peer_would_get_it() { + let server = server_or_skip!(); + + let share = tempfile::tempdir().expect("share dir"); + std::fs::create_dir_all(share.path().join("album")).expect("subfolder"); + std::fs::write( + share.path().join("album").join("tui_probe_own.bin"), + [3u8; 32], + ) + .expect("share file"); + + let state = tempfile::tempdir().expect("state dir"); + let downloads = tempfile::tempdir().expect("download dir"); + let me = Arc::new(server.client( + "tui_e2e_own_shares", + vec![share.path().display().to_string()], + )); + let mut window = Window::open(me, state.path(), downloads.path()); + + window.press(KeyCode::Char('B')); + window.press(KeyCode::Char('L')); + let screen = window.frame(); + assert!(screen.contains("My shares"), "{screen}"); + assert!( + screen.contains("album") && screen.contains("tui_probe_own.bin"), + "the scanned tree, folder by folder:\n{screen}" + ); + + window.press(KeyCode::Esc); + assert!(!window.tui.state().show_browse, "Esc hides the popup"); +} + #[test] fn shift_s_runs_a_restored_query_again_and_the_answers_land_on_screen() { let server = server_or_skip!(); From 62252588a403f963e6cc831719b4247263110a86 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 11:33:33 +0200 Subject: [PATCH 52/55] refactor: split the files that had grown past reading size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five files ran between 1.2k and 2.4k lines, and one function past the length the quality gate allows. None of it was doing anything wrong; they had simply accumulated until you had to scroll to hold one in your head. The move throughout is into *child* modules rather than siblings, because a child still sees its parent's private items. So `ClientContext` keeps its private fields and `ServerActor` keeps its private state, while the methods that work on them move out — no encapsulation is widened for the sake of the split. Three `ServerActor` methods become `pub(super)`, which is only acknowledging the calls the parent already made. The client's operation loop and the actor's standing-message match are split by subject: the arms move out whole, so the bodies read exactly as they did inside. Where an arm said `continue` it now says `return` — the match was the last thing the loop did, and none of the arms that moved holds a loop of its own, so skipping the rest of an arm and ending its handler are the same move. The test modules split by topic the same way, which is most of the size: main_tui's alone was 2.1k of the 2.4k lines. No behaviour changes. Verified against a live server: the full e2e suite passes, including the 98 CLI and 19 daemon-protocol cases that drive the dispatch loop this touches. --- soulseek-rs-lib/src/actor/server_actor.rs | 492 +--- .../src/actor/server_actor/handlers.rs | 528 ++++ soulseek-rs-lib/src/client/context.rs | 761 ++++++ soulseek-rs-lib/src/client/mod.rs | 746 +----- .../{operations.rs => operations/mod.rs} | 625 +---- .../src/client/operations/peers.rs | 196 ++ .../src/client/operations/search.rs | 118 + .../src/client/operations/transfers.rs | 219 ++ soulseek-rs-lib/src/client/operations/tree.rs | 103 + soulseek-rs-lib/src/client/tests.rs | 1186 --------- soulseek-rs-lib/src/client/tests/mod.rs | 52 + soulseek-rs-lib/src/client/tests/peers.rs | 173 ++ soulseek-rs-lib/src/client/tests/rooms.rs | 297 +++ soulseek-rs-lib/src/client/tests/search.rs | 167 ++ soulseek-rs-lib/src/client/tests/transfers.rs | 329 +++ soulseek-rs-lib/src/client/tests/users.rs | 200 ++ soulseek-rs/src/ui/main_tui/mod.rs | 2319 +---------------- soulseek-rs/src/ui/main_tui/render/mod.rs | 509 ++++ .../{render.rs => render/shortcuts.rs} | 527 +--- soulseek-rs/src/ui/main_tui/tests/browse.rs | 172 ++ soulseek-rs/src/ui/main_tui/tests/layout.rs | 386 +++ soulseek-rs/src/ui/main_tui/tests/mod.rs | 560 ++++ .../src/ui/main_tui/tests/navigation.rs | 236 ++ soulseek-rs/src/ui/main_tui/tests/rooms.rs | 164 ++ soulseek-rs/src/ui/main_tui/tests/searches.rs | 89 + soulseek-rs/src/ui/main_tui/tests/session.rs | 289 ++ soulseek-rs/src/ui/main_tui/tests/settings.rs | 201 ++ .../src/ui/main_tui/tests/transfers.rs | 239 ++ 28 files changed, 6041 insertions(+), 5842 deletions(-) create mode 100644 soulseek-rs-lib/src/actor/server_actor/handlers.rs create mode 100644 soulseek-rs-lib/src/client/context.rs rename soulseek-rs-lib/src/client/{operations.rs => operations/mod.rs} (54%) create mode 100644 soulseek-rs-lib/src/client/operations/peers.rs create mode 100644 soulseek-rs-lib/src/client/operations/search.rs create mode 100644 soulseek-rs-lib/src/client/operations/transfers.rs create mode 100644 soulseek-rs-lib/src/client/operations/tree.rs delete mode 100644 soulseek-rs-lib/src/client/tests.rs create mode 100644 soulseek-rs-lib/src/client/tests/mod.rs create mode 100644 soulseek-rs-lib/src/client/tests/peers.rs create mode 100644 soulseek-rs-lib/src/client/tests/rooms.rs create mode 100644 soulseek-rs-lib/src/client/tests/search.rs create mode 100644 soulseek-rs-lib/src/client/tests/transfers.rs create mode 100644 soulseek-rs-lib/src/client/tests/users.rs create mode 100644 soulseek-rs/src/ui/main_tui/render/mod.rs rename soulseek-rs/src/ui/main_tui/{render.rs => render/shortcuts.rs} (52%) create mode 100644 soulseek-rs/src/ui/main_tui/tests/browse.rs create mode 100644 soulseek-rs/src/ui/main_tui/tests/layout.rs create mode 100644 soulseek-rs/src/ui/main_tui/tests/mod.rs create mode 100644 soulseek-rs/src/ui/main_tui/tests/navigation.rs create mode 100644 soulseek-rs/src/ui/main_tui/tests/rooms.rs create mode 100644 soulseek-rs/src/ui/main_tui/tests/searches.rs create mode 100644 soulseek-rs/src/ui/main_tui/tests/session.rs create mode 100644 soulseek-rs/src/ui/main_tui/tests/settings.rs create mode 100644 soulseek-rs/src/ui/main_tui/tests/transfers.rs diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index 0e5e4f2..150de3e 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -59,6 +59,7 @@ use std::time::{Duration, Instant}; use crate::{SoulseekRs, debug, error, trace, warn}; +mod handlers; mod types; pub use types::{Context, PeerAddress, UserMessage}; @@ -471,497 +472,6 @@ impl ServerActor { )); } - fn handle_message(&mut self, msg: ServerMessage) { - if !matches!(self.connection_state, ConnectionState::Connected) - && !matches!( - &msg, - ServerMessage::ProcessRead | ServerMessage::Login { .. } - ) - { - self.queued_messages.push(msg); - return; - } - - match msg { - ServerMessage::ConnectToPeer(peer) => { - self.handle_connect_to_peer(peer); - } - ServerMessage::LoginStatus(message) => { - self.handle_login_status(message); - } - ServerMessage::Relogged => self.handle_relogged(), - ServerMessage::PierceFirewall(token) => { - self.send_message( - MessageFactory::build_pierce_firewall_message(token), - ); - } - ServerMessage::SendMessage(message) => { - self.send_message(message); - } - ServerMessage::GetPeerAddress(username) => { - self.send_message(MessageFactory::build_get_peer_address( - &username, - )); - } - ServerMessage::GetPeerAddressResponse { - username, - host, - port, - obfuscation_type, - obfuscated_port, - } => { - self.handle_get_peer_address_response( - username, - host, - port, - obfuscation_type, - obfuscated_port, - ); - } - ServerMessage::PrivateMessageReceived(user_message) => { - self.handle_private_message_received(user_message); - } - ServerMessage::ProcessRead => { - self.process_read(); - } - ServerMessage::Login { - username, - password, - version, - response, - } => { - self.handle_login(username, password, version, response); - } - ServerMessage::FileSearch { token, query } => { - self.file_search(token, &query); - } - ServerMessage::FileSearchRequest { - username, - token, - query, - } => { - self.handle_file_search_request(username, token, query); - } - other => self.handle_social_message(other), - } - } - - /// Rooms and users (codes 7, 5, 36, 13-17, 64): forwarded as room events - /// and user status or stats. Split out, like the standing traffic below, - /// only to keep `handle_message`'s match readable. - fn handle_social_message(&mut self, message: ServerMessage) { - match message { - ServerMessage::RoomListReceived(rooms) => { - self.forward_room_event(RoomEvent::List(rooms)); - } - ServerMessage::UserStatusReceived { - username, - status, - privileged, - } => { - self.forward_to_client(ClientOperation::UserStatusReceived { - username, - status, - privileged, - }); - } - ServerMessage::WatchedUserReceived { - username, - exists, - status, - average_speed, - shared_files, - shared_folders, - } => { - self.forward_to_client(ClientOperation::WatchedUserReceived { - username, - exists, - status, - average_speed, - shared_files, - shared_folders, - }); - } - ServerMessage::UserStatsReceived { - username, - average_speed, - shared_files, - shared_folders, - } => { - self.forward_to_client(ClientOperation::UserStatsReceived { - username, - average_speed, - shared_files, - shared_folders, - }); - } - ServerMessage::RoomJoined { room, users } => { - self.forward_room_event(RoomEvent::Joined { room, users }); - } - ServerMessage::RoomMemberStats { room, stats } => { - self.forward_to_client(ClientOperation::RoomMemberStats { - room, - stats, - }); - } - ServerMessage::RoomLeft { room } => { - self.forward_room_event(RoomEvent::Left { room }); - } - ServerMessage::RoomMessageReceived { - room, - username, - message, - } => { - self.forward_room_event(RoomEvent::Message { - room, - username, - message, - }); - } - ServerMessage::RoomUserJoined { room, username } => { - self.forward_room_event(RoomEvent::UserJoined { - room, - username, - }); - } - ServerMessage::RoomUserLeft { room, username } => { - self.forward_room_event(RoomEvent::UserLeft { room, username }); - } - ServerMessage::RoomTickers { room, tickers } => { - self.forward_room_event(RoomEvent::Tickers { room, tickers }); - } - ServerMessage::RoomTickerAdded { - room, - username, - ticker, - } => { - self.forward_room_event(RoomEvent::TickerAdded { - room, - username, - ticker, - }); - } - ServerMessage::RoomTickerRemoved { room, username } => { - self.forward_room_event(RoomEvent::TickerRemoved { - room, - username, - }); - } - ServerMessage::GlobalRoomMessageReceived { - room, - username, - message, - } => { - self.forward_room_event(RoomEvent::GlobalMessage { - room, - username, - message, - }); - } - other => self.handle_standing_message(other), - } - } - - /// The wishlist and privilege traffic: standing searches (codes 103/104) and - /// who is privileged (codes 69/92). - /// - /// Split out only because it keeps `handle_message`'s match to a readable - /// length; there is no behaviour here beyond dispatch. - fn handle_standing_message(&mut self, message: ServerMessage) { - match message { - ServerMessage::PossibleParents(candidates) => { - self.forward_to_client(ClientOperation::PossibleParents( - candidates, - )); - } - ServerMessage::ResetDistributed => { - self.forward_to_client(ClientOperation::ResetDistributed); - } - ServerMessage::WishlistSearch { token, query } => { - self.queue_message(MessageFactory::build_wishlist_search( - token, &query, - )); - } - ServerMessage::WishlistInterval(seconds) => { - self.forward_to_client(ClientOperation::WishlistInterval( - seconds, - )); - } - ServerMessage::PrivilegedUsers(users) => { - self.forward_to_client(ClientOperation::PrivilegedUsers(users)); - } - ServerMessage::OwnPrivileges(seconds) => { - self.forward_to_client(ClientOperation::OwnPrivileges(seconds)); - } - ServerMessage::CheckPrivileges => { - self.queue_message(MessageFactory::build_check_privileges()); - } - ServerMessage::RecommendationsReceived { - recommended, - unrecommended, - } => { - self.forward_to_client(ClientOperation::Recommendations { - global: false, - recommended, - unrecommended, - }); - } - ServerMessage::GlobalRecommendationsReceived { - recommended, - unrecommended, - } => { - self.forward_to_client(ClientOperation::Recommendations { - global: true, - recommended, - unrecommended, - }); - } - ServerMessage::ItemRecommendationsReceived { - item, - recommendations, - } => { - self.forward_to_client(ClientOperation::ItemRecommendations { - item, - recommendations, - }); - } - ServerMessage::SimilarUsersReceived { users } => { - self.forward_to_client(ClientOperation::SimilarUsers(users)); - } - ServerMessage::ItemSimilarUsersReceived { item, usernames } => { - self.forward_to_client(ClientOperation::ItemSimilarUsers { - item, - usernames, - }); - } - ServerMessage::UserInterestsReceived(interests) => { - self.forward_to_client(ClientOperation::UserInterests( - interests, - )); - } - ServerMessage::CantConnectToPeer { token } => { - self.forward_to_client(ClientOperation::CantConnectToPeer { - token, - }); - } - ServerMessage::PrivateRoomMembers { room, users } => { - self.forward_room_event(RoomEvent::PrivateMembers { - room, - users, - }); - } - ServerMessage::PrivateRoomOperators { room, users } => { - self.forward_room_event(RoomEvent::PrivateOperators { - room, - users, - }); - } - ServerMessage::PrivateRoomRosterChanged { - room, - username, - members, - added, - } => { - self.forward_room_event(RoomEvent::PrivateRosterChanged { - room, - username, - members, - added, - }); - } - ServerMessage::OwnRoomStandingChanged { - room, - members, - granted, - } => { - self.forward_room_event(RoomEvent::OwnStandingChanged { - room, - members, - granted, - }); - } - ServerMessage::CantCreateRoom { room } => { - self.forward_room_event(RoomEvent::CantCreate { room }); - } - ServerMessage::ParentMinSpeed(speed) => { - self.forward_to_client(ClientOperation::ParentMinSpeed(speed)); - } - ServerMessage::ParentSpeedRatio(ratio) => { - self.forward_to_client(ClientOperation::ParentSpeedRatio( - ratio, - )); - } - ServerMessage::ExcludedSearchPhrases(phrases) => { - self.forward_to_client(ClientOperation::ExcludedSearchPhrases( - phrases, - )); - } - other => { - error!("[server] unroutable message: {:?}", other); - } - } - } - - fn handle_connect_to_peer(&self, peer: Peer) { - if let Some(op) = match peer.connection_type { - ConnectionType::P | ConnectionType::F => { - Some(ClientOperation::ConnectToPeer(peer)) - } - ConnectionType::D => None, - } && let Err(e) = self.client_channel.send(op) - { - error!("[server] failed to send ConnectToPeer: {}", e); - } - } - - fn handle_login_status(&mut self, message: bool) { - // Send the post-login handshake exactly once, only on success, - // on the live path (the old ServerActor::login did this but was - // never called). Advertises real shared counts and, when - // listening, the port peers must connect to. - if message { - for msg in post_login_messages( - self.enable_listen, - self.listen_port, - self.shared_folder_count, - self.shared_file_count, - ) { - self.send_message(msg); - } - self.session.clear(); - // The distributed stance is the leaf's to announce, and a new - // session starts without a parent. - self.forward_to_client(ClientOperation::ResetDistributed); - // The server holds interests only for the life of a session. - self.forward_to_client(ClientOperation::SessionEstablished); - } - match self.context.write_safe() { - Ok(mut ctx) => ctx.logged_in = Some(message), - Err(e) => { - error!("[server] LoginStatus write: {}", e); - } - } - } - - fn handle_get_peer_address_response( - &self, - username: String, - host: String, - port: u32, - obfuscation_type: u32, - obfuscated_port: u16, - ) { - debug!( - "[server] Received GetPeerAddress response for {}: {}:{} (obf_type: {}, obf_port: {})", - username, host, port, obfuscation_type, obfuscated_port - ); - - if let Err(e) = - self.client_channel - .send(ClientOperation::GetPeerAddressResponse { - username, - host, - port, - obfuscation_type, - obfuscated_port, - }) - { - error!( - "[server] Error forwarding GetPeerAddress response to client: {}", - e - ); - } - } - - /// Hand an operation to the client loop, logging a dead channel rather - /// than unwinding the actor. - fn forward_to_client(&self, operation: ClientOperation) { - if let Err(e) = self.client_channel.send(operation) { - error!("[server] Error forwarding to client: {}", e); - } - } - - fn handle_private_message_received(&self, user_message: UserMessage) { - debug!("[server] Private message from {}", user_message.username()); - if let Err(e) = self - .client_channel - .send(ClientOperation::PrivateMessageReceived(user_message)) - { - error!( - "[server] Error forwarding private message to client: {}", - e - ); - } - } - - fn handle_login( - &mut self, - username: String, - password: String, - version: ClientVersion, - response: std::sync::mpsc::Sender>, - ) { - if self.stream.is_none() && !self.initiate_connection() { - let _ = response.send(Err(SoulseekRs::NotConnected)); - return; - } - if let Ok(mut ctx) = self.context.write_safe() { - ctx.logged_in = None; - } - self.queue_message(MessageFactory::build_login_message( - &username, &password, version, - )); - - let start = std::time::Instant::now(); - - let context = self.context.clone(); - std::thread::spawn(move || { - loop { - if start.elapsed() >= LOGIN_VERDICT_TIMEOUT { - let _ = response.send(Err(SoulseekRs::Timeout)); - break; - } - - let logged_in = match context.read_safe() { - Ok(ctx) => ctx.logged_in, - Err(e) => { - let _ = response.send(Err(e)); - break; - } - }; - if let Some(logged_in) = logged_in { - let result = if logged_in { - Ok(true) - } else { - Err(SoulseekRs::AuthenticationFailed) - }; - let _ = response.send(result); - break; - } - - std::thread::sleep(Duration::from_millis(100)); - } - }); - } - - fn handle_file_search_request( - &self, - username: String, - token: u32, - query: String, - ) { - if let Err(e) = - self.client_channel.send(ClientOperation::IncomingSearch { - from_parent: false, - username, - token, - query, - }) - { - error!("[server] forward IncomingSearch: {}", e); - } - } - fn process_read(&mut self) { if self.reader.buffer_len() > 0 { self.extract_and_process_messages(); diff --git a/soulseek-rs-lib/src/actor/server_actor/handlers.rs b/soulseek-rs-lib/src/actor/server_actor/handlers.rs new file mode 100644 index 0000000..072cb10 --- /dev/null +++ b/soulseek-rs-lib/src/actor/server_actor/handlers.rs @@ -0,0 +1,528 @@ +//! What the actor does with a message once it has one: the dispatch and the +//! per-message handlers behind it. +//! +//! A child module, so these keep reaching the actor's private state without +//! any of it being widened for the move. + +use super::{ + ClientOperation, ClientVersion, ConnectionState, ConnectionType, Duration, + LOGIN_VERDICT_TIMEOUT, MessageFactory, Peer, RoomEvent, RwLockExt, + ServerActor, ServerMessage, SoulseekRs, UserMessage, debug, error, + post_login_messages, +}; + +impl ServerActor { + pub(super) fn handle_message(&mut self, msg: ServerMessage) { + if !matches!(self.connection_state, ConnectionState::Connected) + && !matches!( + &msg, + ServerMessage::ProcessRead | ServerMessage::Login { .. } + ) + { + self.queued_messages.push(msg); + return; + } + + match msg { + ServerMessage::ConnectToPeer(peer) => { + self.handle_connect_to_peer(peer); + } + ServerMessage::LoginStatus(message) => { + self.handle_login_status(message); + } + ServerMessage::Relogged => self.handle_relogged(), + ServerMessage::PierceFirewall(token) => { + self.send_message( + MessageFactory::build_pierce_firewall_message(token), + ); + } + ServerMessage::SendMessage(message) => { + self.send_message(message); + } + ServerMessage::GetPeerAddress(username) => { + self.send_message(MessageFactory::build_get_peer_address( + &username, + )); + } + ServerMessage::GetPeerAddressResponse { + username, + host, + port, + obfuscation_type, + obfuscated_port, + } => { + self.handle_get_peer_address_response( + username, + host, + port, + obfuscation_type, + obfuscated_port, + ); + } + ServerMessage::PrivateMessageReceived(user_message) => { + self.handle_private_message_received(user_message); + } + ServerMessage::ProcessRead => { + self.process_read(); + } + ServerMessage::Login { + username, + password, + version, + response, + } => { + self.handle_login(username, password, version, response); + } + ServerMessage::FileSearch { token, query } => { + self.file_search(token, &query); + } + ServerMessage::FileSearchRequest { + username, + token, + query, + } => { + self.handle_file_search_request(username, token, query); + } + other => self.handle_social_message(other), + } + } + + /// Rooms and users (codes 7, 5, 36, 13-17, 64): forwarded as room events + /// and user status or stats. Split out, like the standing traffic below, + /// only to keep `handle_message`'s match readable. + fn handle_social_message(&mut self, message: ServerMessage) { + match message { + ServerMessage::RoomListReceived(rooms) => { + self.forward_room_event(RoomEvent::List(rooms)); + } + ServerMessage::UserStatusReceived { + username, + status, + privileged, + } => { + self.forward_to_client(ClientOperation::UserStatusReceived { + username, + status, + privileged, + }); + } + ServerMessage::WatchedUserReceived { + username, + exists, + status, + average_speed, + shared_files, + shared_folders, + } => { + self.forward_to_client(ClientOperation::WatchedUserReceived { + username, + exists, + status, + average_speed, + shared_files, + shared_folders, + }); + } + ServerMessage::UserStatsReceived { + username, + average_speed, + shared_files, + shared_folders, + } => { + self.forward_to_client(ClientOperation::UserStatsReceived { + username, + average_speed, + shared_files, + shared_folders, + }); + } + ServerMessage::RoomJoined { room, users } => { + self.forward_room_event(RoomEvent::Joined { room, users }); + } + ServerMessage::RoomMemberStats { room, stats } => { + self.forward_to_client(ClientOperation::RoomMemberStats { + room, + stats, + }); + } + ServerMessage::RoomLeft { room } => { + self.forward_room_event(RoomEvent::Left { room }); + } + ServerMessage::RoomMessageReceived { + room, + username, + message, + } => { + self.forward_room_event(RoomEvent::Message { + room, + username, + message, + }); + } + ServerMessage::RoomUserJoined { room, username } => { + self.forward_room_event(RoomEvent::UserJoined { + room, + username, + }); + } + ServerMessage::RoomUserLeft { room, username } => { + self.forward_room_event(RoomEvent::UserLeft { room, username }); + } + ServerMessage::RoomTickers { room, tickers } => { + self.forward_room_event(RoomEvent::Tickers { room, tickers }); + } + ServerMessage::RoomTickerAdded { + room, + username, + ticker, + } => { + self.forward_room_event(RoomEvent::TickerAdded { + room, + username, + ticker, + }); + } + ServerMessage::RoomTickerRemoved { room, username } => { + self.forward_room_event(RoomEvent::TickerRemoved { + room, + username, + }); + } + ServerMessage::GlobalRoomMessageReceived { + room, + username, + message, + } => { + self.forward_room_event(RoomEvent::GlobalMessage { + room, + username, + message, + }); + } + other => self.handle_standing_message(other), + } + } + + /// The wishlist and privilege traffic: standing searches (codes 103/104) and + /// who is privileged (codes 69/92). + /// + /// Split out only because it keeps `handle_message`'s match to a readable + /// length; there is no behaviour here beyond dispatch. + fn handle_standing_message(&mut self, message: ServerMessage) { + match message { + ServerMessage::PossibleParents(candidates) => { + self.forward_to_client(ClientOperation::PossibleParents( + candidates, + )); + } + ServerMessage::ResetDistributed => { + self.forward_to_client(ClientOperation::ResetDistributed); + } + ServerMessage::WishlistSearch { token, query } => { + self.queue_message(MessageFactory::build_wishlist_search( + token, &query, + )); + } + ServerMessage::WishlistInterval(seconds) => { + self.forward_to_client(ClientOperation::WishlistInterval( + seconds, + )); + } + ServerMessage::PrivilegedUsers(users) => { + self.forward_to_client(ClientOperation::PrivilegedUsers(users)); + } + ServerMessage::OwnPrivileges(seconds) => { + self.forward_to_client(ClientOperation::OwnPrivileges(seconds)); + } + ServerMessage::CheckPrivileges => { + self.queue_message(MessageFactory::build_check_privileges()); + } + message @ (ServerMessage::RecommendationsReceived { .. } + | ServerMessage::GlobalRecommendationsReceived { + .. + } + | ServerMessage::ItemRecommendationsReceived { + .. + } + | ServerMessage::SimilarUsersReceived { .. } + | ServerMessage::ItemSimilarUsersReceived { .. } + | ServerMessage::UserInterestsReceived(..)) => { + self.handle_taste_message(message); + } + ServerMessage::CantConnectToPeer { token } => { + self.forward_to_client(ClientOperation::CantConnectToPeer { + token, + }); + } + ServerMessage::PrivateRoomMembers { room, users } => { + self.forward_room_event(RoomEvent::PrivateMembers { + room, + users, + }); + } + ServerMessage::PrivateRoomOperators { room, users } => { + self.forward_room_event(RoomEvent::PrivateOperators { + room, + users, + }); + } + ServerMessage::PrivateRoomRosterChanged { + room, + username, + members, + added, + } => { + self.forward_room_event(RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + }); + } + ServerMessage::OwnRoomStandingChanged { + room, + members, + granted, + } => { + self.forward_room_event(RoomEvent::OwnStandingChanged { + room, + members, + granted, + }); + } + ServerMessage::CantCreateRoom { room } => { + self.forward_room_event(RoomEvent::CantCreate { room }); + } + ServerMessage::ParentMinSpeed(speed) => { + self.forward_to_client(ClientOperation::ParentMinSpeed(speed)); + } + ServerMessage::ParentSpeedRatio(ratio) => { + self.forward_to_client(ClientOperation::ParentSpeedRatio( + ratio, + )); + } + ServerMessage::ExcludedSearchPhrases(phrases) => { + self.forward_to_client(ClientOperation::ExcludedSearchPhrases( + phrases, + )); + } + other => { + error!("[server] unroutable message: {:?}", other); + } + } + } + + /// What the server says about taste: the recommendation and similar-user + /// answers, and a user's interests. Split off `handle_standing_message` + /// only to keep that dispatch readable — these arms share nothing with + /// the rest of it. + fn handle_taste_message(&self, message: ServerMessage) { + match message { + ServerMessage::RecommendationsReceived { + recommended, + unrecommended, + } => { + self.forward_to_client(ClientOperation::Recommendations { + global: false, + recommended, + unrecommended, + }); + } + ServerMessage::GlobalRecommendationsReceived { + recommended, + unrecommended, + } => { + self.forward_to_client(ClientOperation::Recommendations { + global: true, + recommended, + unrecommended, + }); + } + ServerMessage::ItemRecommendationsReceived { + item, + recommendations, + } => { + self.forward_to_client(ClientOperation::ItemRecommendations { + item, + recommendations, + }); + } + ServerMessage::SimilarUsersReceived { users } => { + self.forward_to_client(ClientOperation::SimilarUsers(users)); + } + ServerMessage::ItemSimilarUsersReceived { item, usernames } => { + self.forward_to_client(ClientOperation::ItemSimilarUsers { + item, + usernames, + }); + } + ServerMessage::UserInterestsReceived(interests) => { + self.forward_to_client(ClientOperation::UserInterests( + interests, + )); + } + // `handle_standing_message` routes only the variants above here. + _ => {} + } + } + + fn handle_connect_to_peer(&self, peer: Peer) { + if let Some(op) = match peer.connection_type { + ConnectionType::P | ConnectionType::F => { + Some(ClientOperation::ConnectToPeer(peer)) + } + ConnectionType::D => None, + } && let Err(e) = self.client_channel.send(op) + { + error!("[server] failed to send ConnectToPeer: {}", e); + } + } + + pub(super) fn handle_login_status(&mut self, message: bool) { + // Send the post-login handshake exactly once, only on success, + // on the live path (the old ServerActor::login did this but was + // never called). Advertises real shared counts and, when + // listening, the port peers must connect to. + if message { + for msg in post_login_messages( + self.enable_listen, + self.listen_port, + self.shared_folder_count, + self.shared_file_count, + ) { + self.send_message(msg); + } + self.session.clear(); + // The distributed stance is the leaf's to announce, and a new + // session starts without a parent. + self.forward_to_client(ClientOperation::ResetDistributed); + // The server holds interests only for the life of a session. + self.forward_to_client(ClientOperation::SessionEstablished); + } + match self.context.write_safe() { + Ok(mut ctx) => ctx.logged_in = Some(message), + Err(e) => { + error!("[server] LoginStatus write: {}", e); + } + } + } + + fn handle_get_peer_address_response( + &self, + username: String, + host: String, + port: u32, + obfuscation_type: u32, + obfuscated_port: u16, + ) { + debug!( + "[server] Received GetPeerAddress response for {}: {}:{} (obf_type: {}, obf_port: {})", + username, host, port, obfuscation_type, obfuscated_port + ); + + if let Err(e) = + self.client_channel + .send(ClientOperation::GetPeerAddressResponse { + username, + host, + port, + obfuscation_type, + obfuscated_port, + }) + { + error!( + "[server] Error forwarding GetPeerAddress response to client: {}", + e + ); + } + } + + /// Hand an operation to the client loop, logging a dead channel rather + /// than unwinding the actor. + fn forward_to_client(&self, operation: ClientOperation) { + if let Err(e) = self.client_channel.send(operation) { + error!("[server] Error forwarding to client: {}", e); + } + } + + fn handle_private_message_received(&self, user_message: UserMessage) { + debug!("[server] Private message from {}", user_message.username()); + if let Err(e) = self + .client_channel + .send(ClientOperation::PrivateMessageReceived(user_message)) + { + error!( + "[server] Error forwarding private message to client: {}", + e + ); + } + } + + pub(super) fn handle_login( + &mut self, + username: String, + password: String, + version: ClientVersion, + response: std::sync::mpsc::Sender>, + ) { + if self.stream.is_none() && !self.initiate_connection() { + let _ = response.send(Err(SoulseekRs::NotConnected)); + return; + } + if let Ok(mut ctx) = self.context.write_safe() { + ctx.logged_in = None; + } + self.queue_message(MessageFactory::build_login_message( + &username, &password, version, + )); + + let start = std::time::Instant::now(); + + let context = self.context.clone(); + std::thread::spawn(move || { + loop { + if start.elapsed() >= LOGIN_VERDICT_TIMEOUT { + let _ = response.send(Err(SoulseekRs::Timeout)); + break; + } + + let logged_in = match context.read_safe() { + Ok(ctx) => ctx.logged_in, + Err(e) => { + let _ = response.send(Err(e)); + break; + } + }; + if let Some(logged_in) = logged_in { + let result = if logged_in { + Ok(true) + } else { + Err(SoulseekRs::AuthenticationFailed) + }; + let _ = response.send(result); + break; + } + + std::thread::sleep(Duration::from_millis(100)); + } + }); + } + + fn handle_file_search_request( + &self, + username: String, + token: u32, + query: String, + ) { + if let Err(e) = + self.client_channel.send(ClientOperation::IncomingSearch { + from_parent: false, + username, + token, + query, + }) + { + error!("[server] forward IncomingSearch: {}", e); + } + } +} diff --git a/soulseek-rs-lib/src/client/context.rs b/soulseek-rs-lib/src/client/context.rs new file mode 100644 index 0000000..b51a58a --- /dev/null +++ b/soulseek-rs-lib/src/client/context.rs @@ -0,0 +1,761 @@ +//! What the client's shared context does, as opposed to what it holds. +//! +//! A child module rather than a sibling: the state itself stays declared in +//! `client`, so these methods keep reaching its private fields without any +//! of them having to be widened for the move. + +use super::{ + ActorSystem, Arc, AtomicUsize, BROKER_CONNECT_TIMEOUT, + BROWSE_PROTECT_WINDOW, ClientContext, DEFAULT_MAX_PEERS, + DEFAULT_UPLOAD_SLOTS, DownloadStatus, DownloadStore, HashMap, HashSet, + Instant, PENDING_PEER_TTL, Recommendation, RoomEvent, RoomInfo, RoomTicker, + RoomUserStats, SharedDirectory, Shares, SimilarUser, UserInfo, + UserInterests, UserMessage, UserPresence, UserStats, UserStatus, children, + distributed, sorted_unique, +}; + +impl ClientContext { + /// A context for the client that logs in as `username`. + #[must_use] + pub fn for_user(username: &str) -> Self { + Self { + leaf: distributed::Leaf::new(username), + ..Self::new() + } + } + + #[must_use] + pub fn new() -> Self { + let actor_system = Arc::new(ActorSystem::new()); + + Self { + peer_registry: None, + server_sender: None, + operations: None, + leaf: distributed::Leaf::new(""), + children: children::Children::default(), + announced_accept_children: None, + own_average_speed: None, + parent_min_speed: 0, + parent_speed_ratio: 0, + searches: HashMap::new(), + private_messages: Vec::new(), + pending_connect_tokens: HashMap::new(), + max_peers: Arc::new(AtomicUsize::new(DEFAULT_MAX_PEERS)), + shares: Arc::new(Shares::empty()), + shared_directories: Vec::new(), + peer_addresses: HashMap::new(), + pending_peer_messages: HashMap::new(), + uploads: HashMap::new(), + active_uploads: HashMap::new(), + pending_serves: HashMap::new(), + browse_results: HashMap::new(), + folder_contents: HashMap::new(), + peer_infos: HashMap::new(), + pending_browses: HashMap::new(), + room_list: Vec::new(), + room_events: Vec::new(), + room_members: HashMap::new(), + room_member_stats: HashMap::new(), + room_tickers: HashMap::new(), + private_room_members: HashMap::new(), + private_room_operators: HashMap::new(), + recommendations: None, + global_recommendations: None, + item_recommendations: HashMap::new(), + similar_users: Vec::new(), + item_similar_users: HashMap::new(), + user_interests: HashMap::new(), + own_interests: UserInterests::default(), + user_info: HashMap::new(), + watched_users: HashSet::new(), + wishlist_interval: None, + privileged_users: HashSet::new(), + own_privileges: None, + excluded_search_phrases: Vec::new(), + upload_queue: Vec::new(), + upload_seq: 0, + upload_slots: DEFAULT_UPLOAD_SLOTS, + last_upload_speed: 0, + upload_events: Vec::new(), + downloads: DownloadStore::new(), + actor_system, + } + } + + /// Apply a chat-room event: keep the room-list snapshot and the per-room + /// rosters current, then queue the event for the client/UI to drain. + pub fn apply_room_event(&mut self, event: RoomEvent) { + match &event { + RoomEvent::List(rooms) => self.room_list.clone_from(rooms), + RoomEvent::Joined { room, users } => { + let mut members = users.clone(); + members.sort(); + members.dedup(); + self.room_members.insert(room.clone(), members); + } + RoomEvent::Left { room } => { + self.room_members.remove(room); + // A board for a room we are not in is stale; the server sends + // the whole board again on the next join. + self.room_tickers.remove(room); + } + RoomEvent::UserJoined { room, username } => { + let members = + self.room_members.entry(room.clone()).or_default(); + if let Err(at) = members.binary_search(username) { + members.insert(at, username.clone()); + } + } + RoomEvent::UserLeft { room, username } => { + if let Some(members) = self.room_members.get_mut(room) + && let Ok(at) = members.binary_search(username) + { + members.remove(at); + } + } + RoomEvent::Tickers { room, tickers } => { + self.room_tickers.insert(room.clone(), tickers.clone()); + } + RoomEvent::TickerAdded { + room, + username, + ticker, + } => { + // A user has at most one ticker: an added one replaces theirs + // rather than stacking, which is how the server treats it. + let board = self.room_tickers.entry(room.clone()).or_default(); + board.retain(|t| &t.username != username); + board.push(RoomTicker { + username: username.clone(), + ticker: ticker.clone(), + }); + } + RoomEvent::TickerRemoved { room, username } => { + if let Some(board) = self.room_tickers.get_mut(room) { + board.retain(|t| &t.username != username); + } + } + RoomEvent::PrivateMembers { room, users } => { + self.private_room_members + .insert(room.clone(), sorted_unique(users)); + } + RoomEvent::PrivateOperators { room, users } => { + self.private_room_operators + .insert(room.clone(), sorted_unique(users)); + } + RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + } => { + let roster = if *members { + self.private_room_members.entry(room.clone()).or_default() + } else { + self.private_room_operators.entry(room.clone()).or_default() + }; + match (added, roster.binary_search(username)) { + (true, Err(at)) => roster.insert(at, username.clone()), + (false, Ok(at)) => { + roster.remove(at); + } + _ => {} + } + } + RoomEvent::OwnStandingChanged { + room, + members, + granted, + } => { + // Losing membership takes both rosters with it: a room we + // cannot enter has none worth showing. Losing operatorship + // leaves us a member who still sees them, and the server + // narrates that roster change separately (code 144). + if !granted && *members { + self.private_room_members.remove(room); + self.private_room_operators.remove(room); + } + } + RoomEvent::Message { .. } + | RoomEvent::GlobalMessage { .. } + | RoomEvent::CantCreate { .. } => {} + } + self.room_events.push(event); + } + + /// Remember an interest of our own so it can be sent again next login. + /// Interests are matched by the server case-insensitively, and Nicotine+ + /// lowercases them before sending; do the same so two spellings of one + /// interest cannot both be held. + /// Returns the stored spelling, or `None` for an empty item — which is + /// no interest, and must not be sent to the server either. + pub fn add_own_interest( + &mut self, + item: &str, + liked: bool, + ) -> Option { + let item = item.trim().to_lowercase(); + if item.is_empty() { + return None; + } + let list = if liked { + &mut self.own_interests.likes + } else { + &mut self.own_interests.hates + }; + if !list.contains(&item) { + list.push(item.clone()); + } + Some(item) + } + + /// Forget one of our own interests. `None` for an empty item, as above. + pub fn remove_own_interest( + &mut self, + item: &str, + liked: bool, + ) -> Option { + let item = item.trim().to_lowercase(); + if item.is_empty() { + return None; + } + let list = if liked { + &mut self.own_interests.likes + } else { + &mut self.own_interests.hates + }; + list.retain(|held| held != &item); + Some(item) + } + + /// What we like and hate, as last set. + #[must_use] + pub fn own_interests(&self) -> UserInterests { + self.own_interests.clone() + } + + /// Take the `AcceptChildren` answer to send, or `None` when the server + /// already has this one. + pub fn accept_children_change(&mut self) -> Option { + let has_room = self.children.has_room(); + (self.announced_accept_children != Some(has_room)).then(|| { + self.announced_accept_children = Some(has_room); + has_room + }) + } + + /// The upload speed the server records for us, if it has said. + #[must_use] + pub const fn own_average_speed(&self) -> Option { + self.own_average_speed + } + + /// Record what the server says our own upload speed is, and re-derive the + /// child limit from it. + pub fn set_own_average_speed(&mut self, speed: u32) { + self.own_average_speed = Some(speed); + self.apply_child_limits(); + } + + /// Record the server's parent figures (codes 83 and 84). + pub fn set_parent_min_speed(&mut self, speed: u32) { + self.parent_min_speed = speed; + self.apply_child_limits(); + } + + pub fn set_parent_speed_ratio(&mut self, ratio: u32) { + self.parent_speed_ratio = ratio; + self.apply_child_limits(); + } + + fn apply_child_limits(&mut self) { + self.children.set_limits( + self.own_average_speed, + self.parent_min_speed, + self.parent_speed_ratio, + ); + } + + /// Whether something is feeding us the distributed search stream: a + /// parent of our own, or the server relaying searches to us. Children may + /// only hang from a client that has one. + pub fn set_fed_by(&mut self, fed: bool) { + self.children.set_fed(fed); + if !fed { + self.children.drop_all(); + } + } + + /// Record what `username` says about itself. + pub fn store_peer_info( + &mut self, + username: String, + info: crate::message::peer::PeerInfo, + ) { + self.peer_infos.insert(username, info); + } + + /// What `username` last said about itself, if anything. + #[must_use] + pub fn peer_info( + &self, + username: &str, + ) -> Option { + self.peer_infos.get(username).cloned() + } + + /// Drop what we hold about `username`, so a poll after a fresh request + /// cannot report the previous answer. + pub fn invalidate_peer_info(&mut self, username: &str) { + self.peer_infos.remove(username); + } + + /// Record one folder's listing from `username`. + pub fn store_folder_contents( + &mut self, + username: String, + folder: String, + directories: Vec, + ) { + self.folder_contents.insert((username, folder), directories); + } + + /// Remove and return the listing of `folder` from `username`, if it has + /// arrived. + pub fn take_folder_contents( + &mut self, + username: &str, + folder: &str, + ) -> Option> { + self.folder_contents + .remove(&(username.to_string(), folder.to_string())) + } + + /// Who may enter the private room `room`, as last reported. + #[must_use] + pub fn private_room_members(&self, room: &str) -> Vec { + self.private_room_members + .get(room) + .cloned() + .unwrap_or_default() + } + + /// Who runs the private room `room`, as last reported. + #[must_use] + pub fn private_room_operators(&self, room: &str) -> Vec { + self.private_room_operators + .get(room) + .cloned() + .unwrap_or_default() + } + + /// The private rooms we belong to, in a stable order. + #[must_use] + pub fn private_rooms(&self) -> Vec { + let mut rooms: Vec = + self.private_room_members.keys().cloned().collect(); + rooms.sort(); + rooms + } + + /// The ticker board of `room` as last reported by the server. + #[must_use] + pub fn room_tickers(&self, room: &str) -> Vec { + self.room_tickers.get(room).cloned().unwrap_or_default() + } + + /// Record a recommendations reply; `global` selects the server-wide set. + pub fn apply_recommendations( + &mut self, + global: bool, + recommended: Vec, + unrecommended: Vec, + ) { + let slot = if global { + &mut self.global_recommendations + } else { + &mut self.recommendations + }; + *slot = Some((recommended, unrecommended)); + } + + /// The last recommendations reply, or `None` until one arrives. + #[must_use] + pub fn recommendations( + &self, + global: bool, + ) -> Option<(Vec, Vec)> { + if global { + self.global_recommendations.clone() + } else { + self.recommendations.clone() + } + } + + pub fn apply_item_recommendations( + &mut self, + item: String, + recommendations: Vec, + ) { + self.item_recommendations.insert(item, recommendations); + } + + #[must_use] + pub fn item_recommendations(&self, item: &str) -> Vec { + self.item_recommendations + .get(item) + .cloned() + .unwrap_or_default() + } + + pub fn apply_similar_users(&mut self, users: Vec) { + self.similar_users = users; + } + + #[must_use] + pub fn similar_users(&self) -> Vec { + self.similar_users.clone() + } + + pub fn apply_item_similar_users( + &mut self, + item: String, + usernames: Vec, + ) { + self.item_similar_users.insert(item, usernames); + } + + #[must_use] + pub fn item_similar_users(&self, item: &str) -> Vec { + self.item_similar_users + .get(item) + .cloned() + .unwrap_or_default() + } + + pub fn apply_user_interests(&mut self, interests: UserInterests) { + self.user_interests + .insert(interests.username.clone(), interests); + } + + #[must_use] + pub fn user_interests(&self, username: &str) -> Option { + self.user_interests.get(username).cloned() + } + + /// Record the phrases the server excludes (code 160), lowercased here so + /// the per-file check is a plain `contains` on a lowercased path. + pub fn set_excluded_search_phrases(&mut self, phrases: Vec) { + self.excluded_search_phrases = + phrases.iter().map(|p| p.to_lowercase()).collect(); + } + + /// The phrases the server refuses to search for. + #[must_use] + pub fn excluded_search_phrases(&self) -> Vec { + self.excluded_search_phrases.clone() + } + + /// Forget any interests cached for `username`, so a poll after a fresh + /// request cannot mistake the previous answer for this one. + pub fn invalidate_user_interests(&mut self, username: &str) { + self.user_interests.remove(username); + } + + /// Record a `GetUserStatus` reply, merging it with any statistics already + /// received for that user. + pub fn apply_user_status( + &mut self, + username: String, + status: u32, + privileged: bool, + ) { + self.user_info + .entry(username.clone()) + .or_insert_with(|| UserInfo::pending(username)) + .presence = Some(UserPresence { + status: UserStatus::from_code(status), + privileged, + }); + } + + /// Forget what we know about `username`, so the next poll reports the + /// answer to the request being made now rather than the previous one. + pub fn invalidate_user_info(&mut self, username: &str) { + self.user_info.remove(username); + } + + /// Record a `GetUserStats` reply, merging it with any status already + /// received for that user. + pub fn apply_user_stats( + &mut self, + username: String, + average_speed: u32, + shared_files: u32, + shared_folders: u32, + ) { + self.user_info + .entry(username.clone()) + .or_insert_with(|| UserInfo::pending(username)) + .stats = Some(UserStats { + average_speed, + shared_files, + shared_folders, + }); + } + + /// Record that we are now watching `username`. + pub fn add_watched_user(&mut self, username: &str) { + self.watched_users.insert(username.to_string()); + } + + /// Forget a watch, dropping any snapshot we held for that user so a later + /// re-watch reports a fresh answer rather than a stale one. + pub fn remove_watched_user(&mut self, username: &str) { + self.watched_users.remove(username); + self.user_info.remove(username); + } + + /// Everyone we are currently watching, sorted by name. + #[must_use] + pub fn watched_users(&self) -> Vec { + let mut users: Vec = + self.watched_users.iter().cloned().collect(); + users.sort(); + users + } + + /// Record the initial snapshot from a `WatchUser` reply. A username the + /// server does not know carries no stats and is dropped from the watch + /// list, since the server will never push anything for it. + pub fn apply_watched_user( + &mut self, + username: String, + exists: bool, + status: Option, + average_speed: Option, + shared_files: Option, + shared_folders: Option, + ) { + if !exists { + self.watched_users.remove(&username); + return; + } + if let Some(status) = status { + let entry = self + .user_info + .entry(username.clone()) + .or_insert_with(|| UserInfo::pending(username.clone())); + // The watch reply carries no privileged flag, so keep whatever a + // previous GetUserStatus told us rather than asserting `false`. + let privileged = + entry.presence.as_ref().is_some_and(|p| p.privileged); + entry.presence = Some(UserPresence { + status: UserStatus::from_code(status), + privileged, + }); + } + if let (Some(average_speed), Some(shared_files), Some(shared_folders)) = + (average_speed, shared_files, shared_folders) + { + self.apply_user_stats( + username, + average_speed, + shared_files, + shared_folders, + ); + } + } + + /// What the server has said about `username` so far. + #[must_use] + pub fn user_info(&self, username: &str) -> Option { + self.user_info.get(username).cloned() + } + + /// Who is currently in `room`, sorted, or empty when we are not in it. + #[must_use] + pub fn room_members(&self, room: &str) -> Vec { + self.room_members.get(room).cloned().unwrap_or_default() + } + + /// Record the per-member statistics of a room we just joined, replacing + /// any previous snapshot for it. + pub fn apply_room_member_stats( + &mut self, + room: String, + stats: Vec, + ) { + self.room_member_stats.insert(room, stats); + } + + /// What the server reported about the members of `room`, sorted by name, + /// or empty for a room we have not joined. + #[must_use] + pub fn room_member_stats(&self, room: &str) -> Vec { + let mut stats = self + .room_member_stats + .get(room) + .cloned() + .unwrap_or_default(); + stats.sort_by(|a, b| a.username.cmp(&b.username)); + stats + } + + /// The latest snapshot of the public chat-room list. + #[must_use] + pub fn room_list(&self) -> Vec { + self.room_list.clone() + } + + /// Remove and return all chat-room events received since the last call. + #[must_use] + pub fn take_room_events(&mut self) -> Vec { + std::mem::take(&mut self.room_events) + } + + /// Cache a peer's listen address learned from a GetPeerAddress response. + pub fn cache_peer_address( + &mut self, + username: &str, + host: String, + port: u32, + ) { + self.peer_addresses + .insert(username.to_string(), (host, port)); + } + + /// The cached listen address for `username`, if known. + #[must_use] + pub fn peer_address(&self, username: &str) -> Option<(String, u32)> { + self.peer_addresses.get(username).cloned() + } + + /// Queue a peer message to send once a control connection to `username` is up. + pub fn queue_peer_message( + &mut self, + username: &str, + message: crate::message::Message, + ) { + let entry = self + .pending_peer_messages + .entry(username.to_string()) + .or_insert_with(|| (Instant::now(), Vec::new())); + entry.0 = Instant::now(); + entry.1.push(message); + } + + /// Remove and return the messages queued for `username`. + pub fn take_peer_messages( + &mut self, + username: &str, + ) -> Vec { + self.pending_peer_messages + .remove(username) + .map(|(_, messages)| messages) + .unwrap_or_default() + } + + /// Drop peer messages nobody could deliver within + /// [`PENDING_PEER_TTL`]: the searcher went offline, or never existed. + pub(crate) fn expire_pending_peer_messages(&mut self, now: Instant) { + self.pending_peer_messages.retain(|_, (since, _)| { + now.duration_since(*since) < PENDING_PEER_TTL + }); + } + + /// Store a shared-file listing received from browsing `username`. + pub fn store_browse_result( + &mut self, + username: String, + directories: Vec, + ) { + self.pending_browses.remove(&username); + self.browse_results.insert(username, directories); + } + + pub(crate) fn mark_browse_pending(&mut self, username: &str) { + let now = Instant::now(); + self.pending_browses.retain(|_, deadline| *deadline > now); + self.pending_browses + .insert(username.to_string(), now + BROWSE_PROTECT_WINDOW); + } + + /// Remove and return the shared-file listing browsed from `username`. + pub fn take_browse_result( + &mut self, + username: &str, + ) -> Option> { + self.browse_results.remove(username) + } + + /// Remember that a server-brokered connection to `username` is pending under + /// `token`; the peer will quote it back in a PierceFirewall. + pub fn add_pending_connect(&mut self, token: u32, username: String) { + self.pending_connect_tokens + .insert(token, (username, Instant::now() + BROKER_CONNECT_TIMEOUT)); + } + + /// Resolve and consume the peer expected for a brokered connection `token`. + pub fn take_pending_connect(&mut self, token: u32) -> Option { + self.pending_connect_tokens + .remove(&token) + .map(|(username, _)| username) + } + + pub fn take_expired_connects(&mut self, now: Instant) -> Vec { + let mut expired = Vec::new(); + self.pending_connect_tokens + .retain(|_, (username, deadline)| { + if now >= *deadline { + expired.push(std::mem::take(username)); + return false; + } + true + }); + expired + } + + pub(crate) fn protected_peers(&self) -> HashSet { + let mut protected: HashSet = self + .downloads + .list() + .iter() + .filter(|d| { + matches!( + d.status, + DownloadStatus::Queued + | DownloadStatus::InProgress { .. } + | DownloadStatus::Paused { .. } + ) + }) + .map(|d| d.username.clone()) + .collect(); + protected + .extend(self.active_uploads.values().map(|u| u.username.clone())); + protected.extend(self.uploads.values().map(|j| j.downloader.clone())); + protected + .extend(self.upload_queue.iter().map(|q| q.downloader.clone())); + protected.extend(self.pending_serves.keys().cloned()); + protected.extend(self.pending_peer_messages.keys().cloned()); + let now = Instant::now(); + protected.extend( + self.pending_browses + .iter() + .filter(|(_, deadline)| **deadline > now) + .map(|(username, _)| username.clone()), + ); + protected + } + + /// Record a private message received from another user. + pub fn push_private_message(&mut self, message: UserMessage) { + self.private_messages.push(message); + } + + /// Remove and return all buffered private messages. + pub fn take_private_messages(&mut self) -> Vec { + std::mem::take(&mut self.private_messages) + } +} diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index a8ff5c9..21d7dfd 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -629,751 +629,6 @@ impl ClientContext { } } -impl ClientContext { - /// A context for the client that logs in as `username`. - #[must_use] - pub fn for_user(username: &str) -> Self { - Self { - leaf: distributed::Leaf::new(username), - ..Self::new() - } - } - - #[must_use] - pub fn new() -> Self { - let actor_system = Arc::new(ActorSystem::new()); - - Self { - peer_registry: None, - server_sender: None, - operations: None, - leaf: distributed::Leaf::new(""), - children: children::Children::default(), - announced_accept_children: None, - own_average_speed: None, - parent_min_speed: 0, - parent_speed_ratio: 0, - searches: HashMap::new(), - private_messages: Vec::new(), - pending_connect_tokens: HashMap::new(), - max_peers: Arc::new(AtomicUsize::new(DEFAULT_MAX_PEERS)), - shares: Arc::new(Shares::empty()), - shared_directories: Vec::new(), - peer_addresses: HashMap::new(), - pending_peer_messages: HashMap::new(), - uploads: HashMap::new(), - active_uploads: HashMap::new(), - pending_serves: HashMap::new(), - browse_results: HashMap::new(), - folder_contents: HashMap::new(), - peer_infos: HashMap::new(), - pending_browses: HashMap::new(), - room_list: Vec::new(), - room_events: Vec::new(), - room_members: HashMap::new(), - room_member_stats: HashMap::new(), - room_tickers: HashMap::new(), - private_room_members: HashMap::new(), - private_room_operators: HashMap::new(), - recommendations: None, - global_recommendations: None, - item_recommendations: HashMap::new(), - similar_users: Vec::new(), - item_similar_users: HashMap::new(), - user_interests: HashMap::new(), - own_interests: UserInterests::default(), - user_info: HashMap::new(), - watched_users: HashSet::new(), - wishlist_interval: None, - privileged_users: HashSet::new(), - own_privileges: None, - excluded_search_phrases: Vec::new(), - upload_queue: Vec::new(), - upload_seq: 0, - upload_slots: DEFAULT_UPLOAD_SLOTS, - last_upload_speed: 0, - upload_events: Vec::new(), - downloads: DownloadStore::new(), - actor_system, - } - } - - /// Apply a chat-room event: keep the room-list snapshot and the per-room - /// rosters current, then queue the event for the client/UI to drain. - pub fn apply_room_event(&mut self, event: RoomEvent) { - match &event { - RoomEvent::List(rooms) => self.room_list.clone_from(rooms), - RoomEvent::Joined { room, users } => { - let mut members = users.clone(); - members.sort(); - members.dedup(); - self.room_members.insert(room.clone(), members); - } - RoomEvent::Left { room } => { - self.room_members.remove(room); - // A board for a room we are not in is stale; the server sends - // the whole board again on the next join. - self.room_tickers.remove(room); - } - RoomEvent::UserJoined { room, username } => { - let members = - self.room_members.entry(room.clone()).or_default(); - if let Err(at) = members.binary_search(username) { - members.insert(at, username.clone()); - } - } - RoomEvent::UserLeft { room, username } => { - if let Some(members) = self.room_members.get_mut(room) - && let Ok(at) = members.binary_search(username) - { - members.remove(at); - } - } - RoomEvent::Tickers { room, tickers } => { - self.room_tickers.insert(room.clone(), tickers.clone()); - } - RoomEvent::TickerAdded { - room, - username, - ticker, - } => { - // A user has at most one ticker: an added one replaces theirs - // rather than stacking, which is how the server treats it. - let board = self.room_tickers.entry(room.clone()).or_default(); - board.retain(|t| &t.username != username); - board.push(RoomTicker { - username: username.clone(), - ticker: ticker.clone(), - }); - } - RoomEvent::TickerRemoved { room, username } => { - if let Some(board) = self.room_tickers.get_mut(room) { - board.retain(|t| &t.username != username); - } - } - RoomEvent::PrivateMembers { room, users } => { - self.private_room_members - .insert(room.clone(), sorted_unique(users)); - } - RoomEvent::PrivateOperators { room, users } => { - self.private_room_operators - .insert(room.clone(), sorted_unique(users)); - } - RoomEvent::PrivateRosterChanged { - room, - username, - members, - added, - } => { - let roster = if *members { - self.private_room_members.entry(room.clone()).or_default() - } else { - self.private_room_operators.entry(room.clone()).or_default() - }; - match (added, roster.binary_search(username)) { - (true, Err(at)) => roster.insert(at, username.clone()), - (false, Ok(at)) => { - roster.remove(at); - } - _ => {} - } - } - RoomEvent::OwnStandingChanged { - room, - members, - granted, - } => { - // Losing membership takes both rosters with it: a room we - // cannot enter has none worth showing. Losing operatorship - // leaves us a member who still sees them, and the server - // narrates that roster change separately (code 144). - if !granted && *members { - self.private_room_members.remove(room); - self.private_room_operators.remove(room); - } - } - RoomEvent::Message { .. } - | RoomEvent::GlobalMessage { .. } - | RoomEvent::CantCreate { .. } => {} - } - self.room_events.push(event); - } - - /// Remember an interest of our own so it can be sent again next login. - /// Interests are matched by the server case-insensitively, and Nicotine+ - /// lowercases them before sending; do the same so two spellings of one - /// interest cannot both be held. - /// Returns the stored spelling, or `None` for an empty item — which is - /// no interest, and must not be sent to the server either. - pub fn add_own_interest( - &mut self, - item: &str, - liked: bool, - ) -> Option { - let item = item.trim().to_lowercase(); - if item.is_empty() { - return None; - } - let list = if liked { - &mut self.own_interests.likes - } else { - &mut self.own_interests.hates - }; - if !list.contains(&item) { - list.push(item.clone()); - } - Some(item) - } - - /// Forget one of our own interests. `None` for an empty item, as above. - pub fn remove_own_interest( - &mut self, - item: &str, - liked: bool, - ) -> Option { - let item = item.trim().to_lowercase(); - if item.is_empty() { - return None; - } - let list = if liked { - &mut self.own_interests.likes - } else { - &mut self.own_interests.hates - }; - list.retain(|held| held != &item); - Some(item) - } - - /// What we like and hate, as last set. - #[must_use] - pub fn own_interests(&self) -> UserInterests { - self.own_interests.clone() - } - - /// Take the `AcceptChildren` answer to send, or `None` when the server - /// already has this one. - pub fn accept_children_change(&mut self) -> Option { - let has_room = self.children.has_room(); - (self.announced_accept_children != Some(has_room)).then(|| { - self.announced_accept_children = Some(has_room); - has_room - }) - } - - /// The upload speed the server records for us, if it has said. - #[must_use] - pub const fn own_average_speed(&self) -> Option { - self.own_average_speed - } - - /// Record what the server says our own upload speed is, and re-derive the - /// child limit from it. - pub fn set_own_average_speed(&mut self, speed: u32) { - self.own_average_speed = Some(speed); - self.apply_child_limits(); - } - - /// Record the server's parent figures (codes 83 and 84). - pub fn set_parent_min_speed(&mut self, speed: u32) { - self.parent_min_speed = speed; - self.apply_child_limits(); - } - - pub fn set_parent_speed_ratio(&mut self, ratio: u32) { - self.parent_speed_ratio = ratio; - self.apply_child_limits(); - } - - fn apply_child_limits(&mut self) { - self.children.set_limits( - self.own_average_speed, - self.parent_min_speed, - self.parent_speed_ratio, - ); - } - - /// Whether something is feeding us the distributed search stream: a - /// parent of our own, or the server relaying searches to us. Children may - /// only hang from a client that has one. - pub fn set_fed_by(&mut self, fed: bool) { - self.children.set_fed(fed); - if !fed { - self.children.drop_all(); - } - } - - /// Record what `username` says about itself. - pub fn store_peer_info( - &mut self, - username: String, - info: crate::message::peer::PeerInfo, - ) { - self.peer_infos.insert(username, info); - } - - /// What `username` last said about itself, if anything. - #[must_use] - pub fn peer_info( - &self, - username: &str, - ) -> Option { - self.peer_infos.get(username).cloned() - } - - /// Drop what we hold about `username`, so a poll after a fresh request - /// cannot report the previous answer. - pub fn invalidate_peer_info(&mut self, username: &str) { - self.peer_infos.remove(username); - } - - /// Record one folder's listing from `username`. - pub fn store_folder_contents( - &mut self, - username: String, - folder: String, - directories: Vec, - ) { - self.folder_contents.insert((username, folder), directories); - } - - /// Remove and return the listing of `folder` from `username`, if it has - /// arrived. - pub fn take_folder_contents( - &mut self, - username: &str, - folder: &str, - ) -> Option> { - self.folder_contents - .remove(&(username.to_string(), folder.to_string())) - } - - /// Who may enter the private room `room`, as last reported. - #[must_use] - pub fn private_room_members(&self, room: &str) -> Vec { - self.private_room_members - .get(room) - .cloned() - .unwrap_or_default() - } - - /// Who runs the private room `room`, as last reported. - #[must_use] - pub fn private_room_operators(&self, room: &str) -> Vec { - self.private_room_operators - .get(room) - .cloned() - .unwrap_or_default() - } - - /// The private rooms we belong to, in a stable order. - #[must_use] - pub fn private_rooms(&self) -> Vec { - let mut rooms: Vec = - self.private_room_members.keys().cloned().collect(); - rooms.sort(); - rooms - } - - /// The ticker board of `room` as last reported by the server. - #[must_use] - pub fn room_tickers(&self, room: &str) -> Vec { - self.room_tickers.get(room).cloned().unwrap_or_default() - } - - /// Record a recommendations reply; `global` selects the server-wide set. - pub fn apply_recommendations( - &mut self, - global: bool, - recommended: Vec, - unrecommended: Vec, - ) { - let slot = if global { - &mut self.global_recommendations - } else { - &mut self.recommendations - }; - *slot = Some((recommended, unrecommended)); - } - - /// The last recommendations reply, or `None` until one arrives. - #[must_use] - pub fn recommendations( - &self, - global: bool, - ) -> Option<(Vec, Vec)> { - if global { - self.global_recommendations.clone() - } else { - self.recommendations.clone() - } - } - - pub fn apply_item_recommendations( - &mut self, - item: String, - recommendations: Vec, - ) { - self.item_recommendations.insert(item, recommendations); - } - - #[must_use] - pub fn item_recommendations(&self, item: &str) -> Vec { - self.item_recommendations - .get(item) - .cloned() - .unwrap_or_default() - } - - pub fn apply_similar_users(&mut self, users: Vec) { - self.similar_users = users; - } - - #[must_use] - pub fn similar_users(&self) -> Vec { - self.similar_users.clone() - } - - pub fn apply_item_similar_users( - &mut self, - item: String, - usernames: Vec, - ) { - self.item_similar_users.insert(item, usernames); - } - - #[must_use] - pub fn item_similar_users(&self, item: &str) -> Vec { - self.item_similar_users - .get(item) - .cloned() - .unwrap_or_default() - } - - pub fn apply_user_interests(&mut self, interests: UserInterests) { - self.user_interests - .insert(interests.username.clone(), interests); - } - - #[must_use] - pub fn user_interests(&self, username: &str) -> Option { - self.user_interests.get(username).cloned() - } - - /// Record the phrases the server excludes (code 160), lowercased here so - /// the per-file check is a plain `contains` on a lowercased path. - pub fn set_excluded_search_phrases(&mut self, phrases: Vec) { - self.excluded_search_phrases = - phrases.iter().map(|p| p.to_lowercase()).collect(); - } - - /// The phrases the server refuses to search for. - #[must_use] - pub fn excluded_search_phrases(&self) -> Vec { - self.excluded_search_phrases.clone() - } - - /// Forget any interests cached for `username`, so a poll after a fresh - /// request cannot mistake the previous answer for this one. - pub fn invalidate_user_interests(&mut self, username: &str) { - self.user_interests.remove(username); - } - - /// Record a `GetUserStatus` reply, merging it with any statistics already - /// received for that user. - pub fn apply_user_status( - &mut self, - username: String, - status: u32, - privileged: bool, - ) { - self.user_info - .entry(username.clone()) - .or_insert_with(|| UserInfo::pending(username)) - .presence = Some(UserPresence { - status: UserStatus::from_code(status), - privileged, - }); - } - - /// Forget what we know about `username`, so the next poll reports the - /// answer to the request being made now rather than the previous one. - pub fn invalidate_user_info(&mut self, username: &str) { - self.user_info.remove(username); - } - - /// Record a `GetUserStats` reply, merging it with any status already - /// received for that user. - pub fn apply_user_stats( - &mut self, - username: String, - average_speed: u32, - shared_files: u32, - shared_folders: u32, - ) { - self.user_info - .entry(username.clone()) - .or_insert_with(|| UserInfo::pending(username)) - .stats = Some(UserStats { - average_speed, - shared_files, - shared_folders, - }); - } - - /// Record that we are now watching `username`. - pub fn add_watched_user(&mut self, username: &str) { - self.watched_users.insert(username.to_string()); - } - - /// Forget a watch, dropping any snapshot we held for that user so a later - /// re-watch reports a fresh answer rather than a stale one. - pub fn remove_watched_user(&mut self, username: &str) { - self.watched_users.remove(username); - self.user_info.remove(username); - } - - /// Everyone we are currently watching, sorted by name. - #[must_use] - pub fn watched_users(&self) -> Vec { - let mut users: Vec = - self.watched_users.iter().cloned().collect(); - users.sort(); - users - } - - /// Record the initial snapshot from a `WatchUser` reply. A username the - /// server does not know carries no stats and is dropped from the watch - /// list, since the server will never push anything for it. - pub fn apply_watched_user( - &mut self, - username: String, - exists: bool, - status: Option, - average_speed: Option, - shared_files: Option, - shared_folders: Option, - ) { - if !exists { - self.watched_users.remove(&username); - return; - } - if let Some(status) = status { - let entry = self - .user_info - .entry(username.clone()) - .or_insert_with(|| UserInfo::pending(username.clone())); - // The watch reply carries no privileged flag, so keep whatever a - // previous GetUserStatus told us rather than asserting `false`. - let privileged = - entry.presence.as_ref().is_some_and(|p| p.privileged); - entry.presence = Some(UserPresence { - status: UserStatus::from_code(status), - privileged, - }); - } - if let (Some(average_speed), Some(shared_files), Some(shared_folders)) = - (average_speed, shared_files, shared_folders) - { - self.apply_user_stats( - username, - average_speed, - shared_files, - shared_folders, - ); - } - } - - /// What the server has said about `username` so far. - #[must_use] - pub fn user_info(&self, username: &str) -> Option { - self.user_info.get(username).cloned() - } - - /// Who is currently in `room`, sorted, or empty when we are not in it. - #[must_use] - pub fn room_members(&self, room: &str) -> Vec { - self.room_members.get(room).cloned().unwrap_or_default() - } - - /// Record the per-member statistics of a room we just joined, replacing - /// any previous snapshot for it. - pub fn apply_room_member_stats( - &mut self, - room: String, - stats: Vec, - ) { - self.room_member_stats.insert(room, stats); - } - - /// What the server reported about the members of `room`, sorted by name, - /// or empty for a room we have not joined. - #[must_use] - pub fn room_member_stats(&self, room: &str) -> Vec { - let mut stats = self - .room_member_stats - .get(room) - .cloned() - .unwrap_or_default(); - stats.sort_by(|a, b| a.username.cmp(&b.username)); - stats - } - - /// The latest snapshot of the public chat-room list. - #[must_use] - pub fn room_list(&self) -> Vec { - self.room_list.clone() - } - - /// Remove and return all chat-room events received since the last call. - #[must_use] - pub fn take_room_events(&mut self) -> Vec { - std::mem::take(&mut self.room_events) - } - - /// Cache a peer's listen address learned from a GetPeerAddress response. - pub fn cache_peer_address( - &mut self, - username: &str, - host: String, - port: u32, - ) { - self.peer_addresses - .insert(username.to_string(), (host, port)); - } - - /// The cached listen address for `username`, if known. - #[must_use] - pub fn peer_address(&self, username: &str) -> Option<(String, u32)> { - self.peer_addresses.get(username).cloned() - } - - /// Queue a peer message to send once a control connection to `username` is up. - pub fn queue_peer_message( - &mut self, - username: &str, - message: crate::message::Message, - ) { - let entry = self - .pending_peer_messages - .entry(username.to_string()) - .or_insert_with(|| (Instant::now(), Vec::new())); - entry.0 = Instant::now(); - entry.1.push(message); - } - - /// Remove and return the messages queued for `username`. - pub fn take_peer_messages( - &mut self, - username: &str, - ) -> Vec { - self.pending_peer_messages - .remove(username) - .map(|(_, messages)| messages) - .unwrap_or_default() - } - - /// Drop peer messages nobody could deliver within - /// [`PENDING_PEER_TTL`]: the searcher went offline, or never existed. - pub(crate) fn expire_pending_peer_messages(&mut self, now: Instant) { - self.pending_peer_messages.retain(|_, (since, _)| { - now.duration_since(*since) < PENDING_PEER_TTL - }); - } - - /// Store a shared-file listing received from browsing `username`. - pub fn store_browse_result( - &mut self, - username: String, - directories: Vec, - ) { - self.pending_browses.remove(&username); - self.browse_results.insert(username, directories); - } - - pub(crate) fn mark_browse_pending(&mut self, username: &str) { - let now = Instant::now(); - self.pending_browses.retain(|_, deadline| *deadline > now); - self.pending_browses - .insert(username.to_string(), now + BROWSE_PROTECT_WINDOW); - } - - /// Remove and return the shared-file listing browsed from `username`. - pub fn take_browse_result( - &mut self, - username: &str, - ) -> Option> { - self.browse_results.remove(username) - } - - /// Remember that a server-brokered connection to `username` is pending under - /// `token`; the peer will quote it back in a PierceFirewall. - pub fn add_pending_connect(&mut self, token: u32, username: String) { - self.pending_connect_tokens - .insert(token, (username, Instant::now() + BROKER_CONNECT_TIMEOUT)); - } - - /// Resolve and consume the peer expected for a brokered connection `token`. - pub fn take_pending_connect(&mut self, token: u32) -> Option { - self.pending_connect_tokens - .remove(&token) - .map(|(username, _)| username) - } - - pub fn take_expired_connects(&mut self, now: Instant) -> Vec { - let mut expired = Vec::new(); - self.pending_connect_tokens - .retain(|_, (username, deadline)| { - if now >= *deadline { - expired.push(std::mem::take(username)); - return false; - } - true - }); - expired - } - - pub(crate) fn protected_peers(&self) -> HashSet { - let mut protected: HashSet = self - .downloads - .list() - .iter() - .filter(|d| { - matches!( - d.status, - DownloadStatus::Queued - | DownloadStatus::InProgress { .. } - | DownloadStatus::Paused { .. } - ) - }) - .map(|d| d.username.clone()) - .collect(); - protected - .extend(self.active_uploads.values().map(|u| u.username.clone())); - protected.extend(self.uploads.values().map(|j| j.downloader.clone())); - protected - .extend(self.upload_queue.iter().map(|q| q.downloader.clone())); - protected.extend(self.pending_serves.keys().cloned()); - protected.extend(self.pending_peer_messages.keys().cloned()); - let now = Instant::now(); - protected.extend( - self.pending_browses - .iter() - .filter(|(_, deadline)| **deadline > now) - .map(|(username, _)| username.clone()), - ); - protected - } - - /// Record a private message received from another user. - pub fn push_private_message(&mut self, message: UserMessage) { - self.private_messages.push(message); - } - - /// Remove and return all buffered private messages. - pub fn take_private_messages(&mut self) -> Vec { - std::mem::take(&mut self.private_messages) - } -} pub struct Client { enable_listen: bool, listen_port: u16, @@ -1592,6 +847,7 @@ impl Client { mod children; mod connection; +mod context; mod distributed; mod downloads; mod operations; diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations/mod.rs similarity index 54% rename from soulseek-rs-lib/src/client/operations.rs rename to soulseek-rs-lib/src/client/operations/mod.rs index f65684a..d8f9905 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations/mod.rs @@ -8,6 +8,11 @@ use super::{ use crate::message::server::MessageFactory; use crate::peer::DownloadError; +mod peers; +mod search; +mod transfers; +mod tree; + const CONNECT_SWEEP_INTERVAL: Duration = Duration::from_secs(1); /// How often to ask the peers holding our queued downloads where they sit @@ -98,118 +103,24 @@ impl Client { } } } - ClientOperation::PeerDisconnected(id, username, error) => { - // Scope the read guard: process_failed_uploads - // below acquires a write lock on the same - // RwLock, which would self-deadlock the entire - // client ops loop if this read guard were still - // held on this thread. Evict only if this exact - // actor still occupies the slot, so a replaced - // actor's shutdown can't remove its successor. - { - let context = match client_context.read_safe() { - Ok(c) => c, - Err(e) => { - error!( - "[client] PeerDisconnected read: {}", - e - ); - continue; - } - }; - if let Some(ref registry) = context.peer_registry - && let Some(handle) = - registry.remove_peer_if(&username, id) - { - let _ = handle.stop(); - } - } - // Only an error is evidence the peer is gone. A clean - // close — our idle reaper, or a remote client tidying - // an idle socket while it waits in our queue — must - // not throw away everything that peer has queued: the - // connection comes back, the queue cannot. A departed - // peer wedging uploads shut is still covered: its - // erroring transfer fails within the socket deadlines - // and the error branch here frees its slots. - if let Some(error) = error { - warn!( - "[client] Peer {} disconnected with error: {:?}", - username, error - ); - Self::process_failed_uploads( - client_context.clone(), - &username, - None, - ); - Self::release_upload_slots( - &client_context, - &username, - ); - } + operation @ (ClientOperation::PeerDisconnected(..) + | ClientOperation::PeerConnectFailed(..) + | ClientOperation::ChildConnected { .. }) => { + Self::on_peers( + operation, + &client_context, + &own_username, + ); } - ClientOperation::DownloadFromPeer(token, peer, allowed) => { - let maybe_download = match client_context.write_safe() { - Ok(mut ctx) => ctx - .downloads - .claim_for_peer(token, &peer.username), - Err(e) => { - error!( - "[client] DownloadFromPeer write: {}", - e - ); - continue; - } - }; - trace!( - "[client] DownloadFromPeer token: {} peer: {:?}", - token, peer + operation @ (ClientOperation::DownloadFromPeer(..) + | ClientOperation::UpdateDownloadTokens(..) + | ClientOperation::StartUpload { .. } + | ClientOperation::QueueUpload { .. }) => { + Self::on_transfers( + operation, + &client_context, + &own_username, ); - let Some(download) = maybe_download else { - debug!( - "[client] transfer token {} is missing, belongs \ - to another peer, or is already claimed; ignoring", - token, - ); - continue; - }; - - let own_username = own_username.clone(); - let client_context = client_context.clone(); - - thread::spawn(move || { - let download_peer = DownloadPeer::new( - download.username.clone(), - peer.host.clone(), - peer.port, - token, - allowed, - own_username, - ); - match download_peer.download_file( - client_context, - Some(download.clone()), - None, - ) { - Ok((download, filename)) => { - info!( - "Successfully downloaded {} bytes to {}", - download.size, filename - ); - } - Err(DownloadError::Cancelled) => {} - Err(e) => { - error!( - "Failed to download file '{}' from {}:{} (token: {}) - Error: {}", - download.filename, - peer.host, - peer.port, - download.token, - e - ); - } - } - }); } ClientOperation::GetPeerAddressResponse { username, @@ -310,78 +221,6 @@ impl Client { ); } } - ClientOperation::UpdateDownloadTokens( - transfer, - username, - ) => { - let mut context = match client_context.write_safe() { - Ok(c) => c, - Err(e) => { - error!( - "[client] UpdateDownloadTokens write: {}", - e - ); - continue; - } - }; - - let download_to_update = - context.get_downloads().iter().find_map(|d| { - if d.username == username - && d.filename == transfer.filename - { - Some((d.token, d.clone())) - } else { - None - } - }); - - let cancelled = download_to_update - .as_ref() - .is_some_and(|(_, d)| { - matches!(d.status, DownloadStatus::Cancelled) - }); - if !cancelled - && let Some((old_token, download)) = - download_to_update - { - trace!( - "[client] UpdateDownloadTokens found {old_token}, transfer: {:?}", - transfer - ); - - context.add_download(Download { - token: transfer.token, - size: transfer.size, - ..download - }); - context.remove_download(old_token); - } - - // Only now invite the file connection: it is - // matched by this token, which is recorded as - // of the line above. Answering any earlier - // races the peer's connection against our own - // bookkeeping. - let registry = context.peer_registry.clone(); - drop(context); - if let Some(registry) = registry { - let response = if cancelled { - MessageFactory::build_transfer_denial_message( - transfer.token, - "Cancelled", - ) - } else { - MessageFactory::build_transfer_response_message( - transfer, - ) - }; - let _ = registry.send_to_peer( - &username, - PeerMessage::SendMessage(response), - ); - } - } ClientOperation::UploadFailed(username, filename) => { Self::process_failed_uploads( client_context.clone(), @@ -583,138 +422,12 @@ impl Client { } } } - ClientOperation::IncomingSearch { - username, - token, - query, - from_parent, - } => { - // Every search we receive comes from the tree: from - // our parent, or from the server acting as one. That - // is what qualifies us to carry children at all — - // Nicotine+ refuses them until something feeds it — - // and passing the search down is the whole duty. - let (capacity_changed, answer) = match client_context - .write_safe() - { - Ok(mut ctx) => { - let was_fed = ctx.children.is_fed(); - let before = ctx.children.len(); - ctx.set_fed_by(true); - if !from_parent { - // A parent's search was passed down where - // it arrived; this is the server's. - ctx.children.broadcast_search( - &username, token, &query, - ); - } - // Answering costs a scan of everything we - // share, so the budget applies to searches - // from the tree. The server's own are few. - let answer = !from_parent - || ctx.leaf.admit_search(Instant::now()); - // A child whose socket has gone is dropped by - // that write, which frees a slot the server - // should hear about. - ( - !was_fed || ctx.children.len() != before, - answer, - ) - } - Err(_) => (false, false), - }; - if capacity_changed { - Self::announce_child_capacity(&client_context); - } - if !answer { - continue; - } - - // Don't answer our own distributed search. - if username == own_username { - continue; - } - let response = match client_context.read_safe() { - Ok(ctx) => build_search_response( - &ctx.shares, - &own_username, - token, - &query, - ctx.has_free_upload_slot(), - ctx.last_upload_speed, - ctx.upload_queue.len() as u32, - &ctx.excluded_search_phrases, - ), - Err(e) => { - error!("[client] IncomingSearch read: {}", e); - continue; - } - }; - let Some(message) = response else { - continue; // no matching shares - }; - - // Deliver to the searcher: send now if we have a - // control connection, else open one and queue. - let (connected, registry, server_sender) = - match client_context.read_safe() { - Ok(ctx) => ( - ctx.peer_registry - .as_ref() - .is_some_and(|r| r.contains(&username)), - ctx.peer_registry.clone(), - ctx.server_sender.clone(), - ), - Err(_) => continue, - }; - if connected { - if let Some(registry) = registry { - let _ = registry.send_to_peer( - &username, - PeerMessage::SendMessage(message), - ); - } - } else { - if let Ok(mut ctx) = client_context.write_safe() { - ctx.queue_peer_message(&username, message); - } - if let Some(sender) = server_sender { - let _ = sender.send( - ServerMessage::GetPeerAddress(username), - ); - } - } - } - ClientOperation::QueueUpload { - requester_key, - filename, - } => { - // The peer served next may not be this one. - match client_context.write_safe() { - Ok(mut ctx) => { - let Some(file) = ctx.shares.get(&filename) - else { - debug!( - "[client] QueueUpload for unknown file {}", - filename - ); - continue; - }; - let size = file.size; - let real_path = file.real_path.clone(); - ctx.enqueue_upload( - &requester_key, - &filename, - real_path, - size, - ); - } - Err(e) => { - error!("[client] QueueUpload write: {}", e); - continue; - } - } - Self::pump_upload_queue(&client_context); + operation @ ClientOperation::IncomingSearch { .. } => { + Self::on_search( + operation, + &client_context, + &own_username, + ); } ClientOperation::PlaceInQueueRequested { requester_key, @@ -760,99 +473,16 @@ impl Client { ); } } - ClientOperation::ParentBranchLevel { - parent, - link, - level, - } => { - let Ok(mut ctx) = client_context.write_safe() else { - continue; - }; - if let Some(branch) = - ctx.leaf.branch_level(&parent, link, level) - { - super::distributed::announce_move( - &mut ctx, &branch, true, - ); - } + operation @ (ClientOperation::ParentSearch { .. } + | ClientOperation::ParentBranchLevel { + .. } - ClientOperation::ParentBranchRoot { - parent, - link, - root, - } => { - let Ok(mut ctx) = client_context.write_safe() else { - continue; - }; - if let Some(branch) = - ctx.leaf.branch_root(&parent, link, &root) - { - super::distributed::announce_move( - &mut ctx, &branch, true, - ); - } + | ClientOperation::ParentBranchRoot { + .. } - ClientOperation::ParentSearch { - parent, - link, - username, - token, - query, - } => { - let Ok(mut ctx) = client_context.write_safe() else { - continue; - }; - if let Some(branch) = - ctx.leaf.search_from(&parent, link) - { - super::distributed::announce_move( - &mut ctx, &branch, true, - ); - } - if ctx.leaf.is_parent(&parent, link) { - // The tree's stream is not ours to throttle: every - // search goes down to our children. The budget - // below bounds only the share scan we do for our - // own reply. - ctx.children - .broadcast_search(&username, token, &query); - if let Some(ops) = &ctx.operations { - let _ = - ops.send(ClientOperation::IncomingSearch { - username, - token, - query, - from_parent: true, - }); - } - } - } - ClientOperation::ParentClosed { parent, link } => { - let Ok(mut ctx) = client_context.write_safe() else { - continue; - }; - if ctx.leaf.closed(&parent, link) { - let branch = ctx.leaf.branch(); - super::distributed::announce_move( - &mut ctx, &branch, false, - ); - } - } - // Also what a fresh login means: whatever tree we hung - // from belongs to the old session. - ClientOperation::ResetDistributed => { - let Ok(mut ctx) = client_context.write_safe() else { - continue; - }; - ctx.leaf.reset(); - // A fresh session feeds us nothing until the tree (or - // the server) starts again, so the children we were - // carrying are let go rather than left starving. - ctx.set_fed_by(false); - let branch = ctx.leaf.branch(); - super::distributed::announce_move( - &mut ctx, &branch, false, - ); + | ClientOperation::ParentClosed { .. } + | ClientOperation::ResetDistributed) => { + Self::on_tree(operation, &client_context); } ClientOperation::PrivilegedUsers(users) => { if let Ok(mut ctx) = client_context.write_safe() { @@ -911,51 +541,6 @@ impl Client { ctx.apply_user_interests(interests); } } - ClientOperation::ChildConnected { username, stream } => { - // A peer wants to hang from us. Take it on if this - // client serves children and has room, then tell it - // where our branch sits so it can report its own - // place; otherwise the socket is dropped here and the - // peer looks for another parent. - let Ok(mut ctx) = client_context.write_safe() else { - continue; - }; - if !ctx.children.has_room() { - continue; - } - // A child that stops reading must not wedge us: the - // relay writes under the context lock, so the write - // is bounded and a child that hits it loses its slot. - if stream.set_nodelay(true).is_err() - || stream - .set_write_timeout(Some(CHILD_WRITE_TIMEOUT)) - .is_err() - { - continue; - } - if username == own_username { - continue; // we cannot hang from ourselves - } - if ctx.children.accept(&username, stream) { - let branch = ctx.leaf.branch(); - ctx.children.send_stance_to( - &username, - &branch.root, - branch.level, - ); - debug!( - "[distributed] carrying {} children", - ctx.children.len() - ); - // The server stops offering us to peers once we - // are full, and starts again when one leaves. - let full = !ctx.children.has_room(); - drop(ctx); - if full { - Self::announce_child_capacity(&client_context); - } - } - } ClientOperation::PeerInfoReceived { username, info } => { if let Ok(mut ctx) = client_context.write_safe() { ctx.store_peer_info(username, info); @@ -1058,49 +643,6 @@ impl Client { ); } } - ClientOperation::StartUpload { token } => { - // The peer accepted our offer: resolve their - // address (from the code-9 GetPeerAddress) and - // stream the file, or queue until it resolves. - let (job_addr, downloader) = match client_context - .write_safe() - { - Ok(mut ctx) => { - ctx.mark_offer_answered(token); - let Some(job) = ctx.uploads.get(&token) else { - continue; - }; - ( - ctx.peer_address(&job.downloader), - job.downloader.clone(), - ) - } - Err(_) => continue, - }; - if let Some((host, port)) = job_addr { - Self::spawn_serve( - &client_context, - &own_username, - token, - host, - port, - ); - } else { - if let Ok(mut ctx) = client_context.write_safe() { - ctx.pending_serves - .entry(downloader.clone()) - .or_default() - .push(token); - } - if let Ok(ctx) = client_context.read_safe() - && let Some(sender) = ctx.server_sender.clone() - { - let _ = sender.send( - ServerMessage::GetPeerAddress(downloader), - ); - } - } - } ClientOperation::ShareListRequested { requester_key } => { Self::reply_to_peer( &client_context, @@ -1156,103 +698,6 @@ impl Client { ctx.store_browse_result(username, directories); } } - ClientOperation::PeerConnectFailed( - id, - username, - brokered_token, - ) => { - // Direct connect failed. A dial the server asked - // us to make is answered with CantConnectToPeer - // quoting the peer's own token: that peer is - // already waiting on the server, and asking it to - // broker the same connection back only trades one - // unreachable direction for the other. Every other - // dial falls back to the broker below. - if let Some(peer_token) = brokered_token { - let server_sender = match client_context - .write_safe() - { - Ok(ctx) => { - if let Some(handle) = - ctx.peer_registry.as_ref().and_then( - |r| r.remove_peer_if(&username, id), - ) - { - let _ = handle.stop(); - } - ctx.server_sender.clone() - } - Err(e) => { - error!( - "[client] PeerConnectFailed write: {}", - e - ); - continue; - } - }; - if let Some(sender) = server_sender { - let msg = crate::message::server::MessageFactory::build_cant_connect_to_peer( - peer_token, - &username, - ); - let _ = sender - .send(ServerMessage::SendMessage(msg)); - } - // Nothing else is coming from this peer, so - // anything queued for it fails now rather than - // waiting out its timeout. - Self::fail_queued_downloads( - &client_context, - &username, - ); - continue; - } - - // Direct connect failed: ask the server to - // broker it. Register a correlation token, then - // send ConnectToPeer so the (firewalled) peer - // connects back to our listener quoting it. - let token = next_connect_token(); - let server_sender = match client_context.write_safe() { - Ok(mut ctx) => { - // Reap the dead outbound actor so it - // stops pinning a pool worker and no - // longer shadows the brokered reconnect - // (a stale registry entry would make - // later downloads queue into a dead, - // streamless actor and hang). Identity- - // aware so a newer namesake is untouched. - if let Some(handle) = - ctx.peer_registry.as_ref().and_then(|r| { - r.remove_peer_if(&username, id) - }) - { - let _ = handle.stop(); - } - ctx.add_pending_connect( - token, - username.clone(), - ); - ctx.server_sender.clone() - } - Err(e) => { - error!( - "[client] PeerConnectFailed write: {}", - e - ); - continue; - } - }; - let Some(sender) = server_sender else { - continue; - }; - let msg = crate::message::server::MessageFactory::build_connect_to_peer( - token, - &username, - ConnectionType::P, - ); - let _ = sender.send(ServerMessage::SendMessage(msg)); - } } } }); diff --git a/soulseek-rs-lib/src/client/operations/peers.rs b/soulseek-rs-lib/src/client/operations/peers.rs new file mode 100644 index 0000000..41028d5 --- /dev/null +++ b/soulseek-rs-lib/src/client/operations/peers.rs @@ -0,0 +1,196 @@ +//! Dispatch arms about a peer connection appearing or going away. +//! +//! Lifted out of the dispatch loop whole, so the bodies read exactly as they +//! did inside it — including `own_username`, which is re-owned on the way in +//! rather than have every line that clones it rewritten. + +use super::{ + Arc, CHILD_WRITE_TIMEOUT, Client, ClientContext, ClientOperation, + ConnectionType, RwLock, RwLockExt, ServerMessage, debug, error, + next_connect_token, warn, +}; + +impl Client { + pub(super) fn on_peers( + operation: ClientOperation, + client_context: &Arc>, + own_username: &str, + ) { + let own_username = own_username.to_string(); + match operation { + ClientOperation::PeerDisconnected(id, username, error) => { + // Scope the read guard: process_failed_uploads + // below acquires a write lock on the same + // RwLock, which would self-deadlock the entire + // client ops loop if this read guard were still + // held on this thread. Evict only if this exact + // actor still occupies the slot, so a replaced + // actor's shutdown can't remove its successor. + { + let context = match client_context.read_safe() { + Ok(c) => c, + Err(e) => { + error!("[client] PeerDisconnected read: {}", e); + return; + } + }; + if let Some(ref registry) = context.peer_registry + && let Some(handle) = + registry.remove_peer_if(&username, id) + { + let _ = handle.stop(); + } + } + // Only an error is evidence the peer is gone. A clean + // close — our idle reaper, or a remote client tidying + // an idle socket while it waits in our queue — must + // not throw away everything that peer has queued: the + // connection comes back, the queue cannot. A departed + // peer wedging uploads shut is still covered: its + // erroring transfer fails within the socket deadlines + // and the error branch here frees its slots. + if let Some(error) = error { + warn!( + "[client] Peer {} disconnected with error: {:?}", + username, error + ); + Self::process_failed_uploads( + client_context.clone(), + &username, + None, + ); + Self::release_upload_slots(client_context, &username); + } + } + ClientOperation::PeerConnectFailed( + id, + username, + brokered_token, + ) => { + // Direct connect failed. A dial the server asked + // us to make is answered with CantConnectToPeer + // quoting the peer's own token: that peer is + // already waiting on the server, and asking it to + // broker the same connection back only trades one + // unreachable direction for the other. Every other + // dial falls back to the broker below. + if let Some(peer_token) = brokered_token { + let server_sender = match client_context.write_safe() { + Ok(ctx) => { + if let Some(handle) = ctx + .peer_registry + .as_ref() + .and_then(|r| r.remove_peer_if(&username, id)) + { + let _ = handle.stop(); + } + ctx.server_sender.clone() + } + Err(e) => { + error!("[client] PeerConnectFailed write: {}", e); + return; + } + }; + if let Some(sender) = server_sender { + let msg = crate::message::server::MessageFactory::build_cant_connect_to_peer( + peer_token, + &username, + ); + let _ = sender.send(ServerMessage::SendMessage(msg)); + } + // Nothing else is coming from this peer, so + // anything queued for it fails now rather than + // waiting out its timeout. + Self::fail_queued_downloads(client_context, &username); + return; + } + + // Direct connect failed: ask the server to + // broker it. Register a correlation token, then + // send ConnectToPeer so the (firewalled) peer + // connects back to our listener quoting it. + let token = next_connect_token(); + let server_sender = match client_context.write_safe() { + Ok(mut ctx) => { + // Reap the dead outbound actor so it + // stops pinning a pool worker and no + // longer shadows the brokered reconnect + // (a stale registry entry would make + // later downloads queue into a dead, + // streamless actor and hang). Identity- + // aware so a newer namesake is untouched. + if let Some(handle) = ctx + .peer_registry + .as_ref() + .and_then(|r| r.remove_peer_if(&username, id)) + { + let _ = handle.stop(); + } + ctx.add_pending_connect(token, username.clone()); + ctx.server_sender.clone() + } + Err(e) => { + error!("[client] PeerConnectFailed write: {}", e); + return; + } + }; + let Some(sender) = server_sender else { + return; + }; + let msg = crate::message::server::MessageFactory::build_connect_to_peer( + token, + &username, + ConnectionType::P, + ); + let _ = sender.send(ServerMessage::SendMessage(msg)); + } + ClientOperation::ChildConnected { username, stream } => { + // A peer wants to hang from us. Take it on if this + // client serves children and has room, then tell it + // where our branch sits so it can report its own + // place; otherwise the socket is dropped here and the + // peer looks for another parent. + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if !ctx.children.has_room() { + return; + } + // A child that stops reading must not wedge us: the + // relay writes under the context lock, so the write + // is bounded and a child that hits it loses its slot. + if stream.set_nodelay(true).is_err() + || stream + .set_write_timeout(Some(CHILD_WRITE_TIMEOUT)) + .is_err() + { + return; + } + if username == own_username { + return; // we cannot hang from ourselves + } + if ctx.children.accept(&username, stream) { + let branch = ctx.leaf.branch(); + ctx.children.send_stance_to( + &username, + &branch.root, + branch.level, + ); + debug!( + "[distributed] carrying {} children", + ctx.children.len() + ); + // The server stops offering us to peers once we + // are full, and starts again when one leaves. + let full = !ctx.children.has_room(); + drop(ctx); + if full { + Self::announce_child_capacity(client_context); + } + } + } + // The dispatch loop routes only the variants above to here. + _ => {} + } + } +} diff --git a/soulseek-rs-lib/src/client/operations/search.rs b/soulseek-rs-lib/src/client/operations/search.rs new file mode 100644 index 0000000..526633b --- /dev/null +++ b/soulseek-rs-lib/src/client/operations/search.rs @@ -0,0 +1,118 @@ +//! The dispatch arm that answers a search reaching us from the network. +//! +//! Lifted out of the dispatch loop whole, so the bodies read exactly as they +//! did inside it — including `own_username`, which is re-owned on the way in +//! rather than have every line that clones it rewritten. + +use super::{ + Arc, Client, ClientContext, ClientOperation, Instant, PeerMessage, RwLock, + RwLockExt, ServerMessage, build_search_response, error, +}; + +impl Client { + pub(super) fn on_search( + operation: ClientOperation, + client_context: &Arc>, + own_username: &str, + ) { + let own_username = own_username.to_string(); + let ClientOperation::IncomingSearch { + username, + token, + query, + from_parent, + } = operation + else { + return; + }; + { + // Every search we receive comes from the tree: from + // our parent, or from the server acting as one. That + // is what qualifies us to carry children at all — + // Nicotine+ refuses them until something feeds it — + // and passing the search down is the whole duty. + let (capacity_changed, answer) = match client_context.write_safe() { + Ok(mut ctx) => { + let was_fed = ctx.children.is_fed(); + let before = ctx.children.len(); + ctx.set_fed_by(true); + if !from_parent { + // A parent's search was passed down where + // it arrived; this is the server's. + ctx.children.broadcast_search(&username, token, &query); + } + // Answering costs a scan of everything we + // share, so the budget applies to searches + // from the tree. The server's own are few. + let answer = + !from_parent || ctx.leaf.admit_search(Instant::now()); + // A child whose socket has gone is dropped by + // that write, which frees a slot the server + // should hear about. + (!was_fed || ctx.children.len() != before, answer) + } + Err(_) => (false, false), + }; + if capacity_changed { + Self::announce_child_capacity(client_context); + } + if !answer { + return; + } + + // Don't answer our own distributed search. + if username == own_username { + return; + } + let response = match client_context.read_safe() { + Ok(ctx) => build_search_response( + &ctx.shares, + &own_username, + token, + &query, + ctx.has_free_upload_slot(), + ctx.last_upload_speed, + ctx.upload_queue.len() as u32, + &ctx.excluded_search_phrases, + ), + Err(e) => { + error!("[client] IncomingSearch read: {}", e); + return; + } + }; + let Some(message) = response else { + return; // no matching shares + }; + + // Deliver to the searcher: send now if we have a + // control connection, else open one and queue. + let (connected, registry, server_sender) = + match client_context.read_safe() { + Ok(ctx) => ( + ctx.peer_registry + .as_ref() + .is_some_and(|r| r.contains(&username)), + ctx.peer_registry.clone(), + ctx.server_sender.clone(), + ), + Err(_) => return, + }; + if connected { + if let Some(registry) = registry { + let _ = registry.send_to_peer( + &username, + PeerMessage::SendMessage(message), + ); + } + } else { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.queue_peer_message(&username, message); + } + if let Some(sender) = server_sender { + let _ = + sender.send(ServerMessage::GetPeerAddress(username)); + } + } + } + } +} diff --git a/soulseek-rs-lib/src/client/operations/transfers.rs b/soulseek-rs-lib/src/client/operations/transfers.rs new file mode 100644 index 0000000..d41fd63 --- /dev/null +++ b/soulseek-rs-lib/src/client/operations/transfers.rs @@ -0,0 +1,219 @@ +//! Dispatch arms that move bytes: what a peer allows, what it asks of us, +//! and the tokens tying the two together. +//! +//! Lifted out of the dispatch loop whole, so the bodies read exactly as they +//! did inside it — including `own_username`, which is re-owned on the way in +//! rather than have every line that clones it rewritten. + +use super::{ + Arc, Client, ClientContext, ClientOperation, Download, DownloadError, + DownloadPeer, DownloadStatus, MessageFactory, PeerMessage, RwLock, + RwLockExt, ServerMessage, debug, error, info, thread, trace, +}; + +impl Client { + pub(super) fn on_transfers( + operation: ClientOperation, + client_context: &Arc>, + own_username: &str, + ) { + let own_username = own_username.to_string(); + match operation { + ClientOperation::DownloadFromPeer(token, peer, allowed) => { + let maybe_download = match client_context.write_safe() { + Ok(mut ctx) => { + ctx.downloads.claim_for_peer(token, &peer.username) + } + Err(e) => { + error!("[client] DownloadFromPeer write: {}", e); + return; + } + }; + trace!( + "[client] DownloadFromPeer token: {} peer: {:?}", + token, peer + ); + let Some(download) = maybe_download else { + debug!( + "[client] transfer token {} is missing, belongs \ + to another peer, or is already claimed; ignoring", + token, + ); + return; + }; + + let client_context = client_context.clone(); + + thread::spawn(move || { + let download_peer = DownloadPeer::new( + download.username.clone(), + peer.host.clone(), + peer.port, + token, + allowed, + own_username, + ); + match download_peer.download_file( + client_context, + Some(download.clone()), + None, + ) { + Ok((download, filename)) => { + info!( + "Successfully downloaded {} bytes to {}", + download.size, filename + ); + } + Err(DownloadError::Cancelled) => {} + Err(e) => { + error!( + "Failed to download file '{}' from {}:{} (token: {}) - Error: {}", + download.filename, + peer.host, + peer.port, + download.token, + e + ); + } + } + }); + } + ClientOperation::UpdateDownloadTokens(transfer, username) => { + let mut context = match client_context.write_safe() { + Ok(c) => c, + Err(e) => { + error!("[client] UpdateDownloadTokens write: {}", e); + return; + } + }; + + let download_to_update = + context.get_downloads().iter().find_map(|d| { + if d.username == username + && d.filename == transfer.filename + { + Some((d.token, d.clone())) + } else { + None + } + }); + + let cancelled = + download_to_update.as_ref().is_some_and(|(_, d)| { + matches!(d.status, DownloadStatus::Cancelled) + }); + if !cancelled + && let Some((old_token, download)) = download_to_update + { + trace!( + "[client] UpdateDownloadTokens found {old_token}, transfer: {:?}", + transfer + ); + + context.add_download(Download { + token: transfer.token, + size: transfer.size, + ..download + }); + context.remove_download(old_token); + } + + // Only now invite the file connection: it is + // matched by this token, which is recorded as + // of the line above. Answering any earlier + // races the peer's connection against our own + // bookkeeping. + let registry = context.peer_registry.clone(); + drop(context); + if let Some(registry) = registry { + let response = if cancelled { + MessageFactory::build_transfer_denial_message( + transfer.token, + "Cancelled", + ) + } else { + MessageFactory::build_transfer_response_message( + transfer, + ) + }; + let _ = registry.send_to_peer( + &username, + PeerMessage::SendMessage(response), + ); + } + } + ClientOperation::StartUpload { token } => { + // The peer accepted our offer: resolve their + // address (from the code-9 GetPeerAddress) and + // stream the file, or queue until it resolves. + let (job_addr, downloader) = match client_context.write_safe() { + Ok(mut ctx) => { + ctx.mark_offer_answered(token); + let Some(job) = ctx.uploads.get(&token) else { + return; + }; + ( + ctx.peer_address(&job.downloader), + job.downloader.clone(), + ) + } + Err(_) => return, + }; + if let Some((host, port)) = job_addr { + Self::spawn_serve( + client_context, + &own_username, + token, + host, + port, + ); + } else { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.pending_serves + .entry(downloader.clone()) + .or_default() + .push(token); + } + if let Ok(ctx) = client_context.read_safe() + && let Some(sender) = ctx.server_sender.clone() + { + let _ = sender + .send(ServerMessage::GetPeerAddress(downloader)); + } + } + } + ClientOperation::QueueUpload { + requester_key, + filename, + } => { + // The peer served next may not be this one. + match client_context.write_safe() { + Ok(mut ctx) => { + let Some(file) = ctx.shares.get(&filename) else { + debug!( + "[client] QueueUpload for unknown file {}", + filename + ); + return; + }; + let size = file.size; + let real_path = file.real_path.clone(); + ctx.enqueue_upload( + &requester_key, + &filename, + real_path, + size, + ); + } + Err(e) => { + error!("[client] QueueUpload write: {}", e); + return; + } + } + Self::pump_upload_queue(client_context); + } + // The dispatch loop routes only the variants above to here. + _ => {} + } + } +} diff --git a/soulseek-rs-lib/src/client/operations/tree.rs b/soulseek-rs-lib/src/client/operations/tree.rs new file mode 100644 index 0000000..68a4d36 --- /dev/null +++ b/soulseek-rs-lib/src/client/operations/tree.rs @@ -0,0 +1,103 @@ +//! Dispatch arms for our place in the distributed search tree. +//! +//! Lifted out of the dispatch loop whole, so the bodies read exactly as they +//! did inside it. + +use super::{Arc, Client, ClientContext, ClientOperation, RwLock, RwLockExt}; + +impl Client { + pub(super) fn on_tree( + operation: ClientOperation, + client_context: &Arc>, + ) { + match operation { + ClientOperation::ParentSearch { + parent, + link, + username, + token, + query, + } => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if let Some(branch) = ctx.leaf.search_from(&parent, link) { + crate::client::distributed::announce_move( + &mut ctx, &branch, true, + ); + } + if ctx.leaf.is_parent(&parent, link) { + // The tree's stream is not ours to throttle: every + // search goes down to our children. The budget + // below bounds only the share scan we do for our + // own reply. + ctx.children.broadcast_search(&username, token, &query); + if let Some(ops) = &ctx.operations { + let _ = ops.send(ClientOperation::IncomingSearch { + username, + token, + query, + from_parent: true, + }); + } + } + } + ClientOperation::ParentBranchLevel { + parent, + link, + level, + } => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if let Some(branch) = + ctx.leaf.branch_level(&parent, link, level) + { + crate::client::distributed::announce_move( + &mut ctx, &branch, true, + ); + } + } + ClientOperation::ParentBranchRoot { parent, link, root } => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if let Some(branch) = ctx.leaf.branch_root(&parent, link, &root) + { + crate::client::distributed::announce_move( + &mut ctx, &branch, true, + ); + } + } + ClientOperation::ParentClosed { parent, link } => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if ctx.leaf.closed(&parent, link) { + let branch = ctx.leaf.branch(); + crate::client::distributed::announce_move( + &mut ctx, &branch, false, + ); + } + } + // Also what a fresh login means: whatever tree we hung + // from belongs to the old session. + ClientOperation::ResetDistributed => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + ctx.leaf.reset(); + // A fresh session feeds us nothing until the tree (or + // the server) starts again, so the children we were + // carrying are let go rather than left starving. + ctx.set_fed_by(false); + let branch = ctx.leaf.branch(); + crate::client::distributed::announce_move( + &mut ctx, &branch, false, + ); + } + // The dispatch loop routes only the variants above to here. + _ => {} + } + } +} diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs deleted file mode 100644 index 6be8609..0000000 --- a/soulseek-rs-lib/src/client/tests.rs +++ /dev/null @@ -1,1186 +0,0 @@ -use super::*; - -fn download( - username: &str, - filename: &str, - token: u32, - status: DownloadStatus, - sender: Sender, -) -> Download { - Download { - username: username.to_string(), - filename: filename.to_string(), - token, - size: 100, - download_directory: "test".to_string(), - status, - sender, - queue_position: None, - metadata: DownloadMetadata::default(), - } -} - -#[test] -fn test_client_context_downloads() { - let mut context = ClientContext::new(); - let token = 123; - let new_token = 1234; - context.add_download(download( - "test", - "test.txt", - token, - DownloadStatus::Queued, - mpsc::channel().0, - )); - assert!(context.get_download_by_token(123).is_some()); - assert_eq!(context.get_download_tokens(), vec![123]); - assert_eq!(context.get_downloads().len(), 1); - if let Some(download) = context.get_download_by_token_mut(token) { - assert_eq!(download.token, token); - download.token = new_token; - } - assert!(context.get_download_by_token(new_token).is_some()); - assert_eq!(context.get_download_tokens(), vec![new_token]); - context.remove_download(new_token); - assert_eq!(context.get_downloads().len(), 0); - assert!(context.get_download_by_token(1234).is_none()); -} - -#[test] -fn test_client_pause_and_resume_download() { - let client = Client::new("test-user", "test-password"); - let (download_sender, download_receiver) = mpsc::channel(); - - client.context.write().unwrap().add_download(download( - "peer", - "song.mp3", - 123, - DownloadStatus::InProgress { - bytes_downloaded: 25, - total_bytes: 100, - speed_bytes_per_sec: 10.0, - }, - download_sender, - )); - - assert!(client.pause_download("peer", "song.mp3")); - assert!(matches!( - client - .context - .read() - .unwrap() - .get_download_by_token(123) - .unwrap() - .status, - DownloadStatus::Paused { - bytes_downloaded: 25, - total_bytes: 100 - } - )); - assert!(matches!( - download_receiver.try_recv().unwrap(), - DownloadStatus::Paused { - bytes_downloaded: 25, - total_bytes: 100 - } - )); - - assert!(client.resume_download("peer", "song.mp3")); - assert!(matches!( - client - .context - .read() - .unwrap() - .get_download_by_token(123) - .unwrap() - .status, - DownloadStatus::InProgress { - bytes_downloaded: 25, - total_bytes: 100, - speed_bytes_per_sec: 0.0 - } - )); -} - -#[test] -fn download_without_a_connection_resolves_failed() { - // A client that never connected has no server handle and no peer registry, - // so it cannot open a connection to the peer: the download must resolve to - // Failed rather than hang Queued forever. - let client = Client::new("test-user", "test-password"); - let (_download, receiver) = client - .download( - "song.mp3".to_string(), - "peer".to_string(), - 100, - "test".to_string(), - ) - .expect("download() should return a handle"); - assert!(matches!( - receiver.recv_timeout(Duration::from_secs(1)), - Ok(DownloadStatus::Failed(_)) - )); -} - -#[test] -fn fail_queued_downloads_notifies_receiver_and_store() { - // When a brokered connect times out, every Queued download for the peer - // must resolve to Failed both on its channel and in the store. - let client = Client::new("u", "p"); - let (sender, receiver) = mpsc::channel(); - client.context.write().unwrap().add_download(download( - "peer", - "f.mp3", - 7, - DownloadStatus::Queued, - sender, - )); - - Client::fail_queued_downloads(&client.context, "peer"); - - assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Failed(_)))); - assert!(matches!( - client - .context - .read() - .unwrap() - .get_download_by_token(7) - .unwrap() - .status, - DownloadStatus::Failed(_) - )); -} - -#[test] -fn build_search_response_matches_shares_and_echoes_token() { - let dir = std::env::temp_dir() - .join(format!("soulseek-searchresp-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("probe_xyzzy.bin"), b"data").unwrap(); - let shares = Shares::scan(&dir).unwrap(); - - let response = - build_search_response(&shares, "me", 99, "xyzzy", true, 0, 0, &[]) - .expect("a matching share yields a response"); - let mut decoded = - crate::message::Message::new_with_data(response.get_buffer()); - decoded.set_pointer(8); - let result = SearchResult::new_from_message(&mut decoded).unwrap(); - assert_eq!(result.username, "me"); - assert_eq!(result.token, 99); - assert!(result.files.iter().any(|f| f.name.contains("probe_xyzzy"))); - - assert!( - build_search_response(&shares, "me", 1, "nomatch", true, 0, 0, &[]) - .is_none() - ); - let _ = std::fs::remove_dir_all(dir); -} - -fn peer_files(count: usize) -> SearchResult { - SearchResult { - token: 1, - files: (0..count) - .map(|i| crate::types::File { - username: "bob".to_string(), - name: format!("song-{i}.mp3"), - size: 1, - attribs: std::collections::HashMap::new(), - }) - .collect(), - slots: 1, - speed: 0, - username: "bob".to_string(), - } -} - -#[test] -fn search_file_counts_cover_every_search_without_the_results() { - // The daemon polls these counts many times a second; they must come from - // a walk of the cache, never a copy of it. - let client = Client::new("u", "p"); - { - let mut context = client.context.write().unwrap(); - context.searches.insert( - "aphex twin".to_string(), - Search { - token: 1, - results: vec![peer_files(2), peer_files(1)], - }, - ); - context.searches.insert( - "nothing yet".to_string(), - Search { - token: 2, - results: Vec::new(), - }, - ); - } - - let mut counts = client.search_file_counts(); - counts.sort(); - assert_eq!( - counts, - [ - ("aphex twin".to_string(), 3), - ("nothing yet".to_string(), 0) - ] - ); -} - -#[test] -fn a_search_stops_collecting_once_it_has_enough_responses() { - // A popular query on the live network draws answers for minutes — - // nearly a million files and gigabytes of memory for one search. - // Surplus responders are dropped, not archived. - let mut search = Search { - token: 1, - results: Vec::new(), - }; - for _ in 0..(crate::types::MAX_SEARCH_RESPONSES + 50) { - search.accept(peer_files(1)); - } - assert_eq!(search.results.len(), crate::types::MAX_SEARCH_RESPONSES); -} - -#[test] -fn a_flood_of_files_fills_a_search_before_the_response_cap() { - // A handful of whales with huge matching collections must not add up - // to an unbounded set just because the responses are few. - let mut search = Search { - token: 1, - results: Vec::new(), - }; - for _ in 0..10 { - search.accept(peer_files(crate::types::MAX_SEARCH_FILES / 2)); - } - assert_eq!( - search.results.len(), - 2, - "two half-cap responses fill the search; the rest are dropped" - ); -} - -#[test] -fn test_client_removes_only_queued_downloads() { - let client = Client::new("test-user", "test-password"); - { - let mut context = client.context.write().unwrap(); - context.add_download(download( - "peer", - "queued.mp3", - 123, - DownloadStatus::Queued, - mpsc::channel().0, - )); - context.add_download(download( - "peer", - "active.mp3", - 456, - DownloadStatus::InProgress { - bytes_downloaded: 25, - total_bytes: 100, - speed_bytes_per_sec: 10.0, - }, - mpsc::channel().0, - )); - } - - assert!(client.remove_queued_download("peer", "queued.mp3")); - assert!(!client.remove_queued_download("peer", "active.mp3")); - let context = client.context.read().unwrap(); - assert!(context.get_download_by_token(123).is_none()); - assert!(context.get_download_by_token(456).is_some()); -} - -use crate::types::{RoomEvent, UploadStatus}; -use std::time::{Duration, Instant}; - -#[test] -fn the_room_roster_follows_the_joins_and_leaves_the_server_reports() { - let mut context = ClientContext::new(); - - // Joining hands us the current membership in one event. - context.apply_room_event(RoomEvent::Joined { - room: "lobby".to_string(), - users: vec!["bob".to_string(), "alice".to_string()], - }); - assert_eq!(context.room_members("lobby"), ["alice", "bob"]); - - context.apply_room_event(RoomEvent::UserJoined { - room: "lobby".to_string(), - username: "carol".to_string(), - }); - context.apply_room_event(RoomEvent::UserLeft { - room: "lobby".to_string(), - username: "bob".to_string(), - }); - assert_eq!(context.room_members("lobby"), ["alice", "carol"]); - - // Events for other rooms do not leak into this one. - context.apply_room_event(RoomEvent::UserJoined { - room: "elsewhere".to_string(), - username: "dave".to_string(), - }); - assert_eq!(context.room_members("lobby"), ["alice", "carol"]); - - // A room we never joined has no roster, and leaving forgets it. - assert!(context.room_members("unknown").is_empty()); - context.apply_room_event(RoomEvent::Left { - room: "lobby".to_string(), - }); - assert!(context.room_members("lobby").is_empty()); -} - -#[test] -fn a_fresh_request_discards_the_previous_answer() { - // Without this, a poll after a second request returns the old - // snapshot immediately and the caller cannot tell stale from fresh. - let mut context = ClientContext::new(); - context.apply_user_status("alice".to_string(), 2, false); - context.apply_user_stats("alice".to_string(), 10, 20, 30); - assert!(context.user_info("alice").is_some_and(|i| i.is_complete())); - - context.invalidate_user_info("alice"); - assert!( - context.user_info("alice").is_none(), - "a new request must not be answerable from the old reply" - ); -} - -#[test] -fn each_reply_fills_only_its_own_half() { - let mut context = ClientContext::new(); - context.apply_user_status("bob".to_string(), 1, true); - - let info = context.user_info("bob").expect("a snapshot"); - assert!(!info.is_complete(), "stats have not arrived"); - assert_eq!( - info.presence.map(|p| p.status), - Some(crate::types::UserStatus::Away) - ); - assert!(info.stats.is_none(), "must not invent statistics"); - - context.apply_user_stats("bob".to_string(), 5, 6, 7); - let info = context.user_info("bob").expect("a snapshot"); - assert!(info.is_complete()); - assert_eq!(info.stats.map(|s| s.shared_files), Some(6)); - assert_eq!( - info.presence.map(|p| p.privileged), - Some(true), - "the earlier half must survive the merge" - ); -} - -#[test] -fn a_user_joining_twice_is_listed_once() { - let mut context = ClientContext::new(); - context.apply_room_event(RoomEvent::Joined { - room: "lobby".to_string(), - users: vec!["alice".to_string()], - }); - context.apply_room_event(RoomEvent::UserJoined { - room: "lobby".to_string(), - username: "alice".to_string(), - }); - assert_eq!(context.room_members("lobby"), ["alice"]); -} - -#[test] -fn upload_speed_is_reported_only_while_running() { - let two_seconds_ago = Instant::now() - .checked_sub(Duration::from_secs(2)) - .expect("clock supports a 2s offset"); - - // 1 KiB over ~2s is ~512 B/s. - let rate = upload_speed(&UploadStatus::InProgress, 1024, two_seconds_ago); - assert!((rate - 512.0).abs() < 50.0, "unexpected rate {rate}"); - - // A finished upload reports no rate, exactly as a finished download - // does, so the Speed column renders "-" rather than a stale figure. - for status in [ - UploadStatus::Completed, - UploadStatus::Cancelled, - UploadStatus::Failed("nope".to_string()), - ] { - let rate = upload_speed(&status, 1024, two_seconds_ago); - assert!(rate.abs() < f64::EPSILON, "unexpected rate {rate}"); - } - - // A just-started upload must not divide by a zero elapsed time. - let rate = upload_speed(&UploadStatus::InProgress, 0, Instant::now()); - assert!( - rate.is_finite() && rate.abs() < f64::EPSILON, - "unexpected rate {rate}" - ); -} - -// A clean disconnect — the idle reaper, or a remote client tidying an idle -// socket while it waits in our queue — must keep the peer's queued uploads; -// only an error disconnect is evidence the peer is gone. -#[test] -fn a_clean_disconnect_keeps_queued_uploads_an_error_drops_them() { - let client = Client::new("test-user", "test-password"); - let (ops, ops_rx) = mpsc::channel(); - Client::listen_to_client_operations( - ops_rx, - client.context.clone(), - "me".to_string(), - ); - - client.context.write().unwrap().enqueue_upload( - "amy", - "@@share\\f.mp3", - std::path::PathBuf::from("/tmp/f.mp3"), - 4096, - ); - - ops.send(ClientOperation::PeerDisconnected( - 1, - "amy".to_string(), - None, - )) - .unwrap(); - // The loop is serial, so once this fence op is visible the disconnect - // before it has been handled. - ops.send(ClientOperation::OwnPrivileges(7)).unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - while client.own_privilege_seconds() != Some(7) { - assert!(Instant::now() < deadline, "ops loop never caught up"); - thread::sleep(Duration::from_millis(10)); - } - assert_eq!( - client - .context - .read() - .unwrap() - .place_in_queue("amy", "@@share\\f.mp3"), - Some(1), - "a clean disconnect must keep the queue" - ); - - ops.send(ClientOperation::PeerDisconnected( - 1, - "amy".to_string(), - Some(crate::error::SoulseekRs::NotConnected), - )) - .unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - loop { - let place = client - .context - .read() - .unwrap() - .place_in_queue("amy", "@@share\\f.mp3"); - if place.is_none() { - break; - } - assert!( - Instant::now() < deadline, - "an error disconnect must drop the queue" - ); - thread::sleep(Duration::from_millis(10)); - } -} - -#[test] -fn a_pending_connect_expires_after_the_broker_timeout() { - let mut context = ClientContext::new(); - context.add_pending_connect(7, "ghost".to_string()); - - assert!(context.take_expired_connects(Instant::now()).is_empty()); - - let after_deadline = - Instant::now() + BROKER_CONNECT_TIMEOUT + Duration::from_secs(1); - assert_eq!( - context.take_expired_connects(after_deadline), - vec!["ghost".to_string()] - ); - assert!(context.take_pending_connect(7).is_none()); -} - -#[test] -fn protected_peers_covers_downloads_uploads_and_pending_serves() { - let mut context = ClientContext::new(); - context.add_download(download( - "downloader", - "song.mp3", - 1, - DownloadStatus::Queued, - mpsc::channel().0, - )); - context.add_download(download( - "done", - "old.mp3", - 2, - DownloadStatus::Completed, - mpsc::channel().0, - )); - context - .pending_serves - .insert("waiting".to_string(), vec![9]); - context.mark_browse_pending("browsed"); - - let protected = context.protected_peers(); - assert!(protected.contains("downloader")); - assert!(protected.contains("waiting")); - assert!(protected.contains("browsed")); - assert!(!protected.contains("done")); - - context.store_browse_result("browsed".to_string(), Vec::new()); - assert!(!context.protected_peers().contains("browsed")); -} - -#[test] -fn an_expired_broker_connect_fails_the_queued_downloads() { - let client = Client::new("u", "p"); - let (sender, receiver) = mpsc::channel(); - { - let mut ctx = client.context.write().unwrap(); - ctx.add_download(download( - "ghost", - "f.mp3", - 7, - DownloadStatus::Queued, - sender, - )); - ctx.pending_connect_tokens - .insert(7, ("ghost".to_string(), Instant::now())); - } - let (_ops_tx, ops_rx) = mpsc::channel(); - Client::listen_to_client_operations( - ops_rx, - client.context.clone(), - "u".to_string(), - ); - - let status = receiver.recv_timeout(Duration::from_secs(5)); - assert!( - matches!(status, Ok(DownloadStatus::Failed(_))), - "the sweep must fail the queued download, got {status:?}" - ); -} - -#[test] -fn a_replayed_transfer_response_does_not_start_a_second_transfer() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - listener.set_nonblocking(true).unwrap(); - let port = u32::from(listener.local_addr().unwrap().port()); - let download_dir = std::env::temp_dir() - .join(format!("soulseek-replayed-transfer-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&download_dir); - std::fs::create_dir_all(&download_dir).unwrap(); - - let client = Client::new("u", "p"); - let (sender, _receiver) = mpsc::channel(); - let mut queued = - download("peer", "f.mp3", 9, DownloadStatus::Queued, sender); - queued.download_directory = download_dir.display().to_string(); - client.context.write().unwrap().add_download(queued); - - let (ops_tx, ops_rx) = mpsc::channel(); - Client::listen_to_client_operations( - ops_rx, - client.context.clone(), - "u".to_string(), - ); - - let peer = Peer::new( - "peer".to_string(), - ConnectionType::F, - "127.0.0.1".to_string(), - port, - None, - 0, - 0, - 0, - ); - ops_tx - .send(ClientOperation::DownloadFromPeer(9, peer.clone(), true)) - .unwrap(); - ops_tx - .send(ClientOperation::DownloadFromPeer(9, peer, true)) - .unwrap(); - - let deadline = Instant::now() + Duration::from_secs(5); - let mut connections = 0; - while Instant::now() < deadline { - match listener.accept() { - Ok(_) => { - connections += 1; - if connections == 1 { - thread::sleep(Duration::from_millis(500)); - } - } - Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { - if connections >= 1 { - break; - } - thread::sleep(Duration::from_millis(20)); - } - Err(_) => break, - } - } - assert_eq!( - connections, 1, - "a replayed TransferResponse must not dial the peer again" - ); - let _ = std::fs::remove_dir_all(download_dir); -} - -#[test] -fn a_cancelled_download_is_not_started_when_the_peer_allows_it() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - listener.set_nonblocking(true).unwrap(); - let port = u32::from(listener.local_addr().unwrap().port()); - - let client = Client::new("u", "p"); - let (sender, _receiver) = mpsc::channel(); - client.context.write().unwrap().add_download(download( - "peer", - "f.mp3", - 9, - DownloadStatus::Cancelled, - sender, - )); - - let (ops_tx, ops_rx) = mpsc::channel(); - Client::listen_to_client_operations( - ops_rx, - client.context.clone(), - "u".to_string(), - ); - let peer = Peer::new( - "peer".to_string(), - ConnectionType::F, - "127.0.0.1".to_string(), - port, - None, - 0, - 0, - 0, - ); - ops_tx - .send(ClientOperation::DownloadFromPeer(9, peer, true)) - .unwrap(); - - thread::sleep(Duration::from_millis(500)); - assert!( - matches!(listener.accept(), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock), - "a cancelled download must not dial the peer" - ); -} - -#[test] -fn cancel_download_marks_the_store_and_answers_whether_it_matched() { - let client = Client::new("u", "p"); - let (sender, receiver) = mpsc::channel(); - client.context.write().unwrap().add_download(download( - "peer", - "f.mp3", - 9, - DownloadStatus::Queued, - sender, - )); - - assert!(client.cancel_download("peer", "f.mp3")); - assert!(!client.cancel_download("peer", "f.mp3")); - assert!(!client.cancel_download("peer", "other.mp3")); - assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Cancelled))); - assert!(matches!( - client.get_all_downloads()[0].status, - DownloadStatus::Cancelled - )); -} - -#[test] -fn room_member_stats_are_returned_sorted_and_scoped_to_their_room() { - use crate::types::{RoomUserStats, UserStatus}; - let stat = |username: &str| RoomUserStats { - username: username.to_string(), - status: UserStatus::Online, - average_speed: 1, - shared_files: 2, - shared_folders: 3, - slots_full: true, - country: None, - }; - - let mut context = ClientContext::new(); - context.apply_room_member_stats( - "jazz".to_string(), - vec![stat("carol"), stat("alice")], - ); - - let names: Vec = context - .room_member_stats("jazz") - .into_iter() - .map(|s| s.username) - .collect(); - assert_eq!(names, vec!["alice", "carol"]); - assert!(context.room_member_stats("unjoined").is_empty()); -} - -#[test] -fn rejoining_replaces_the_previous_member_stats() { - use crate::types::{RoomUserStats, UserStatus}; - let stat = |username: &str| RoomUserStats { - username: username.to_string(), - status: UserStatus::Online, - average_speed: 1, - shared_files: 2, - shared_folders: 3, - slots_full: false, - country: None, - }; - - let mut context = ClientContext::new(); - context.apply_room_member_stats("jazz".to_string(), vec![stat("alice")]); - context.apply_room_member_stats("jazz".to_string(), vec![stat("bob")]); - - let stats = context.room_member_stats("jazz"); - assert_eq!(stats.len(), 1, "a rejoin supersedes the old snapshot"); - assert_eq!(stats[0].username, "bob"); -} - -#[test] -fn a_watch_reply_fills_both_halves_of_the_snapshot() { - let mut context = ClientContext::new(); - context.add_watched_user("alice"); - context.apply_watched_user( - "alice".to_string(), - true, - Some(2), - Some(1024), - Some(20), - Some(3), - ); - - let info = context.user_info("alice").expect("a snapshot"); - assert!(info.is_complete(), "a watch reply carries status and stats"); - assert_eq!(info.stats.map(|s| s.average_speed), Some(1024)); - assert_eq!(context.watched_users(), vec!["alice".to_string()]); -} - -#[test] -fn a_watch_reply_keeps_a_privileged_flag_it_cannot_carry() { - // WatchUser has no privileged field, so it must not overwrite what - // GetUserStatus already told us with a fabricated `false`. - let mut context = ClientContext::new(); - context.apply_user_status("bob".to_string(), 1, true); - context.apply_watched_user( - "bob".to_string(), - true, - Some(2), - Some(1), - Some(2), - Some(3), - ); - - let info = context.user_info("bob").expect("a snapshot"); - assert_eq!(info.presence.map(|p| p.privileged), Some(true)); -} - -#[test] -fn watching_an_unknown_user_drops_them_from_the_list() { - // The server will never push status for a name it does not know, so - // keeping it in the watch list would show a permanently blank row. - let mut context = ClientContext::new(); - context.add_watched_user("ghost"); - context.apply_watched_user( - "ghost".to_string(), - false, - None, - None, - None, - None, - ); - - assert!(context.watched_users().is_empty()); - assert!(context.user_info("ghost").is_none()); -} - -#[test] -fn unwatching_forgets_the_users_snapshot() { - let mut context = ClientContext::new(); - context.add_watched_user("alice"); - context.apply_user_status("alice".to_string(), 2, false); - - context.remove_watched_user("alice"); - assert!(context.watched_users().is_empty()); - assert!( - context.user_info("alice").is_none(), - "a later re-watch must report a fresh answer" - ); -} - -#[test] -fn watched_users_are_listed_in_a_stable_order() { - let mut context = ClientContext::new(); - context.add_watched_user("carol"); - context.add_watched_user("alice"); - context.add_watched_user("bob"); - assert_eq!(context.watched_users(), vec!["alice", "bob", "carol"]); -} - -// A reply queued for a searcher the server cannot place waits a minute, -// not forever: every such search would otherwise pin a map entry and a -// protected slot in the peer registry. -#[test] -fn a_peer_message_nobody_could_deliver_expires() { - let mut ctx = ClientContext::for_user("me"); - ctx.queue_peer_message("ghost", crate::message::Message::new()); - ctx.expire_pending_peer_messages(Instant::now()); - assert_eq!(ctx.take_peer_messages("ghost").len(), 1, "still fresh"); - - ctx.queue_peer_message("ghost", crate::message::Message::new()); - ctx.expire_pending_peer_messages(Instant::now() + PENDING_PEER_TTL); - assert!(ctx.take_peer_messages("ghost").is_empty(), "expired"); -} - -#[test] -fn a_new_ticker_replaces_that_users_previous_one() { - // The server treats a user's ticker as singular: a second one from the - // same user supersedes the first rather than stacking beside it. - let mut ctx = ClientContext::new(); - ctx.apply_room_event(RoomEvent::Tickers { - room: "jazz".into(), - tickers: vec![ - RoomTicker { - username: "alice".into(), - ticker: "first".into(), - }, - RoomTicker { - username: "bob".into(), - ticker: "bobs".into(), - }, - ], - }); - ctx.apply_room_event(RoomEvent::TickerAdded { - room: "jazz".into(), - username: "alice".into(), - ticker: "second".into(), - }); - - let board = ctx.room_tickers("jazz"); - assert_eq!(board.len(), 2); - assert_eq!( - board.iter().find(|t| t.username == "alice").unwrap().ticker, - "second" - ); -} - -#[test] -fn a_removed_ticker_leaves_the_rest_of_the_board() { - let mut ctx = ClientContext::new(); - ctx.apply_room_event(RoomEvent::Tickers { - room: "jazz".into(), - tickers: vec![ - RoomTicker { - username: "alice".into(), - ticker: "a".into(), - }, - RoomTicker { - username: "bob".into(), - ticker: "b".into(), - }, - ], - }); - ctx.apply_room_event(RoomEvent::TickerRemoved { - room: "jazz".into(), - username: "alice".into(), - }); - - let board = ctx.room_tickers("jazz"); - assert_eq!(board.len(), 1); - assert_eq!(board[0].username, "bob"); -} - -#[test] -fn a_global_message_is_queued_without_touching_room_membership() { - // The global feed carries messages from rooms we have not joined; they - // must not invent a roster for those rooms. - let mut ctx = ClientContext::new(); - ctx.apply_room_event(RoomEvent::GlobalMessage { - room: "elsewhere".into(), - username: "alice".into(), - message: "hi".into(), - }); - assert!(ctx.room_members("elsewhere").is_empty()); - assert_eq!(ctx.take_room_events().len(), 1); -} - -#[test] -fn own_and_global_recommendations_are_kept_apart() { - let mut ctx = ClientContext::new(); - ctx.apply_recommendations( - false, - vec![Recommendation { - item: "jazz".into(), - rating: 2, - }], - Vec::new(), - ); - ctx.apply_recommendations( - true, - vec![Recommendation { - item: "pop".into(), - rating: 9, - }], - Vec::new(), - ); - - assert_eq!(ctx.recommendations(false).unwrap().0[0].item, "jazz"); - assert_eq!(ctx.recommendations(true).unwrap().0[0].item, "pop"); -} - -#[test] -fn asking_again_about_a_users_interests_drops_the_previous_answer() { - let mut ctx = ClientContext::new(); - ctx.apply_user_interests(UserInterests { - username: "alice".into(), - likes: vec!["jazz".into()], - hates: Vec::new(), - }); - assert!(ctx.user_interests("alice").is_some()); - - ctx.invalidate_user_interests("alice"); - assert!( - ctx.user_interests("alice").is_none(), - "a stale answer must not be mistaken for the next one" - ); -} - -#[test] -fn a_private_room_roster_tracks_who_is_added_and_removed() { - let mut ctx = ClientContext::new(); - ctx.apply_room_event(RoomEvent::PrivateMembers { - room: "club".into(), - users: vec!["bob".into(), "alice".into(), "bob".into()], - }); - assert_eq!( - ctx.private_room_members("club"), - vec!["alice".to_string(), "bob".to_string()], - "a roster is sorted and free of repeats" - ); - - ctx.apply_room_event(RoomEvent::PrivateRosterChanged { - room: "club".into(), - username: "carol".into(), - members: true, - added: true, - }); - ctx.apply_room_event(RoomEvent::PrivateRosterChanged { - room: "club".into(), - username: "alice".into(), - members: true, - added: false, - }); - assert_eq!( - ctx.private_room_members("club"), - vec!["bob".to_string(), "carol".to_string()] - ); - - // Operators are a separate roster in the same room. - ctx.apply_room_event(RoomEvent::PrivateRosterChanged { - room: "club".into(), - username: "bob".into(), - members: false, - added: true, - }); - assert_eq!(ctx.private_room_operators("club"), vec!["bob".to_string()]); - assert_eq!(ctx.private_rooms(), vec!["club".to_string()]); -} - -#[test] -fn revoked_membership_drops_the_room_we_can_no_longer_see() { - let mut ctx = ClientContext::new(); - ctx.apply_room_event(RoomEvent::PrivateMembers { - room: "club".into(), - users: vec!["alice".into()], - }); - ctx.apply_room_event(RoomEvent::PrivateOperators { - room: "club".into(), - users: vec!["alice".into()], - }); - - ctx.apply_room_event(RoomEvent::OwnStandingChanged { - room: "club".into(), - members: true, - granted: false, - }); - assert!(ctx.private_rooms().is_empty()); - assert!(ctx.private_room_operators("club").is_empty()); -} - -#[test] -fn losing_operatorship_keeps_the_roster_we_can_still_see() { - // Demotion does not blind us: we are still a member, and the room's other - // operators are still ours to show. The server narrates our own removal - // from that roster separately (code 144). - let mut ctx = ClientContext::new(); - ctx.apply_room_event(RoomEvent::PrivateMembers { - room: "club".into(), - users: vec!["alice".into()], - }); - ctx.apply_room_event(RoomEvent::PrivateOperators { - room: "club".into(), - users: vec!["alice".into(), "bob".into()], - }); - - ctx.apply_room_event(RoomEvent::OwnStandingChanged { - room: "club".into(), - members: false, - granted: false, - }); - assert_eq!(ctx.private_room_members("club"), vec!["alice".to_string()]); - assert_eq!( - ctx.private_room_operators("club"), - vec!["alice".to_string(), "bob".to_string()], - "the other operators are still there to show" - ); - - ctx.apply_room_event(RoomEvent::PrivateRosterChanged { - room: "club".into(), - username: "alice".into(), - members: false, - added: false, - }); - assert_eq!( - ctx.private_room_operators("club"), - vec!["bob".to_string()], - "only the demotion the server narrates removes us" - ); -} - -#[test] -fn a_file_the_server_excludes_is_left_out_of_a_reply() { - // The server's excluded phrases (code 160) police what travels the search - // network: a matching file whose path carries one must not be offered, - // and a reply with nothing left is not sent at all. - let dir = std::env::temp_dir() - .join(format!("soulseek-excluded-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("Spam_Xyzzy.bin"), b"data").unwrap(); - let shares = Shares::scan(&dir).unwrap(); - - assert!( - build_search_response(&shares, "me", 1, "xyzzy", true, 0, 0, &[]) - .is_some(), - "with no exclusions the file is offered" - ); - assert!( - build_search_response( - &shares, - "me", - 1, - "xyzzy", - true, - 0, - 0, - &["spam".to_string()], - ) - .is_none(), - "an excluded phrase in the path, matched case-insensitively, \ - withholds the file — and with no files there is no reply" - ); - assert!( - build_search_response( - &shares, - "me", - 1, - "xyzzy", - true, - 0, - 0, - &["unrelated".to_string()], - ) - .is_some(), - "an exclusion the path does not carry changes nothing" - ); - - // The phrases are lowercased where they arrive, so a server that sends - // one capitalised still matches a path. - let mut ctx = ClientContext::new(); - ctx.set_excluded_search_phrases(vec!["SPAM".to_string()]); - assert!( - build_search_response( - &shares, - "me", - 1, - "xyzzy", - true, - 0, - 0, - &ctx.excluded_search_phrases(), - ) - .is_none(), - "a capitalised phrase from the server still withholds the file" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn our_own_interests_are_kept_lowercased_and_deduplicated() { - // The server matches interests case-insensitively and forgets them when - // the session ends, so they are held here in one spelling to be sent - // again next login. - let mut ctx = ClientContext::new(); - assert_eq!( - ctx.add_own_interest(" Krautrock ", true).as_deref(), - Some("krautrock") - ); - assert_eq!( - ctx.add_own_interest(" ", true), - None, - "an empty interest is not stored, and is not sent either" - ); - ctx.add_own_interest("KRAUTROCK", true); - ctx.add_own_interest("Muzak", false); - - let interests = ctx.own_interests(); - assert_eq!(interests.likes, ["krautrock"]); - assert_eq!(interests.hates, ["muzak"]); - - ctx.remove_own_interest("KrautRock", true); - assert!(ctx.own_interests().likes.is_empty()); - assert_eq!( - ctx.own_interests().hates, - ["muzak"], - "the other list stands" - ); -} - -#[test] -fn leaving_a_room_drops_its_ticker_board() { - let mut ctx = ClientContext::new(); - ctx.apply_room_event(RoomEvent::Tickers { - room: "jazz".into(), - tickers: vec![RoomTicker { - username: "alice".into(), - ticker: "hi".into(), - }], - }); - ctx.apply_room_event(RoomEvent::Left { - room: "jazz".into(), - }); - assert!( - ctx.room_tickers("jazz").is_empty(), - "a board for a room we left is stale" - ); -} - -#[test] -fn the_server_is_told_our_child_capacity_only_when_it_changes() { - // AcceptChildren is a standing state on the server, not a heartbeat. - let mut ctx = ClientContext::new(); - assert_eq!( - ctx.accept_children_change(), - Some(false), - "the first answer is always worth sending" - ); - assert_eq!(ctx.accept_children_change(), None, "unchanged, so silent"); -} diff --git a/soulseek-rs-lib/src/client/tests/mod.rs b/soulseek-rs-lib/src/client/tests/mod.rs new file mode 100644 index 0000000..0862c05 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/mod.rs @@ -0,0 +1,52 @@ +//! The client's context, exercised directly. +//! +//! This file is the harness the topic modules below share. + +mod peers; +mod rooms; +mod search; +mod transfers; +mod users; + +use super::*; + +fn download( + username: &str, + filename: &str, + token: u32, + status: DownloadStatus, + sender: Sender, +) -> Download { + Download { + username: username.to_string(), + filename: filename.to_string(), + token, + size: 100, + download_directory: "test".to_string(), + status, + sender, + queue_position: None, + metadata: DownloadMetadata::default(), + } +} + +fn peer_files(count: usize) -> SearchResult { + SearchResult { + token: 1, + files: (0..count) + .map(|i| crate::types::File { + username: "bob".to_string(), + name: format!("song-{i}.mp3"), + size: 1, + attribs: std::collections::HashMap::new(), + }) + .collect(), + slots: 1, + speed: 0, + username: "bob".to_string(), + } +} + +use crate::types::{RoomEvent, UploadStatus}; + +use std::time::{Duration, Instant}; diff --git a/soulseek-rs-lib/src/client/tests/peers.rs b/soulseek-rs-lib/src/client/tests/peers.rs new file mode 100644 index 0000000..4f214bd --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/peers.rs @@ -0,0 +1,173 @@ +//! Uploads, broker connects, and the peers a disconnect may not drop. + +use super::*; + +#[test] +fn upload_speed_is_reported_only_while_running() { + let two_seconds_ago = Instant::now() + .checked_sub(Duration::from_secs(2)) + .expect("clock supports a 2s offset"); + + // 1 KiB over ~2s is ~512 B/s. + let rate = upload_speed(&UploadStatus::InProgress, 1024, two_seconds_ago); + assert!((rate - 512.0).abs() < 50.0, "unexpected rate {rate}"); + + // A finished upload reports no rate, exactly as a finished download + // does, so the Speed column renders "-" rather than a stale figure. + for status in [ + UploadStatus::Completed, + UploadStatus::Cancelled, + UploadStatus::Failed("nope".to_string()), + ] { + let rate = upload_speed(&status, 1024, two_seconds_ago); + assert!(rate.abs() < f64::EPSILON, "unexpected rate {rate}"); + } + + // A just-started upload must not divide by a zero elapsed time. + let rate = upload_speed(&UploadStatus::InProgress, 0, Instant::now()); + assert!( + rate.is_finite() && rate.abs() < f64::EPSILON, + "unexpected rate {rate}" + ); +} + +// A clean disconnect — the idle reaper, or a remote client tidying an idle +// socket while it waits in our queue — must keep the peer's queued uploads; +// only an error disconnect is evidence the peer is gone. + +#[test] +fn a_clean_disconnect_keeps_queued_uploads_an_error_drops_them() { + let client = Client::new("test-user", "test-password"); + let (ops, ops_rx) = mpsc::channel(); + Client::listen_to_client_operations( + ops_rx, + client.context.clone(), + "me".to_string(), + ); + + client.context.write().unwrap().enqueue_upload( + "amy", + "@@share\\f.mp3", + std::path::PathBuf::from("/tmp/f.mp3"), + 4096, + ); + + ops.send(ClientOperation::PeerDisconnected( + 1, + "amy".to_string(), + None, + )) + .unwrap(); + // The loop is serial, so once this fence op is visible the disconnect + // before it has been handled. + ops.send(ClientOperation::OwnPrivileges(7)).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while client.own_privilege_seconds() != Some(7) { + assert!(Instant::now() < deadline, "ops loop never caught up"); + thread::sleep(Duration::from_millis(10)); + } + assert_eq!( + client + .context + .read() + .unwrap() + .place_in_queue("amy", "@@share\\f.mp3"), + Some(1), + "a clean disconnect must keep the queue" + ); + + ops.send(ClientOperation::PeerDisconnected( + 1, + "amy".to_string(), + Some(crate::error::SoulseekRs::NotConnected), + )) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let place = client + .context + .read() + .unwrap() + .place_in_queue("amy", "@@share\\f.mp3"); + if place.is_none() { + break; + } + assert!( + Instant::now() < deadline, + "an error disconnect must drop the queue" + ); + thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn a_pending_connect_expires_after_the_broker_timeout() { + let mut context = ClientContext::new(); + context.add_pending_connect(7, "ghost".to_string()); + + assert!(context.take_expired_connects(Instant::now()).is_empty()); + + let after_deadline = + Instant::now() + BROKER_CONNECT_TIMEOUT + Duration::from_secs(1); + assert_eq!( + context.take_expired_connects(after_deadline), + vec!["ghost".to_string()] + ); + assert!(context.take_pending_connect(7).is_none()); +} + +#[test] +fn protected_peers_covers_downloads_uploads_and_pending_serves() { + let mut context = ClientContext::new(); + context.add_download(download( + "downloader", + "song.mp3", + 1, + DownloadStatus::Queued, + mpsc::channel().0, + )); + context.add_download(download( + "done", + "old.mp3", + 2, + DownloadStatus::Completed, + mpsc::channel().0, + )); + context + .pending_serves + .insert("waiting".to_string(), vec![9]); + context.mark_browse_pending("browsed"); + + let protected = context.protected_peers(); + assert!(protected.contains("downloader")); + assert!(protected.contains("waiting")); + assert!(protected.contains("browsed")); + assert!(!protected.contains("done")); + + context.store_browse_result("browsed".to_string(), Vec::new()); + assert!(!context.protected_peers().contains("browsed")); +} + +#[test] +fn a_peer_message_nobody_could_deliver_expires() { + let mut ctx = ClientContext::for_user("me"); + ctx.queue_peer_message("ghost", crate::message::Message::new()); + ctx.expire_pending_peer_messages(Instant::now()); + assert_eq!(ctx.take_peer_messages("ghost").len(), 1, "still fresh"); + + ctx.queue_peer_message("ghost", crate::message::Message::new()); + ctx.expire_pending_peer_messages(Instant::now() + PENDING_PEER_TTL); + assert!(ctx.take_peer_messages("ghost").is_empty(), "expired"); +} + +#[test] +fn the_server_is_told_our_child_capacity_only_when_it_changes() { + // AcceptChildren is a standing state on the server, not a heartbeat. + let mut ctx = ClientContext::new(); + assert_eq!( + ctx.accept_children_change(), + Some(false), + "the first answer is always worth sending" + ); + assert_eq!(ctx.accept_children_change(), None, "unchanged, so silent"); +} diff --git a/soulseek-rs-lib/src/client/tests/rooms.rs b/soulseek-rs-lib/src/client/tests/rooms.rs new file mode 100644 index 0000000..0af6784 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/rooms.rs @@ -0,0 +1,297 @@ +//! Room rosters, ticker boards, and the private rooms the server lets us +//! see. + +use super::*; + +#[test] +fn the_room_roster_follows_the_joins_and_leaves_the_server_reports() { + let mut context = ClientContext::new(); + + // Joining hands us the current membership in one event. + context.apply_room_event(RoomEvent::Joined { + room: "lobby".to_string(), + users: vec!["bob".to_string(), "alice".to_string()], + }); + assert_eq!(context.room_members("lobby"), ["alice", "bob"]); + + context.apply_room_event(RoomEvent::UserJoined { + room: "lobby".to_string(), + username: "carol".to_string(), + }); + context.apply_room_event(RoomEvent::UserLeft { + room: "lobby".to_string(), + username: "bob".to_string(), + }); + assert_eq!(context.room_members("lobby"), ["alice", "carol"]); + + // Events for other rooms do not leak into this one. + context.apply_room_event(RoomEvent::UserJoined { + room: "elsewhere".to_string(), + username: "dave".to_string(), + }); + assert_eq!(context.room_members("lobby"), ["alice", "carol"]); + + // A room we never joined has no roster, and leaving forgets it. + assert!(context.room_members("unknown").is_empty()); + context.apply_room_event(RoomEvent::Left { + room: "lobby".to_string(), + }); + assert!(context.room_members("lobby").is_empty()); +} + +#[test] +fn a_user_joining_twice_is_listed_once() { + let mut context = ClientContext::new(); + context.apply_room_event(RoomEvent::Joined { + room: "lobby".to_string(), + users: vec!["alice".to_string()], + }); + context.apply_room_event(RoomEvent::UserJoined { + room: "lobby".to_string(), + username: "alice".to_string(), + }); + assert_eq!(context.room_members("lobby"), ["alice"]); +} + +#[test] +fn room_member_stats_are_returned_sorted_and_scoped_to_their_room() { + use crate::types::{RoomUserStats, UserStatus}; + let stat = |username: &str| RoomUserStats { + username: username.to_string(), + status: UserStatus::Online, + average_speed: 1, + shared_files: 2, + shared_folders: 3, + slots_full: true, + country: None, + }; + + let mut context = ClientContext::new(); + context.apply_room_member_stats( + "jazz".to_string(), + vec![stat("carol"), stat("alice")], + ); + + let names: Vec = context + .room_member_stats("jazz") + .into_iter() + .map(|s| s.username) + .collect(); + assert_eq!(names, vec!["alice", "carol"]); + assert!(context.room_member_stats("unjoined").is_empty()); +} + +#[test] +fn rejoining_replaces_the_previous_member_stats() { + use crate::types::{RoomUserStats, UserStatus}; + let stat = |username: &str| RoomUserStats { + username: username.to_string(), + status: UserStatus::Online, + average_speed: 1, + shared_files: 2, + shared_folders: 3, + slots_full: false, + country: None, + }; + + let mut context = ClientContext::new(); + context.apply_room_member_stats("jazz".to_string(), vec![stat("alice")]); + context.apply_room_member_stats("jazz".to_string(), vec![stat("bob")]); + + let stats = context.room_member_stats("jazz"); + assert_eq!(stats.len(), 1, "a rejoin supersedes the old snapshot"); + assert_eq!(stats[0].username, "bob"); +} + +#[test] +fn a_new_ticker_replaces_that_users_previous_one() { + // The server treats a user's ticker as singular: a second one from the + // same user supersedes the first rather than stacking beside it. + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::Tickers { + room: "jazz".into(), + tickers: vec![ + RoomTicker { + username: "alice".into(), + ticker: "first".into(), + }, + RoomTicker { + username: "bob".into(), + ticker: "bobs".into(), + }, + ], + }); + ctx.apply_room_event(RoomEvent::TickerAdded { + room: "jazz".into(), + username: "alice".into(), + ticker: "second".into(), + }); + + let board = ctx.room_tickers("jazz"); + assert_eq!(board.len(), 2); + assert_eq!( + board.iter().find(|t| t.username == "alice").unwrap().ticker, + "second" + ); +} + +#[test] +fn a_removed_ticker_leaves_the_rest_of_the_board() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::Tickers { + room: "jazz".into(), + tickers: vec![ + RoomTicker { + username: "alice".into(), + ticker: "a".into(), + }, + RoomTicker { + username: "bob".into(), + ticker: "b".into(), + }, + ], + }); + ctx.apply_room_event(RoomEvent::TickerRemoved { + room: "jazz".into(), + username: "alice".into(), + }); + + let board = ctx.room_tickers("jazz"); + assert_eq!(board.len(), 1); + assert_eq!(board[0].username, "bob"); +} + +#[test] +fn a_global_message_is_queued_without_touching_room_membership() { + // The global feed carries messages from rooms we have not joined; they + // must not invent a roster for those rooms. + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::GlobalMessage { + room: "elsewhere".into(), + username: "alice".into(), + message: "hi".into(), + }); + assert!(ctx.room_members("elsewhere").is_empty()); + assert_eq!(ctx.take_room_events().len(), 1); +} + +#[test] +fn a_private_room_roster_tracks_who_is_added_and_removed() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["bob".into(), "alice".into(), "bob".into()], + }); + assert_eq!( + ctx.private_room_members("club"), + vec!["alice".to_string(), "bob".to_string()], + "a roster is sorted and free of repeats" + ); + + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "carol".into(), + members: true, + added: true, + }); + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "alice".into(), + members: true, + added: false, + }); + assert_eq!( + ctx.private_room_members("club"), + vec!["bob".to_string(), "carol".to_string()] + ); + + // Operators are a separate roster in the same room. + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "bob".into(), + members: false, + added: true, + }); + assert_eq!(ctx.private_room_operators("club"), vec!["bob".to_string()]); + assert_eq!(ctx.private_rooms(), vec!["club".to_string()]); +} + +#[test] +fn revoked_membership_drops_the_room_we_can_no_longer_see() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["alice".into()], + }); + ctx.apply_room_event(RoomEvent::PrivateOperators { + room: "club".into(), + users: vec!["alice".into()], + }); + + ctx.apply_room_event(RoomEvent::OwnStandingChanged { + room: "club".into(), + members: true, + granted: false, + }); + assert!(ctx.private_rooms().is_empty()); + assert!(ctx.private_room_operators("club").is_empty()); +} + +#[test] +fn losing_operatorship_keeps_the_roster_we_can_still_see() { + // Demotion does not blind us: we are still a member, and the room's other + // operators are still ours to show. The server narrates our own removal + // from that roster separately (code 144). + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["alice".into()], + }); + ctx.apply_room_event(RoomEvent::PrivateOperators { + room: "club".into(), + users: vec!["alice".into(), "bob".into()], + }); + + ctx.apply_room_event(RoomEvent::OwnStandingChanged { + room: "club".into(), + members: false, + granted: false, + }); + assert_eq!(ctx.private_room_members("club"), vec!["alice".to_string()]); + assert_eq!( + ctx.private_room_operators("club"), + vec!["alice".to_string(), "bob".to_string()], + "the other operators are still there to show" + ); + + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "alice".into(), + members: false, + added: false, + }); + assert_eq!( + ctx.private_room_operators("club"), + vec!["bob".to_string()], + "only the demotion the server narrates removes us" + ); +} + +#[test] +fn leaving_a_room_drops_its_ticker_board() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::Tickers { + room: "jazz".into(), + tickers: vec![RoomTicker { + username: "alice".into(), + ticker: "hi".into(), + }], + }); + ctx.apply_room_event(RoomEvent::Left { + room: "jazz".into(), + }); + assert!( + ctx.room_tickers("jazz").is_empty(), + "a board for a room we left is stale" + ); +} diff --git a/soulseek-rs-lib/src/client/tests/search.rs b/soulseek-rs-lib/src/client/tests/search.rs new file mode 100644 index 0000000..f18fe93 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/search.rs @@ -0,0 +1,167 @@ +//! Answering a search against our shares, and what the client keeps of +//! the answers it gets back. + +use super::*; + +#[test] +fn build_search_response_matches_shares_and_echoes_token() { + let dir = std::env::temp_dir() + .join(format!("soulseek-searchresp-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("probe_xyzzy.bin"), b"data").unwrap(); + let shares = Shares::scan(&dir).unwrap(); + + let response = + build_search_response(&shares, "me", 99, "xyzzy", true, 0, 0, &[]) + .expect("a matching share yields a response"); + let mut decoded = + crate::message::Message::new_with_data(response.get_buffer()); + decoded.set_pointer(8); + let result = SearchResult::new_from_message(&mut decoded).unwrap(); + assert_eq!(result.username, "me"); + assert_eq!(result.token, 99); + assert!(result.files.iter().any(|f| f.name.contains("probe_xyzzy"))); + + assert!( + build_search_response(&shares, "me", 1, "nomatch", true, 0, 0, &[]) + .is_none() + ); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn search_file_counts_cover_every_search_without_the_results() { + // The daemon polls these counts many times a second; they must come from + // a walk of the cache, never a copy of it. + let client = Client::new("u", "p"); + { + let mut context = client.context.write().unwrap(); + context.searches.insert( + "aphex twin".to_string(), + Search { + token: 1, + results: vec![peer_files(2), peer_files(1)], + }, + ); + context.searches.insert( + "nothing yet".to_string(), + Search { + token: 2, + results: Vec::new(), + }, + ); + } + + let mut counts = client.search_file_counts(); + counts.sort(); + assert_eq!( + counts, + [ + ("aphex twin".to_string(), 3), + ("nothing yet".to_string(), 0) + ] + ); +} + +#[test] +fn a_search_stops_collecting_once_it_has_enough_responses() { + // A popular query on the live network draws answers for minutes — + // nearly a million files and gigabytes of memory for one search. + // Surplus responders are dropped, not archived. + let mut search = Search { + token: 1, + results: Vec::new(), + }; + for _ in 0..(crate::types::MAX_SEARCH_RESPONSES + 50) { + search.accept(peer_files(1)); + } + assert_eq!(search.results.len(), crate::types::MAX_SEARCH_RESPONSES); +} + +#[test] +fn a_flood_of_files_fills_a_search_before_the_response_cap() { + // A handful of whales with huge matching collections must not add up + // to an unbounded set just because the responses are few. + let mut search = Search { + token: 1, + results: Vec::new(), + }; + for _ in 0..10 { + search.accept(peer_files(crate::types::MAX_SEARCH_FILES / 2)); + } + assert_eq!( + search.results.len(), + 2, + "two half-cap responses fill the search; the rest are dropped" + ); +} + +#[test] +fn a_file_the_server_excludes_is_left_out_of_a_reply() { + // The server's excluded phrases (code 160) police what travels the search + // network: a matching file whose path carries one must not be offered, + // and a reply with nothing left is not sent at all. + let dir = std::env::temp_dir() + .join(format!("soulseek-excluded-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Spam_Xyzzy.bin"), b"data").unwrap(); + let shares = Shares::scan(&dir).unwrap(); + + assert!( + build_search_response(&shares, "me", 1, "xyzzy", true, 0, 0, &[]) + .is_some(), + "with no exclusions the file is offered" + ); + assert!( + build_search_response( + &shares, + "me", + 1, + "xyzzy", + true, + 0, + 0, + &["spam".to_string()], + ) + .is_none(), + "an excluded phrase in the path, matched case-insensitively, \ + withholds the file — and with no files there is no reply" + ); + assert!( + build_search_response( + &shares, + "me", + 1, + "xyzzy", + true, + 0, + 0, + &["unrelated".to_string()], + ) + .is_some(), + "an exclusion the path does not carry changes nothing" + ); + + // The phrases are lowercased where they arrive, so a server that sends + // one capitalised still matches a path. + let mut ctx = ClientContext::new(); + ctx.set_excluded_search_phrases(vec!["SPAM".to_string()]); + assert!( + build_search_response( + &shares, + "me", + 1, + "xyzzy", + true, + 0, + 0, + &ctx.excluded_search_phrases(), + ) + .is_none(), + "a capitalised phrase from the server still withholds the file" + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/soulseek-rs-lib/src/client/tests/transfers.rs b/soulseek-rs-lib/src/client/tests/transfers.rs new file mode 100644 index 0000000..dbabdd3 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/transfers.rs @@ -0,0 +1,329 @@ +//! Downloads through the context: what is queued, paused, cancelled, +//! and what a peer's answer is allowed to start. + +use super::*; + +#[test] +fn test_client_context_downloads() { + let mut context = ClientContext::new(); + let token = 123; + let new_token = 1234; + context.add_download(download( + "test", + "test.txt", + token, + DownloadStatus::Queued, + mpsc::channel().0, + )); + assert!(context.get_download_by_token(123).is_some()); + assert_eq!(context.get_download_tokens(), vec![123]); + assert_eq!(context.get_downloads().len(), 1); + if let Some(download) = context.get_download_by_token_mut(token) { + assert_eq!(download.token, token); + download.token = new_token; + } + assert!(context.get_download_by_token(new_token).is_some()); + assert_eq!(context.get_download_tokens(), vec![new_token]); + context.remove_download(new_token); + assert_eq!(context.get_downloads().len(), 0); + assert!(context.get_download_by_token(1234).is_none()); +} + +#[test] +fn test_client_pause_and_resume_download() { + let client = Client::new("test-user", "test-password"); + let (download_sender, download_receiver) = mpsc::channel(); + + client.context.write().unwrap().add_download(download( + "peer", + "song.mp3", + 123, + DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + download_sender, + )); + + assert!(client.pause_download("peer", "song.mp3")); + assert!(matches!( + client + .context + .read() + .unwrap() + .get_download_by_token(123) + .unwrap() + .status, + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100 + } + )); + assert!(matches!( + download_receiver.try_recv().unwrap(), + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100 + } + )); + + assert!(client.resume_download("peer", "song.mp3")); + assert!(matches!( + client + .context + .read() + .unwrap() + .get_download_by_token(123) + .unwrap() + .status, + DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 0.0 + } + )); +} + +#[test] +fn download_without_a_connection_resolves_failed() { + // A client that never connected has no server handle and no peer registry, + // so it cannot open a connection to the peer: the download must resolve to + // Failed rather than hang Queued forever. + let client = Client::new("test-user", "test-password"); + let (_download, receiver) = client + .download( + "song.mp3".to_string(), + "peer".to_string(), + 100, + "test".to_string(), + ) + .expect("download() should return a handle"); + assert!(matches!( + receiver.recv_timeout(Duration::from_secs(1)), + Ok(DownloadStatus::Failed(_)) + )); +} + +#[test] +fn fail_queued_downloads_notifies_receiver_and_store() { + // When a brokered connect times out, every Queued download for the peer + // must resolve to Failed both on its channel and in the store. + let client = Client::new("u", "p"); + let (sender, receiver) = mpsc::channel(); + client.context.write().unwrap().add_download(download( + "peer", + "f.mp3", + 7, + DownloadStatus::Queued, + sender, + )); + + Client::fail_queued_downloads(&client.context, "peer"); + + assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Failed(_)))); + assert!(matches!( + client + .context + .read() + .unwrap() + .get_download_by_token(7) + .unwrap() + .status, + DownloadStatus::Failed(_) + )); +} + +#[test] +fn test_client_removes_only_queued_downloads() { + let client = Client::new("test-user", "test-password"); + { + let mut context = client.context.write().unwrap(); + context.add_download(download( + "peer", + "queued.mp3", + 123, + DownloadStatus::Queued, + mpsc::channel().0, + )); + context.add_download(download( + "peer", + "active.mp3", + 456, + DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + mpsc::channel().0, + )); + } + + assert!(client.remove_queued_download("peer", "queued.mp3")); + assert!(!client.remove_queued_download("peer", "active.mp3")); + let context = client.context.read().unwrap(); + assert!(context.get_download_by_token(123).is_none()); + assert!(context.get_download_by_token(456).is_some()); +} + +#[test] +fn an_expired_broker_connect_fails_the_queued_downloads() { + let client = Client::new("u", "p"); + let (sender, receiver) = mpsc::channel(); + { + let mut ctx = client.context.write().unwrap(); + ctx.add_download(download( + "ghost", + "f.mp3", + 7, + DownloadStatus::Queued, + sender, + )); + ctx.pending_connect_tokens + .insert(7, ("ghost".to_string(), Instant::now())); + } + let (_ops_tx, ops_rx) = mpsc::channel(); + Client::listen_to_client_operations( + ops_rx, + client.context.clone(), + "u".to_string(), + ); + + let status = receiver.recv_timeout(Duration::from_secs(5)); + assert!( + matches!(status, Ok(DownloadStatus::Failed(_))), + "the sweep must fail the queued download, got {status:?}" + ); +} + +#[test] +fn a_replayed_transfer_response_does_not_start_a_second_transfer() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = u32::from(listener.local_addr().unwrap().port()); + let download_dir = std::env::temp_dir() + .join(format!("soulseek-replayed-transfer-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&download_dir); + std::fs::create_dir_all(&download_dir).unwrap(); + + let client = Client::new("u", "p"); + let (sender, _receiver) = mpsc::channel(); + let mut queued = + download("peer", "f.mp3", 9, DownloadStatus::Queued, sender); + queued.download_directory = download_dir.display().to_string(); + client.context.write().unwrap().add_download(queued); + + let (ops_tx, ops_rx) = mpsc::channel(); + Client::listen_to_client_operations( + ops_rx, + client.context.clone(), + "u".to_string(), + ); + + let peer = Peer::new( + "peer".to_string(), + ConnectionType::F, + "127.0.0.1".to_string(), + port, + None, + 0, + 0, + 0, + ); + ops_tx + .send(ClientOperation::DownloadFromPeer(9, peer.clone(), true)) + .unwrap(); + ops_tx + .send(ClientOperation::DownloadFromPeer(9, peer, true)) + .unwrap(); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut connections = 0; + while Instant::now() < deadline { + match listener.accept() { + Ok(_) => { + connections += 1; + if connections == 1 { + thread::sleep(Duration::from_millis(500)); + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + if connections >= 1 { + break; + } + thread::sleep(Duration::from_millis(20)); + } + Err(_) => break, + } + } + assert_eq!( + connections, 1, + "a replayed TransferResponse must not dial the peer again" + ); + let _ = std::fs::remove_dir_all(download_dir); +} + +#[test] +fn a_cancelled_download_is_not_started_when_the_peer_allows_it() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = u32::from(listener.local_addr().unwrap().port()); + + let client = Client::new("u", "p"); + let (sender, _receiver) = mpsc::channel(); + client.context.write().unwrap().add_download(download( + "peer", + "f.mp3", + 9, + DownloadStatus::Cancelled, + sender, + )); + + let (ops_tx, ops_rx) = mpsc::channel(); + Client::listen_to_client_operations( + ops_rx, + client.context.clone(), + "u".to_string(), + ); + let peer = Peer::new( + "peer".to_string(), + ConnectionType::F, + "127.0.0.1".to_string(), + port, + None, + 0, + 0, + 0, + ); + ops_tx + .send(ClientOperation::DownloadFromPeer(9, peer, true)) + .unwrap(); + + thread::sleep(Duration::from_millis(500)); + assert!( + matches!(listener.accept(), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock), + "a cancelled download must not dial the peer" + ); +} + +#[test] +fn cancel_download_marks_the_store_and_answers_whether_it_matched() { + let client = Client::new("u", "p"); + let (sender, receiver) = mpsc::channel(); + client.context.write().unwrap().add_download(download( + "peer", + "f.mp3", + 9, + DownloadStatus::Queued, + sender, + )); + + assert!(client.cancel_download("peer", "f.mp3")); + assert!(!client.cancel_download("peer", "f.mp3")); + assert!(!client.cancel_download("peer", "other.mp3")); + assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Cancelled))); + assert!(matches!( + client.get_all_downloads()[0].status, + DownloadStatus::Cancelled + )); +} diff --git a/soulseek-rs-lib/src/client/tests/users.rs b/soulseek-rs-lib/src/client/tests/users.rs new file mode 100644 index 0000000..4094b04 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/users.rs @@ -0,0 +1,200 @@ +//! What the server tells us about people: watches, interests, and the +//! two halves of a user snapshot. + +use super::*; + +#[test] +fn a_fresh_request_discards_the_previous_answer() { + // Without this, a poll after a second request returns the old + // snapshot immediately and the caller cannot tell stale from fresh. + let mut context = ClientContext::new(); + context.apply_user_status("alice".to_string(), 2, false); + context.apply_user_stats("alice".to_string(), 10, 20, 30); + assert!(context.user_info("alice").is_some_and(|i| i.is_complete())); + + context.invalidate_user_info("alice"); + assert!( + context.user_info("alice").is_none(), + "a new request must not be answerable from the old reply" + ); +} + +#[test] +fn each_reply_fills_only_its_own_half() { + let mut context = ClientContext::new(); + context.apply_user_status("bob".to_string(), 1, true); + + let info = context.user_info("bob").expect("a snapshot"); + assert!(!info.is_complete(), "stats have not arrived"); + assert_eq!( + info.presence.map(|p| p.status), + Some(crate::types::UserStatus::Away) + ); + assert!(info.stats.is_none(), "must not invent statistics"); + + context.apply_user_stats("bob".to_string(), 5, 6, 7); + let info = context.user_info("bob").expect("a snapshot"); + assert!(info.is_complete()); + assert_eq!(info.stats.map(|s| s.shared_files), Some(6)); + assert_eq!( + info.presence.map(|p| p.privileged), + Some(true), + "the earlier half must survive the merge" + ); +} + +#[test] +fn a_watch_reply_fills_both_halves_of_the_snapshot() { + let mut context = ClientContext::new(); + context.add_watched_user("alice"); + context.apply_watched_user( + "alice".to_string(), + true, + Some(2), + Some(1024), + Some(20), + Some(3), + ); + + let info = context.user_info("alice").expect("a snapshot"); + assert!(info.is_complete(), "a watch reply carries status and stats"); + assert_eq!(info.stats.map(|s| s.average_speed), Some(1024)); + assert_eq!(context.watched_users(), vec!["alice".to_string()]); +} + +#[test] +fn a_watch_reply_keeps_a_privileged_flag_it_cannot_carry() { + // WatchUser has no privileged field, so it must not overwrite what + // GetUserStatus already told us with a fabricated `false`. + let mut context = ClientContext::new(); + context.apply_user_status("bob".to_string(), 1, true); + context.apply_watched_user( + "bob".to_string(), + true, + Some(2), + Some(1), + Some(2), + Some(3), + ); + + let info = context.user_info("bob").expect("a snapshot"); + assert_eq!(info.presence.map(|p| p.privileged), Some(true)); +} + +#[test] +fn watching_an_unknown_user_drops_them_from_the_list() { + // The server will never push status for a name it does not know, so + // keeping it in the watch list would show a permanently blank row. + let mut context = ClientContext::new(); + context.add_watched_user("ghost"); + context.apply_watched_user( + "ghost".to_string(), + false, + None, + None, + None, + None, + ); + + assert!(context.watched_users().is_empty()); + assert!(context.user_info("ghost").is_none()); +} + +#[test] +fn unwatching_forgets_the_users_snapshot() { + let mut context = ClientContext::new(); + context.add_watched_user("alice"); + context.apply_user_status("alice".to_string(), 2, false); + + context.remove_watched_user("alice"); + assert!(context.watched_users().is_empty()); + assert!( + context.user_info("alice").is_none(), + "a later re-watch must report a fresh answer" + ); +} + +#[test] +fn watched_users_are_listed_in_a_stable_order() { + let mut context = ClientContext::new(); + context.add_watched_user("carol"); + context.add_watched_user("alice"); + context.add_watched_user("bob"); + assert_eq!(context.watched_users(), vec!["alice", "bob", "carol"]); +} + +// A reply queued for a searcher the server cannot place waits a minute, +// not forever: every such search would otherwise pin a map entry and a +// protected slot in the peer registry. + +#[test] +fn own_and_global_recommendations_are_kept_apart() { + let mut ctx = ClientContext::new(); + ctx.apply_recommendations( + false, + vec![Recommendation { + item: "jazz".into(), + rating: 2, + }], + Vec::new(), + ); + ctx.apply_recommendations( + true, + vec![Recommendation { + item: "pop".into(), + rating: 9, + }], + Vec::new(), + ); + + assert_eq!(ctx.recommendations(false).unwrap().0[0].item, "jazz"); + assert_eq!(ctx.recommendations(true).unwrap().0[0].item, "pop"); +} + +#[test] +fn asking_again_about_a_users_interests_drops_the_previous_answer() { + let mut ctx = ClientContext::new(); + ctx.apply_user_interests(UserInterests { + username: "alice".into(), + likes: vec!["jazz".into()], + hates: Vec::new(), + }); + assert!(ctx.user_interests("alice").is_some()); + + ctx.invalidate_user_interests("alice"); + assert!( + ctx.user_interests("alice").is_none(), + "a stale answer must not be mistaken for the next one" + ); +} + +#[test] +fn our_own_interests_are_kept_lowercased_and_deduplicated() { + // The server matches interests case-insensitively and forgets them when + // the session ends, so they are held here in one spelling to be sent + // again next login. + let mut ctx = ClientContext::new(); + assert_eq!( + ctx.add_own_interest(" Krautrock ", true).as_deref(), + Some("krautrock") + ); + assert_eq!( + ctx.add_own_interest(" ", true), + None, + "an empty interest is not stored, and is not sent either" + ); + ctx.add_own_interest("KRAUTROCK", true); + ctx.add_own_interest("Muzak", false); + + let interests = ctx.own_interests(); + assert_eq!(interests.likes, ["krautrock"]); + assert_eq!(interests.hates, ["muzak"]); + + ctx.remove_own_interest("KrautRock", true); + assert!(ctx.own_interests().likes.is_empty()); + assert_eq!( + ctx.own_interests().hates, + ["muzak"], + "the other list stands" + ); +} diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 67178af..3658f4c 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -264,2321 +264,4 @@ pub fn launch_main_tui( } #[cfg(test)] -mod tests { - use super::*; - use crate::daemon::proto::ChatMessageDto; - use crate::models::{ - FocusedPane, MessageDirection, RoomsView, SearchStatus, - }; - use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - - /// A stand-in for a daemon: it already holds a conversation, a transfer - /// queue, and a search cache, none of which this window created. - #[derive(Default)] - struct TalkativeSession { - history: Vec, - downloads: Vec, - searches: std::sync::Mutex>, - /// What `try_get_search_results` hands back to the window. - results: std::sync::Mutex>, - /// Every query a window put on the wire, in order. - searched: std::sync::Mutex>, - /// How many times a window asked for the search list. - all_searches_calls: std::sync::atomic::AtomicUsize, - /// How many times a window pulled a full result set across. Against a - /// daemon each pull is the whole set over the socket, so the tests - /// count them. - result_fetches: std::sync::atomic::AtomicUsize, - /// Whether this session is a daemon's. Local sessions do not sync. - shared: bool, - /// What `set_download_directory` was last asked for, if anything. - download_dir_set: std::sync::Mutex>, - /// What `set_shared_directories` was last asked for, if anything. - shares_set: std::sync::Mutex>>, - /// What `change_password` was last asked for, if anything. - password_set: std::sync::Mutex>, - /// The share index this session serves to peers. - listing: std::sync::Mutex>, - download_cancelled: std::sync::Mutex>, - upload_cancelled: std::sync::Mutex>, - download_removed: std::sync::Mutex>, - } - - fn queued(username: &str, filename: &str) -> soulseek_rs::types::Download { - at_status(username, filename, soulseek_rs::DownloadStatus::Queued) - } - - fn completed( - username: &str, - filename: &str, - ) -> soulseek_rs::types::Download { - at_status(username, filename, soulseek_rs::DownloadStatus::Completed) - } - - fn uploading( - username: &str, - filename: &str, - ) -> soulseek_rs::types::UploadInfo { - soulseek_rs::types::UploadInfo { - username: username.to_string(), - filename: filename.to_string(), - size: 10, - bytes_sent: 1, - speed_bytes_per_sec: 1.0, - status: soulseek_rs::types::UploadStatus::InProgress, - } - } - - fn at_status( - username: &str, - filename: &str, - status: soulseek_rs::DownloadStatus, - ) -> soulseek_rs::types::Download { - let (sender, _) = std::sync::mpsc::channel(); - soulseek_rs::types::Download { - username: username.to_string(), - filename: filename.to_string(), - token: 1, - size: 4096, - download_directory: "/tmp".to_string(), - status, - sender, - queue_position: None, - metadata: soulseek_rs::types::DownloadMetadata::default(), - } - } - - fn key(ch: char) -> KeyEvent { - KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE) - } - - impl SessionApi for TalkativeSession { - fn message_history(&self) -> Vec { - self.history.clone() - } - fn daemon_endpoint(&self) -> Option { - self.shared.then(|| "test-daemon".to_string()) - } - fn username(&self) -> String { - "tester".to_string() - } - fn listen_port(&self) -> Option { - None - } - fn session_loss(&self) -> Option { - None - } - fn search_with_cancel( - &self, - query: &str, - _timeout: Duration, - _cancel: Option>, - ) -> soulseek_rs::Result> { - self.searched - .lock() - .expect("not poisoned") - .push(query.to_string()); - Ok(Vec::new()) - } - fn get_search_results( - &self, - _key: &str, - ) -> Vec { - Vec::new() - } - fn all_searches(&self) -> Vec { - self.all_searches_calls - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - self.searches.lock().expect("not poisoned").clone() - } - fn forget_search(&self, query: &str) -> bool { - let mut searches = self.searches.lock().expect("not poisoned"); - let before = searches.len(); - searches.retain(|search| search.query != query); - searches.len() != before - } - fn get_search_results_count(&self, _key: &str) -> usize { - 0 - } - fn try_get_search_results( - &self, - _key: &str, - ) -> Option> { - self.result_fetches - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - Some(self.results.lock().expect("not poisoned").clone()) - } - fn start_wishlist_search( - &self, - _query: &str, - ) -> soulseek_rs::Result<()> { - Ok(()) - } - fn wishlist_interval(&self) -> Duration { - Duration::from_mins(12) - } - fn download( - &self, - _filename: String, - _username: String, - _size: u64, - _directory: String, - ) -> soulseek_rs::Result<( - soulseek_rs::types::Download, - std::sync::mpsc::Receiver, - )> { - Err(soulseek_rs::SoulseekRs::NotConnected) - } - fn download_with_metadata( - &self, - _filename: String, - _username: String, - _size: u64, - _directory: String, - _metadata: soulseek_rs::types::DownloadMetadata, - ) -> soulseek_rs::Result<( - soulseek_rs::types::Download, - std::sync::mpsc::Receiver, - )> { - Err(soulseek_rs::SoulseekRs::NotConnected) - } - fn get_all_downloads(&self) -> Vec { - self.downloads.clone() - } - fn pause_download(&self, _u: &str, _f: &str) -> bool { - false - } - fn resume_download(&self, _u: &str, _f: &str) -> bool { - false - } - fn remove_queued_download(&self, _u: &str, _f: &str) -> bool { - false - } - fn remove_download(&self, u: &str, f: &str) -> bool { - self.download_removed - .lock() - .expect("not poisoned") - .push((u.to_string(), f.to_string())); - true - } - fn uploads(&self) -> Vec { - Vec::new() - } - fn take_upload_events(&self) -> Vec { - Vec::new() - } - fn cancel_upload(&self, u: &str, f: &str) -> bool { - self.upload_cancelled - .lock() - .expect("not poisoned") - .push((u.to_string(), f.to_string())); - true - } - fn cancel_download(&self, u: &str, f: &str) -> bool { - self.download_cancelled - .lock() - .expect("not poisoned") - .push((u.to_string(), f.to_string())); - true - } - fn set_upload_slots(&self, _slots: usize) {} - fn check_privileges(&self) -> soulseek_rs::Result<()> { - Ok(()) - } - fn own_privilege_seconds(&self) -> Option { - None - } - fn request_room_list(&self) -> soulseek_rs::Result<()> { - Ok(()) - } - fn join_room(&self, _room: &str) -> soulseek_rs::Result<()> { - Ok(()) - } - fn leave_room(&self, _room: &str) -> soulseek_rs::Result<()> { - Ok(()) - } - fn say_in_room( - &self, - _room: &str, - _message: &str, - ) -> soulseek_rs::Result<()> { - Ok(()) - } - fn room_members(&self, _room: &str) -> Vec { - Vec::new() - } - fn room_member_stats( - &self, - _room: &str, - ) -> Vec { - Vec::new() - } - fn take_room_events(&self) -> Vec { - Vec::new() - } - fn send_private_message( - &self, - _u: &str, - _m: &str, - ) -> soulseek_rs::Result<()> { - Ok(()) - } - fn take_private_messages(&self) -> Vec { - Vec::new() - } - fn browse_user(&self, _username: &str) -> soulseek_rs::Result<()> { - Ok(()) - } - fn take_browse_result( - &self, - _username: &str, - ) -> Option> { - None - } - fn request_user_info( - &self, - _username: &str, - ) -> soulseek_rs::Result<()> { - Ok(()) - } - fn watch_user(&self, _username: &str) -> soulseek_rs::Result<()> { - Ok(()) - } - fn unwatch_user(&self, _username: &str) -> soulseek_rs::Result<()> { - Ok(()) - } - fn watched_users(&self) -> Vec { - Vec::new() - } - fn user_info(&self, _username: &str) -> Option { - None - } - fn shared_counts(&self) -> (u32, u32) { - (3, 12) - } - fn change_password(&self, password: &str) -> soulseek_rs::Result<()> { - *self.password_set.lock().expect("not poisoned") = - Some(password.to_string()); - Ok(()) - } - fn shared_listing(&self) -> Vec { - self.listing.lock().expect("not poisoned").clone() - } - fn shared_directories(&self) -> Vec { - Vec::new() - } - fn set_shared_directories( - &self, - directories: Vec, - ) -> soulseek_rs::Result<()> { - *self.shares_set.lock().expect("not poisoned") = Some(directories); - Ok(()) - } - fn set_download_directory( - &self, - directory: String, - ) -> soulseek_rs::Result<()> { - *self.download_dir_set.lock().expect("not poisoned") = - Some(directory); - Ok(()) - } - } - - fn tui(history: Vec) -> MainTui { - with_session(TalkativeSession { - history, - shared: true, - ..TalkativeSession::default() - }) - } - - fn shared(searches: Vec) -> TalkativeSession { - TalkativeSession { - searches: std::sync::Mutex::new(searches), - shared: true, - ..TalkativeSession::default() - } - } - - fn search_of( - query: &str, - files: usize, - age: u64, - ) -> crate::api::SessionSearch { - crate::api::SessionSearch { - query: query.to_string(), - files, - started_secs_ago: Some(age), - } - } - - #[test] - fn x_cancels_the_highlighted_download() { - let session = Arc::new(TalkativeSession::default()); - let mut tui = attach(session.clone()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: at_status( - "bob", - "song.mp3", - soulseek_rs::DownloadStatus::InProgress { - bytes_downloaded: 1, - total_bytes: 4096, - speed_bytes_per_sec: 1.0, - }, - ), - receiver: None, - }); - tui.state.downloads_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('x')); - - assert_eq!( - *session.download_cancelled.lock().expect("not poisoned"), - vec![("bob".to_string(), "song.mp3".to_string())] - ); - assert!( - session - .upload_cancelled - .lock() - .expect("not poisoned") - .is_empty(), - "the download row cancels no upload" - ); - } - - #[test] - fn shift_c_clears_the_whole_transfer_list() { - let session = Arc::new(TalkativeSession::default()); - let mut tui = attach(session.clone()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: queued("bob", "waiting.mp3"), - receiver: None, - }); - tui.state.downloads.push(crate::models::DownloadEntry { - download: at_status( - "carol", - "live.mp3", - soulseek_rs::DownloadStatus::InProgress { - bytes_downloaded: 1, - total_bytes: 4096, - speed_bytes_per_sec: 1.0, - }, - ), - receiver: None, - }); - tui.state.downloads.push(crate::models::DownloadEntry { - download: completed("dave", "done.mp3"), - receiver: None, - }); - tui.state.uploads.push(uploading("alice", "served.mp3")); - tui.state.downloads_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('C')); - - assert!(tui.state.downloads.is_empty(), "every row goes"); - assert_eq!( - *session.download_cancelled.lock().expect("not poisoned"), - vec![ - ("bob".to_string(), "waiting.mp3".to_string()), - ("carol".to_string(), "live.mp3".to_string()), - ], - "live downloads are cancelled; the finished one is not" - ); - assert_eq!( - *session.download_removed.lock().expect("not poisoned"), - vec![ - ("bob".to_string(), "waiting.mp3".to_string()), - ("carol".to_string(), "live.mp3".to_string()), - ("dave".to_string(), "done.mp3".to_string()), - ], - "the session forgets every download, not just this window's rows" - ); - assert_eq!( - *session.upload_cancelled.lock().expect("not poisoned"), - vec![("alice".to_string(), "served.mp3".to_string())], - "a streaming upload is cancelled too" - ); - } - - #[test] - fn d_clears_a_cancelled_row() { - let mut tui = with_session(TalkativeSession::default()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: at_status( - "bob", - "gone.mp3", - soulseek_rs::DownloadStatus::Cancelled, - ), - receiver: None, - }); - tui.state.downloads_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('d')); - - assert!(tui.state.downloads.is_empty(), "the row goes"); - } - - #[test] - fn x_on_a_finished_download_asks_nothing() { - let session = Arc::new(TalkativeSession::default()); - let mut tui = attach(session.clone()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: completed("bob", "done.mp3"), - receiver: None, - }); - tui.state.downloads_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('x')); - - assert!( - session - .download_cancelled - .lock() - .expect("not poisoned") - .is_empty(), - "a finished download is not cancelled" - ); - } - - #[test] - fn x_on_an_upload_row_still_cancels_the_upload() { - let session = Arc::new(TalkativeSession::default()); - let mut tui = attach(session.clone()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: queued("bob", "song.mp3"), - receiver: None, - }); - tui.state.uploads.push(uploading("alice", "served.mp3")); - tui.state.downloads_table_state.select(Some(1)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('x')); - - assert_eq!( - *session.upload_cancelled.lock().expect("not poisoned"), - vec![("alice".to_string(), "served.mp3".to_string())] - ); - assert!( - session - .download_cancelled - .lock() - .expect("not poisoned") - .is_empty(), - "the upload row cancels no download" - ); - } - - #[test] - fn b_browses_the_user_of_the_highlighted_download() { - let mut tui = with_session(TalkativeSession::default()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: queued("bob", "song.mp3"), - receiver: None, - }); - tui.state.downloads_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('b')); - - assert!(tui.state.show_browse, "the browse popup opens"); - assert_eq!( - tui.state.browse.active_tab().map(|b| b.username.as_str()), - Some("bob"), - "the download's user is the one browsed" - ); - assert!( - !tui.state.command_bar_active, - "b on a transfer goes straight to the user, not the prompt" - ); - } - - #[test] - fn b_on_an_upload_row_browses_that_user() { - let mut tui = with_session(TalkativeSession::default()); - tui.state.uploads.push(uploading("alice", "served.mp3")); - tui.state.downloads_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('b')); - - assert_eq!( - tui.state.browse.active_tab().map(|b| b.username.as_str()), - Some("alice") - ); - } - - fn with_session(session: TalkativeSession) -> MainTui { - attach(Arc::new(session)) - } - - fn attach(session: Arc) -> MainTui { - MainTui::new( - session, - "/tmp".to_string(), - Duration::from_secs(1), - None, - None, - ) - } - - #[test] - fn an_attached_session_opens_with_the_conversation_already_in_it() { - // A TUI attached to a daemon has no state file of its own; the - // conversation has to come from the session, or it starts blank while - // the daemon has been collecting messages for hours. - let tui = tui(vec![ - ChatMessageDto { - peer: "bob".into(), - outgoing: false, - text: "hi".into(), - at: 1, - }, - ChatMessageDto { - peer: "bob".into(), - outgoing: true, - text: "hello".into(), - at: 2, - }, - ]); - - assert_eq!(tui.state.messages.len(), 2); - assert_eq!(tui.state.messages[0].peer, "bob"); - assert_eq!(tui.state.messages[0].direction, MessageDirection::Incoming); - assert_eq!( - tui.state.messages[1].direction, - MessageDirection::Outgoing, - "both halves of the conversation, not just what arrived" - ); - assert_eq!(tui.state.chat_peers(), ["bob"]); - } - - #[test] - fn a_window_shows_transfers_it_did_not_start() { - // Two windows on one daemon are two views of one queue. A transfer - // queued in the other one has to appear here, or closing a window - // would look like losing its downloads. - let mut tui = with_session(TalkativeSession { - downloads: vec![queued("bob", "@@x\\theirs.mp3")], - shared: true, - ..TalkativeSession::default() - }); - assert!( - tui.state.downloads.is_empty(), - "nothing until the first poll" - ); - - tui.update_downloads(); - - assert_eq!(tui.state.downloads.len(), 1); - assert_eq!(tui.state.downloads[0].download.filename, "@@x\\theirs.mp3"); - assert!( - tui.state.downloads[0].receiver.is_none(), - "someone else's transfer has no channel of ours" - ); - - // Polling again must not double it up. - tui.update_downloads(); - assert_eq!(tui.state.downloads.len(), 1); - } - - #[test] - fn a_transfer_the_session_forgets_leaves_every_window() { - let mut tui = with_session(TalkativeSession { - downloads: vec![queued("bob", "gone.mp3")], - shared: true, - ..TalkativeSession::default() - }); - tui.update_downloads(); - assert_eq!(tui.state.downloads.len(), 1); - - // The session no longer has it — cancelled from the other window. - tui.client = Arc::new(TalkativeSession { - shared: true, - ..TalkativeSession::default() - }); - tui.update_downloads(); - assert!( - tui.state.downloads.is_empty(), - "a queue is shared in both directions" - ); - } - - #[test] - fn d_clears_a_finished_row_but_not_a_running_one() { - // Finished transfers are what a list fills up with after a restart, so - // `d` has to be able to dismiss one — restored or not. A running - // transfer is not ours to discard, and stays put. - let mut tui = with_session(TalkativeSession::default()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: completed("bob", "done.mp3"), - receiver: None, - }); - tui.state.downloads_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('d')); - - assert!(tui.state.downloads.is_empty(), "the row goes too"); - assert_eq!(tui.state.downloads_table_state.selected(), None); - - tui.state.downloads.push(crate::models::DownloadEntry { - download: at_status( - "bob", - "still.mp3", - soulseek_rs::DownloadStatus::InProgress { - bytes_downloaded: 1, - total_bytes: 2, - speed_bytes_per_sec: 1.0, - }, - ), - receiver: None, - }); - tui.state.downloads_table_state.select(Some(0)); - - tui.handle_key_event(key('d')); - - assert_eq!(tui.state.downloads.len(), 1, "it is not disposable"); - } - - #[test] - fn a_window_shows_searches_run_in_another() { - let mut tui = - with_session(shared(vec![search_of("their query", 7, 9_999)])); - tui.update_search_results(); - - assert_eq!(tui.state.searches.len(), 1); - assert_eq!(tui.state.searches[0].query, "their query"); - - // And it is not added a second time on the next frame. - tui.update_search_results(); - assert_eq!(tui.state.searches.len(), 1); - } - - #[test] - fn a_search_another_window_is_still_running_shows_as_active() { - // It had said "Done" the moment it appeared, because an imported - // search carried no notion of still collecting — so the other window - // looked finished while results were still arriving. - let mut tui = - with_session(shared(vec![search_of("still going", 3, 0)])); - tui.update_search_results(); - - assert_eq!(tui.state.searches[0].status, SearchStatus::Active); - assert_eq!( - tui.state.searches[0].known_files, 3, - "and its count comes across before the results do" - ); - } - - #[test] - fn a_search_that_has_run_its_window_shows_as_done() { - let mut tui = - with_session(shared(vec![search_of("finished", 12, 9_999)])); - tui.update_search_results(); - assert_eq!(tui.state.searches[0].status, SearchStatus::Completed); - } - - #[test] - fn removing_a_search_removes_it_from_the_session() { - // Removing it only from this window would last until the next sync, - // which would put it straight back — and in a shared session it would - // come back for every other window too. - let mut tui = - with_session(shared(vec![search_of("unwanted", 1, 9_999)])); - tui.update_search_results(); - assert_eq!(tui.state.searches.len(), 1); - - tui.remove_search_at_index(0); - tui.update_search_results(); - - assert!(tui.state.searches.is_empty(), "and it stays gone"); - } - - #[test] - fn clearing_every_search_clears_them_in_the_session_too() { - let mut tui = with_session(shared(vec![ - search_of("one", 1, 9_999), - search_of("two", 2, 9_999), - ])); - tui.update_search_results(); - assert_eq!(tui.state.searches.len(), 2); - - tui.clear_all_searches(); - tui.update_search_results(); - - assert!( - tui.state.searches.is_empty(), - "clearing has to reach the session, or every row comes back" - ); - } - - #[test] - fn a_search_dismissed_in_another_window_leaves_this_one() { - let session = shared(vec![search_of("theirs", 4, 9_999)]); - let mut tui = with_session(session); - tui.update_search_results(); - assert_eq!(tui.state.searches.len(), 1); - - // The other window dismissed it. - tui.client.forget_search("theirs"); - tui.update_search_results(); - - assert!(tui.state.searches.is_empty()); - } - - fn files_from_bob(names: &[&str]) -> soulseek_rs::SearchResult { - soulseek_rs::SearchResult { - token: 0, - files: names - .iter() - .map(|name| soulseek_rs::types::File { - username: "bob".to_string(), - name: (*name).to_string(), - size: 42, - attribs: std::collections::HashMap::new(), - }) - .collect(), - slots: 1, - speed: 0, - username: "bob".to_string(), - } - } - - #[test] - fn a_tick_asks_the_session_for_its_searches_once() { - // Against a daemon every call is a round trip, and the daemon answers - // it by walking its whole search cache — twice per frame was half the - // cost of attaching at all. - let session = Arc::new(shared(vec![search_of("q", 0, 9_999)])); - let mut tui = attach(session.clone()); - - tui.update_search_results(); - - assert_eq!( - session - .all_searches_calls - .load(std::sync::atomic::Ordering::Relaxed), - 1, - "one sync means one listing, not one per use of it" - ); - } - - /// An attached window that selected a two-file search and has already - /// ticked past its one necessary fetch. - fn window_that_pulled_two_files() -> (Arc, MainTui) { - let session = Arc::new(TalkativeSession { - results: std::sync::Mutex::new(vec![files_from_bob(&[ - "@@x\\a.mp3", - "@@x\\b.mp3", - ])]), - ..shared(vec![search_of("aphex twin", 2, 9_999)]) - }); - let mut tui = attach(session.clone()); - tui.state.selected_search_index = Some(0); - tui.update_search_results(); - tui.update_search_results(); - (session, tui) - } - - #[test] - fn a_selected_search_with_nothing_new_is_not_pulled_across_again() { - // The result set only accumulates, so an unchanged count means a - // fetch would hand back what this window already holds — against a - // daemon that is the entire result set over the socket, every frame, - // forever. - let (session, tui) = window_that_pulled_two_files(); - - assert_eq!(tui.state.searches[0].results.len(), 2); - assert_eq!( - session - .result_fetches - .load(std::sync::atomic::Ordering::Relaxed), - 1, - "the set crosses once; after that the unchanged count spares it" - ); - } - - #[test] - fn fresh_results_still_reach_a_window_that_skipped_a_fetch() { - let (session, mut tui) = window_that_pulled_two_files(); - - // A late responder lands in the session. - *session.searches.lock().expect("not poisoned") = - vec![search_of("aphex twin", 3, 9_999)]; - *session.results.lock().expect("not poisoned") = - vec![files_from_bob(&["@@x\\a.mp3", "@@x\\b.mp3", "@@x\\c.mp3"])]; - tui.update_search_results(); - - assert_eq!( - tui.state.searches[0].results.len(), - 3, - "a changed count must bring the new results across" - ); - } - - #[test] - fn a_local_window_fetches_results_without_counting_on_the_session() { - // Locally nothing maintains known_files, so the count gate must stay - // out of the way: a search whose count still reads zero has to get - // its results anyway. - let session = Arc::new(TalkativeSession { - results: std::sync::Mutex::new(vec![files_from_bob(&[ - "@@x\\a.mp3", - "@@x\\b.mp3", - ])]), - shared: false, - ..TalkativeSession::default() - }); - let mut tui = attach(session); - tui.state.searches.push(crate::models::SearchEntry { - query: "mine".to_string(), - status: SearchStatus::Active, - results: Vec::new(), - known_files: 0, - owned: true, - start_time: std::time::Instant::now(), - cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), - }); - - tui.update_search_results(); - - assert_eq!( - tui.state.searches[0].results.len(), - 2, - "a zero count matching zero held rows must not read as \ - nothing-to-fetch on a local session" - ); - } - - #[test] - fn a_window_with_its_own_session_is_left_alone() { - // Nothing to share, so nothing is synced — and in particular the - // completed transfers restored from disk, which the session has never - // heard of, are not mistaken for cancelled and deleted. - let mut tui = with_session(TalkativeSession { - downloads: Vec::new(), - shared: false, - ..TalkativeSession::default() - }); - tui.state.downloads.push(crate::models::DownloadEntry { - download: queued("bob", "from-disk.mp3"), - receiver: None, - }); - - tui.update_downloads(); - - assert_eq!( - tui.state.downloads.len(), - 1, - "a local window's history must survive the tick" - ); - } - - #[test] - fn a_local_session_starts_from_its_own_snapshot_instead() { - // Locally the session knows no history and the state file is the whole - // record, so nothing is invented. - assert!(tui(Vec::new()).state.messages.is_empty()); - } - - /// Attached, both folders in the settings form name paths on the - /// *daemon's* machine: they go over the socket exactly as typed, and - /// nothing is created or validated against this machine's filesystem. - #[test] - fn applying_settings_attached_pushes_both_folders_to_the_daemon() { - let session = Arc::new(TalkativeSession { - shared: true, - ..TalkativeSession::default() - }); - let scratch = tempfile::tempdir().expect("a temp dir"); - let config_path = scratch.path().join("config.toml"); - let mut tui = MainTui::new( - session.clone(), - "/daemon/old".to_string(), - Duration::from_secs(1), - None, - Some(config_path.clone()), - ); - - let daemon_only = scratch.path().join("only-on-the-daemon"); - let daemon_dir = daemon_only.to_str().expect("utf-8").to_string(); - tui.open_settings(); - { - let settings = tui.state.settings.as_mut().expect("open"); - settings.download_dir.clone_from(&daemon_dir); - settings.share_dirs = vec!["/not-here-either".to_string()]; - } - tui.apply_settings(); - - assert_eq!( - *session.download_dir_set.lock().expect("not poisoned"), - Some(daemon_dir.clone()), - "the folder is pushed to the daemon" - ); - assert_eq!( - *session.shares_set.lock().expect("not poisoned"), - Some(vec!["/not-here-either".to_string()]), - "shares go over as typed — the daemon's filesystem judges them" - ); - assert!( - !daemon_only.exists(), - "no directory is created on this machine for a daemon's path" - ); - assert_eq!(tui.download_dir, daemon_dir); - - let saved = crate::persist::config::FileConfig::load(&config_path) - .expect("config readable"); - assert_eq!( - saved.download_dir.as_deref(), - Some(daemon_dir.as_str()), - "the change still lands in config.toml for the next restart" - ); - } - - /// Locally the same form works on this machine: the folder is created - /// here and share paths that do not exist here are dropped before the - /// session hears about them. - #[test] - fn applying_settings_locally_stays_on_this_machine() { - let session = Arc::new(TalkativeSession { - shared: false, - ..TalkativeSession::default() - }); - let scratch = tempfile::tempdir().expect("a temp dir"); - let config_path = scratch.path().join("config.toml"); - let share = scratch.path().join("music"); - std::fs::create_dir_all(&share).expect("share dir"); - let mut tui = MainTui::new( - session.clone(), - "/tmp".to_string(), - Duration::from_secs(1), - None, - Some(config_path), - ); - - let downloads = scratch.path().join("downloads"); - let downloads_dir = downloads.to_str().expect("utf-8").to_string(); - tui.open_settings(); - { - let settings = tui.state.settings.as_mut().expect("open"); - settings.download_dir.clone_from(&downloads_dir); - settings.share_dirs = vec![ - share.to_str().expect("utf-8").to_string(), - "/definitely-not-a-real-share".to_string(), - ]; - } - tui.apply_settings(); - - // The seam is told in both modes; a local session accepts and takes - // the directory per download instead of storing it. - assert_eq!( - *session.download_dir_set.lock().expect("not poisoned"), - Some(downloads_dir.clone()) - ); - assert_eq!( - *session.shares_set.lock().expect("not poisoned"), - Some(vec![share.to_str().expect("utf-8").to_string()]), - "paths missing on this machine are dropped before applying" - ); - assert!(downloads.is_dir(), "the folder is created here"); - assert_eq!(tui.download_dir, downloads_dir); - } - - /// The account block answers "who is this session, and what is it - /// offering" without a trip to another screen. - #[test] - fn the_settings_popup_names_the_account_and_its_shares() { - let mut tui = with_session(TalkativeSession::default()); - press(&mut tui, KeyCode::Char('o')); - - let screen = screen_sized(&mut tui, 100, 30); - assert!(screen.contains("tester"), "{screen}"); - assert!(screen.contains("12 files in 3 folders"), "{screen}"); - } - - /// Typed twice the same way, the new password reaches the session — and - /// attached to a daemon it is the daemon that stores it. - #[test] - fn a_password_typed_twice_reaches_the_session() { - let session = Arc::new(TalkativeSession { - shared: true, - ..TalkativeSession::default() - }); - let mut tui = attach(session.clone()); - press(&mut tui, KeyCode::Char('o')); - press(&mut tui, KeyCode::Enter); // the change-password row is first - for _ in 0..2 { - for c in "hunter2".chars() { - press(&mut tui, KeyCode::Char(c)); - } - press(&mut tui, KeyCode::Enter); - } - - assert_eq!( - *session.password_set.lock().expect("not poisoned"), - Some("hunter2".to_string()) - ); - let settings = tui.state.settings.as_ref().expect("still open"); - assert_eq!(settings.status.as_deref(), Some("Password changed")); - } - - /// A mistyped repeat is caught here rather than on the server, where the - /// account would be left with a password nobody knows. - #[test] - fn a_password_typed_two_different_ways_is_not_sent() { - let session = Arc::new(TalkativeSession { - shared: true, - ..TalkativeSession::default() - }); - let mut tui = attach(session.clone()); - press(&mut tui, KeyCode::Char('o')); - press(&mut tui, KeyCode::Enter); - for typed in ["hunter2", "hunter3"] { - for c in typed.chars() { - press(&mut tui, KeyCode::Char(c)); - } - press(&mut tui, KeyCode::Enter); - } - - assert_eq!(*session.password_set.lock().expect("not poisoned"), None); - } - - /// Logging out ends the window, but as a log-out: the caller brings the - /// login screen back rather than returning to the shell. - #[test] - fn logging_out_asks_first_and_then_closes_the_window() { - let mut tui = with_session(TalkativeSession::default()); - press(&mut tui, KeyCode::Char('o')); - press(&mut tui, KeyCode::Down); // change password -> log out - press(&mut tui, KeyCode::Enter); - assert!(tui.state.exit.is_none(), "the confirm comes first"); - - press(&mut tui, KeyCode::Char('y')); - assert_eq!(tui.state.exit, Some(TuiExit::Logout)); - } - - /// Attached, the login is the daemon's: there is no logout row to land - /// on, so the row below the password is the download folder. - #[test] - fn an_attached_window_offers_no_logout() { - let session = Arc::new(TalkativeSession { - shared: true, - ..TalkativeSession::default() - }); - let mut tui = attach(session); - press(&mut tui, KeyCode::Char('o')); - press(&mut tui, KeyCode::Down); - press(&mut tui, KeyCode::Enter); - - let settings = tui.state.settings.as_ref().expect("still open"); - assert_eq!( - settings.mode, - crate::models::SettingsMode::EditingDownloadDir - ); - assert!(tui.state.exit.is_none()); - } - - fn results(n: usize) -> Vec { - (0..n) - .map(|i| crate::models::FileDisplayData { - filename: format!("@@x\\Music\\{i:02}.mp3"), - username: "bob".to_string(), - ..Default::default() - }) - .collect() - } - - fn results_tui(n: usize) -> MainTui { - let mut tui = with_session(TalkativeSession::default()); - tui.state.results_items = results(n); - tui.state.focused_pane = FocusedPane::Results; - // 88 cells wide gives the name column 11 cells and the folder 7. - tui.state.results_pane_area = - Some(ratatui::layout::Rect::new(0, 0, 88, 13)); - tui - } - - fn press(tui: &mut MainTui, code: KeyCode) { - tui.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); - } - - fn ctrl(tui: &mut MainTui, ch: char) { - tui.handle_key_event(KeyEvent::new( - KeyCode::Char(ch), - KeyModifiers::CONTROL, - )); - } - - fn selected(tui: &MainTui) -> Option { - tui.state.results_table_state.selected() - } - - #[test] - fn end_and_home_jump_to_the_last_and_first_result() { - let mut tui = results_tui(30); - press(&mut tui, KeyCode::End); - assert_eq!(selected(&tui), Some(29)); - press(&mut tui, KeyCode::Home); - assert_eq!(selected(&tui), Some(0)); - press(&mut tui, KeyCode::Char('G')); - assert_eq!(selected(&tui), Some(29)); - press(&mut tui, KeyCode::Char('g')); - assert_eq!(selected(&tui), Some(0)); - } - - #[test] - fn page_keys_move_a_pane_of_rows_and_stop_at_the_edges() { - let mut tui = results_tui(30); - press(&mut tui, KeyCode::PageDown); - assert_eq!(selected(&tui), Some(10)); - ctrl(&mut tui, 'f'); - assert_eq!(selected(&tui), Some(20)); - press(&mut tui, KeyCode::PageDown); - assert_eq!(selected(&tui), Some(29), "clamps instead of wrapping"); - press(&mut tui, KeyCode::PageUp); - assert_eq!(selected(&tui), Some(19)); - ctrl(&mut tui, 'b'); - assert_eq!(selected(&tui), Some(9)); - press(&mut tui, KeyCode::PageUp); - assert_eq!(selected(&tui), Some(0)); - assert!( - !tui.state.show_browse && !tui.state.command_bar_active, - "ctrl-b pages up instead of opening browse" - ); - } - - #[test] - fn ctrl_d_and_ctrl_u_move_half_a_pane() { - let mut tui = results_tui(30); - ctrl(&mut tui, 'd'); - assert_eq!(selected(&tui), Some(5)); - ctrl(&mut tui, 'u'); - assert_eq!(selected(&tui), Some(0)); - } - - #[test] - fn navigation_keys_do_nothing_without_results() { - let mut tui = results_tui(0); - tui.state.results_table_state.select(None); - press(&mut tui, KeyCode::End); - press(&mut tui, KeyCode::PageDown); - ctrl(&mut tui, 'd'); - assert_eq!(selected(&tui), None); - } - - #[test] - fn l_and_h_scroll_the_highlighted_name_up_to_its_end() { - let mut tui = results_tui(3); - tui.state.results_items[1].filename = "x".repeat(40); - tui.state.results_table_state.select(Some(1)); - press(&mut tui, KeyCode::Char('l')); - assert_eq!(tui.state.results_name_offset, 8); - press(&mut tui, KeyCode::Right); - assert_eq!(tui.state.results_name_offset, 16); - press(&mut tui, KeyCode::Char('$')); - assert_eq!(tui.state.results_name_offset, 30); - press(&mut tui, KeyCode::Char('l')); - assert_eq!(tui.state.results_name_offset, 30, "stops at the end"); - press(&mut tui, KeyCode::Char('h')); - assert_eq!(tui.state.results_name_offset, 22); - press(&mut tui, KeyCode::Left); - assert_eq!(tui.state.results_name_offset, 14); - press(&mut tui, KeyCode::Char('0')); - assert_eq!(tui.state.results_name_offset, 0); - press(&mut tui, KeyCode::Char('h')); - assert_eq!(tui.state.results_name_offset, 0); - } - - #[test] - fn scrolling_follows_the_highlighted_row_not_the_longest_one() { - let mut tui = results_tui(3); - tui.state.results_items[0].filename = "short.mp3".to_string(); - tui.state.results_items[1].filename = "x".repeat(40); - tui.state.results_items[2].filename = "short.mp3".to_string(); - press(&mut tui, KeyCode::Char('$')); - assert_eq!(tui.state.results_name_offset, 0, "row 0 fits already"); - tui.state.results_table_state.select(Some(1)); - press(&mut tui, KeyCode::Char('$')); - assert_eq!(tui.state.results_name_offset, 30); - tui.state.results_table_state.select(Some(2)); - press(&mut tui, KeyCode::Char('h')); - assert_eq!( - tui.state.results_name_offset, 0, - "a step left lands within the row now highlighted" - ); - } - - #[test] - fn picking_another_search_starts_its_results_unscrolled() { - let mut tui = results_tui(3); - tui.state.results_items[1].filename = "x".repeat(40); - tui.state.results_table_state.select(Some(1)); - press(&mut tui, KeyCode::Char('$')); - assert_eq!(tui.state.results_name_offset, 30); - - tui.state.searches.push(crate::models::SearchEntry { - query: "other".to_string(), - status: SearchStatus::Active, - results: results(2), - known_files: 2, - owned: true, - start_time: std::time::Instant::now(), - cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), - }); - tui.state.searches_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Searches; - press(&mut tui, KeyCode::Enter); - assert_eq!(tui.state.results_name_offset, 0); - } - - #[test] - fn ctrl_keys_are_ignored_outside_the_results_pane() { - let mut tui = results_tui(3); - tui.state.searches.push(crate::models::SearchEntry { - query: "keep me".to_string(), - status: SearchStatus::Active, - results: Vec::new(), - known_files: 0, - owned: true, - start_time: std::time::Instant::now(), - cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), - }); - tui.state.searches_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Searches; - ctrl(&mut tui, 'd'); - assert_eq!(tui.state.searches.len(), 1, "ctrl-d is not d"); - ctrl(&mut tui, 'b'); - assert!(!tui.state.command_bar_active, "ctrl-b is not b"); - ctrl(&mut tui, 'q'); - assert!(tui.state.exit.is_none(), "ctrl-q is not q"); - } - - #[test] - fn ctrl_keys_page_while_a_filter_is_being_typed() { - let mut tui = results_tui(30); - press(&mut tui, KeyCode::Char('/')); - press(&mut tui, KeyCode::Char('m')); - ctrl(&mut tui, 'd'); - assert_eq!(selected(&tui), Some(5)); - assert_eq!(tui.state.results_filter_query, "m", "no d appended"); - press(&mut tui, KeyCode::End); - assert_eq!(selected(&tui), Some(29)); - } - - #[test] - fn space_after_a_filter_that_matches_nothing_does_not_panic() { - let mut tui = results_tui(3); - press(&mut tui, KeyCode::Char('/')); - press(&mut tui, KeyCode::Char('z')); - press(&mut tui, KeyCode::Enter); - assert!(tui.state.results_filtered_items.is_empty()); - press(&mut tui, KeyCode::Char(' ')); - assert!(tui.state.results_selected_indices.is_empty()); - } - - fn screen_sized(tui: &mut MainTui, width: u16, height: u16) -> String { - let backend = ratatui::backend::TestBackend::new(width, height); - let mut terminal = ratatui::Terminal::new(backend).expect("backend"); - terminal.draw(|frame| tui.render(frame)).expect("draw"); - terminal.backend().to_string() - } - - fn screen_of(tui: &mut MainTui) -> String { - screen_sized(tui, 160, 40) - } - - fn mouse( - tui: &mut MainTui, - kind: ratatui::crossterm::event::MouseEventKind, - column: u16, - row: u16, - ) { - tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent { - kind, - column, - row, - modifiers: KeyModifiers::NONE, - }); - } - - fn mouse_drag_row(tui: &mut MainTui, from: (u16, u16), to: (u16, u16)) { - use ratatui::crossterm::event::{MouseButton, MouseEventKind}; - mouse(tui, MouseEventKind::Down(MouseButton::Left), from.0, from.1); - mouse(tui, MouseEventKind::Drag(MouseButton::Left), to.0, to.1); - mouse(tui, MouseEventKind::Up(MouseButton::Left), to.0, to.1); - } - - /// The screen row carrying a pane's legend, `[2] Results` say, without - /// the quotes the test backend wraps each row in. - fn row_with<'a>(screen: &'a str, needle: &str) -> &'a str { - let line = screen - .lines() - .find(|line| line.contains(needle)) - .unwrap_or_else(|| panic!("no row with {needle:?} in\n{screen}")); - let start = line.find('"').map_or(0, |i| i + 1); - let end = line.rfind('"').unwrap_or(line.len()); - &line[start..end] - } - - /// A window with a search, its results, and a transfer, so every pane - /// has something to show. - fn furnished_tui() -> MainTui { - let mut tui = results_tui(3); - tui.state.searches.push(crate::models::SearchEntry { - query: "aphex twin".to_string(), - status: SearchStatus::Completed, - results: results(3), - known_files: 3, - owned: true, - start_time: std::time::Instant::now(), - cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), - }); - tui.state.selected_search_index = Some(0); - tui.state.downloads.push(crate::models::DownloadEntry { - download: queued("bob", "@@x\\Music\\00.mp3"), - receiver: None, - }); - tui - } - - #[test] - fn the_results_pane_has_the_whole_width_to_itself() { - let mut tui = furnished_tui(); - let screen = screen_of(&mut tui); - - let results = row_with(&screen, "[2] Results"); - assert!(results.starts_with('╭'), "{results}"); - assert!(results.trim_end().ends_with('╮'), "{results}"); - assert!(!results.contains("[1]"), "nothing beside it: {results}"); - - // The other three share the row underneath, in this order. - let row = row_with(&screen, "[1] Searches"); - let searches = row.find("[1]").expect("searches"); - let downloads = row.find("[3]").expect("downloads"); - let info = row.find("[Info]").expect("info"); - assert!(searches < downloads && downloads < info, "{row}"); - } - - #[test] - fn tab_and_shift_tab_walk_the_panes_in_legend_order() { - let mut tui = furnished_tui(); - tui.state.focused_pane = FocusedPane::Searches; - press(&mut tui, KeyCode::Tab); - assert_eq!(tui.state.focused_pane, FocusedPane::Results); - press(&mut tui, KeyCode::Tab); - assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); - press(&mut tui, KeyCode::Tab); - assert_eq!(tui.state.focused_pane, FocusedPane::Searches); - press(&mut tui, KeyCode::BackTab); - assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); - } - - #[test] - fn w_hides_the_focused_pane_and_its_number_brings_it_back() { - let mut tui = furnished_tui(); - tui.state.focused_pane = FocusedPane::Searches; - - press(&mut tui, KeyCode::Char('w')); - - let screen = screen_of(&mut tui); - assert!(!screen.contains("[1] Searches"), "{screen}"); - assert!(screen.contains("[2] Results"), "{screen}"); - assert!(screen.contains("[3] Downloads"), "{screen}"); - assert_eq!(tui.state.focused_pane, FocusedPane::Results); - assert_eq!(tui.state.searches_pane_area, None, "nothing to click"); - // The transfers widen into the space. - let row = row_with(&screen, "[3] Downloads"); - assert!(row.starts_with('╭'), "{row}"); - - press(&mut tui, KeyCode::Char('1')); - - let screen = screen_of(&mut tui); - assert!(screen.contains("[1] Searches"), "{screen}"); - assert_eq!(tui.state.focused_pane, FocusedPane::Searches); - } - - #[test] - fn hiding_the_results_gives_the_row_the_whole_height() { - let mut tui = furnished_tui(); - press(&mut tui, KeyCode::Char('w')); - let screen = screen_of(&mut tui); - assert!(!screen.contains("[2] Results"), "{screen}"); - // The row now starts right under the status bar. - let status = screen - .lines() - .position(|line| line.contains("Status")) - .expect("status bar"); - let row = screen - .lines() - .position(|line| line.contains("[1] Searches")) - .expect("searches"); - assert_eq!(row, status + 3, "{screen}"); - } - - #[test] - fn the_last_pane_standing_cannot_be_hidden() { - // Results goes, then Downloads; Searches is what is left, and a - // third press leaves it there. - let mut tui = furnished_tui(); - press(&mut tui, KeyCode::Char('w')); - press(&mut tui, KeyCode::Char('w')); - press(&mut tui, KeyCode::Char('w')); - let screen = screen_of(&mut tui); - assert!(screen.contains("[1] Searches"), "{screen}"); - assert!(!screen.contains("[3] Downloads"), "{screen}"); - assert_eq!(tui.state.focused_pane, FocusedPane::Searches); - } - - #[test] - fn z_zooms_the_focused_pane_and_esc_or_z_leaves_it() { - let mut tui = furnished_tui(); - press(&mut tui, KeyCode::Char('z')); - - let screen = screen_of(&mut tui); - assert!(screen.contains("[2] Results"), "{screen}"); - assert!(!screen.contains("[1] Searches"), "{screen}"); - assert!(!screen.contains("[3] Downloads"), "{screen}"); - assert!(!screen.contains("[Info]"), "{screen}"); - assert!(screen.contains("[z → unzoom]"), "{screen}"); - - // Tab while zoomed switches which pane fills the window. - press(&mut tui, KeyCode::Tab); - let screen = screen_of(&mut tui); - assert!(screen.contains("[3] Downloads"), "{screen}"); - assert!(!screen.contains("[2] Results"), "{screen}"); - - press(&mut tui, KeyCode::Esc); - assert!(!tui.state.layout.zoomed); - let screen = screen_of(&mut tui); - assert!(screen.contains("[2] Results"), "{screen}"); - assert!(screen.contains("[Info]"), "{screen}"); - - press(&mut tui, KeyCode::Char('z')); - press(&mut tui, KeyCode::Char('z')); - assert!(!tui.state.layout.zoomed, "z toggles"); - } - - #[test] - fn a_search_brings_a_hidden_results_pane_back() { - let mut tui = furnished_tui(); - press(&mut tui, KeyCode::Char('w')); - assert!(!tui.state.layout.is_visible(FocusedPane::Results)); - - press(&mut tui, KeyCode::Char('s')); - press(&mut tui, KeyCode::Char('x')); - press(&mut tui, KeyCode::Enter); - - assert!(tui.state.layout.is_visible(FocusedPane::Results)); - assert_eq!(tui.state.focused_pane, FocusedPane::Results); - } - - #[test] - fn question_mark_opens_the_keys_list_and_closes_it_again() { - let mut tui = furnished_tui(); - press(&mut tui, KeyCode::Char('?')); - - let screen = screen_of(&mut tui); - assert!(screen.contains("Keys"), "{screen}"); - assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); - assert!(screen.contains("clear every finished one"), "{screen}"); - - // Other keys are swallowed while it is open. - press(&mut tui, KeyCode::Char('q')); - assert!(tui.state.exit.is_none(), "q closes the list, not the app"); - assert!(!tui.state.show_help); - - press(&mut tui, KeyCode::Char('?')); - press(&mut tui, KeyCode::Char('w')); - assert!(tui.state.show_help, "w did not reach the panes"); - assert!(tui.state.layout.is_visible(FocusedPane::Results)); - press(&mut tui, KeyCode::Esc); - assert!(!tui.state.show_help); - } - - #[test] - fn on_a_small_terminal_the_keys_list_stacks_and_scrolls() { - let mut tui = furnished_tui(); - press(&mut tui, KeyCode::Char('?')); - - let screen = screen_sized(&mut tui, 80, 24); - let panes = row_with(&screen, "Panes"); - assert!(!panes.contains("Searches"), "one column: {panes}"); - assert!(screen.contains("focus a pane, hidden or not"), "{screen}"); - assert!(!screen.contains("next room, chat or user"), "{screen}"); - - press(&mut tui, KeyCode::End); - let screen = screen_sized(&mut tui, 80, 24); - assert!(screen.contains("next room, chat or user"), "{screen}"); - assert!(!screen.contains("next / previous pane"), "{screen}"); - - press(&mut tui, KeyCode::Home); - let screen = screen_sized(&mut tui, 80, 24); - assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); - - // A wide window shows both columns at once, nothing to scroll. It - // has to be tall enough to hold the taller column outright; in a - // shorter one End follows the tail and the headings scroll off. - press(&mut tui, KeyCode::End); - let screen = screen_sized(&mut tui, 160, 44); - let panes = row_with(&screen, "Panes"); - assert!(panes.contains("Searches"), "two columns: {panes}"); - assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); - } - - #[test] - fn a_click_where_a_pane_just_hid_does_not_focus_it() { - let mut tui = furnished_tui(); - let _ = screen_of(&mut tui); - let searches = tui.state.searches_pane_area.expect("laid out"); - // Hidden, but not yet redrawn: the old area is still on record. - tui.state.focused_pane = FocusedPane::Searches; - press(&mut tui, KeyCode::Char('w')); - assert_eq!(tui.state.focused_pane, FocusedPane::Results); - tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent { - kind: ratatui::crossterm::event::MouseEventKind::Down( - ratatui::crossterm::event::MouseButton::Left, - ), - column: searches.x + 1, - row: searches.y + 1, - modifiers: KeyModifiers::NONE, - }); - assert_eq!(tui.state.focused_pane, FocusedPane::Results); - } - - #[test] - fn l_and_h_scroll_a_transfer_name_and_a_query_too() { - let mut tui = furnished_tui(); - tui.state.downloads[0].download.filename = "y".repeat(120); - tui.state.searches[0].query = "z".repeat(60); - let _ = screen_of(&mut tui); - - tui.state.focused_pane = FocusedPane::Downloads; - press(&mut tui, KeyCode::Char('l')); - assert_eq!(tui.state.downloads_name_offset, 8); - press(&mut tui, KeyCode::Char('$')); - let end = tui.state.downloads_name_offset; - assert!(end > 8, "the end is past a step: {end}"); - let screen = screen_of(&mut tui); - assert!(row_with(&screen, "…yyyy").contains("…yyyy"), "{screen}"); - press(&mut tui, KeyCode::Char('0')); - assert_eq!(tui.state.downloads_name_offset, 0); - - tui.state.focused_pane = FocusedPane::Searches; - press(&mut tui, KeyCode::Right); - assert_eq!(tui.state.searches_query_offset, 8); - press(&mut tui, KeyCode::End); - assert_eq!( - tui.state.searches_table_state.selected(), - Some(0), - "End is still a row key, $ is the name key" - ); - press(&mut tui, KeyCode::Char('$')); - assert!(tui.state.searches_query_offset > 8); - let screen = screen_of(&mut tui); - assert!(screen.contains("…zzzz"), "{screen}"); - assert!( - tui.state.results_name_offset == 0, - "each list scrolls on its own" - ); - } - - fn browsed(tui: &mut MainTui, files: usize) { - tui.state.browse.open("bob"); - let listing = vec![soulseek_rs::SharedDirectory { - name: "Music".to_string(), - files: (0..files) - .map(|i| soulseek_rs::SharedFileEntry { - name: format!("{i:02}.mp3"), - size: 1, - attributes: Vec::new(), - }) - .collect(), - }]; - tui.state - .browse - .active_tab_mut() - .expect("tab") - .load(&listing); - tui.state.show_browse = true; - let _ = screen_of(tui); - } - - #[test] - fn shift_b_shows_what_this_session_shares_and_r_re_indexes_it() { - let session = Arc::new(TalkativeSession::default()); - *session.listing.lock().expect("not poisoned") = - vec![soulseek_rs::SharedDirectory { - name: "Music\\Album".to_string(), - files: vec![soulseek_rs::SharedFileEntry { - name: "track.mp3".to_string(), - size: 4096, - attributes: Vec::new(), - }], - }]; - let mut tui = attach(session.clone()); - - press(&mut tui, KeyCode::Char('B')); - assert!(tui.state.show_browse, "the popup opens"); - let tab = tui.state.browse.active_tab().expect("tab"); - assert!(tab.own, "on this session's own index"); - assert_eq!(tab.username, "tester"); - assert_eq!(tab.file_count, 1); - let screen = screen_of(&mut tui); - assert!(screen.contains("My shares"), "{screen}"); - press(&mut tui, KeyCode::Char('L')); - let screen = screen_of(&mut tui); - assert!( - screen.contains("Album") && screen.contains("track.mp3"), - "the virtual path a peer would see, folder by folder: {screen}" - ); - - // Nothing here is downloadable from ourselves. - press(&mut tui, KeyCode::Char('d')); - assert!(tui.state.downloads.is_empty()); - - // `r` re-scans the disk rather than re-asking a peer, and the tab - // picks up what the fresh scan found. - session.listing.lock().expect("not poisoned").clear(); - press(&mut tui, KeyCode::Char('r')); - assert!( - session.shares_set.lock().expect("not poisoned").is_some(), - "the session was asked to re-index" - ); - assert_eq!( - tui.state.browse.active_tab().expect("tab").file_count, - 0, - "the view follows the new index" - ); - - // A second B focuses the tab it already opened. - press(&mut tui, KeyCode::Esc); - press(&mut tui, KeyCode::Char('B')); - assert_eq!(tui.state.browse.tabs.len(), 1); - } - - #[test] - fn o_leaves_the_share_view_for_the_folders_that_feed_it() { - let mut tui = attach(Arc::new(TalkativeSession::default())); - press(&mut tui, KeyCode::Char('B')); - press(&mut tui, KeyCode::Char('o')); - assert!(!tui.state.show_browse, "the popup steps aside"); - assert!(tui.state.settings.is_some(), "for the settings pane"); - } - - #[test] - fn page_keys_walk_the_browse_tree_and_ctrl_d_does_not_download() { - let session = Arc::new(TalkativeSession::default()); - let mut tui = attach(session); - browsed(&mut tui, 40); - let rows = tui.state.browse.active_tab().expect("tab").rows().len(); - assert_eq!(rows, 41, "the folder and its files"); - - press(&mut tui, KeyCode::End); - let selected = |tui: &MainTui| { - tui.state.browse.active_tab().expect("tab").selected_row - }; - assert_eq!(selected(&tui), 40); - press(&mut tui, KeyCode::Home); - assert_eq!(selected(&tui), 0); - press(&mut tui, KeyCode::PageDown); - let page = selected(&tui); - assert!(page > 1 && page < 40, "a page of rows: {page}"); - ctrl(&mut tui, 'd'); - assert!(selected(&tui) > page, "ctrl-d pages"); - assert!( - tui.state.downloads.is_empty() - && tui.state.downloads_receiver_channel.is_none(), - "and does not download the folder" - ); - press(&mut tui, KeyCode::Char('G')); - assert_eq!(selected(&tui), 40); - assert!(tui.state.show_browse, "still open"); - } - - #[test] - fn folder_keys_and_the_filter_move_through_a_browsed_tree() { - let mut tui = attach(Arc::new(TalkativeSession::default())); - tui.state.browse.open("bob"); - let listing = vec![ - soulseek_rs::SharedDirectory { - name: "music\\alpha".to_string(), - files: vec![ - soulseek_rs::SharedFileEntry { - name: "a1.mp3".to_string(), - size: 1, - attributes: Vec::new(), - }, - soulseek_rs::SharedFileEntry { - name: "a2.flac".to_string(), - size: 2, - attributes: Vec::new(), - }, - ], - }, - soulseek_rs::SharedDirectory { - name: "music\\beta".to_string(), - files: vec![soulseek_rs::SharedFileEntry { - name: "b1.mp3".to_string(), - size: 3, - attributes: Vec::new(), - }], - }, - ]; - tui.state - .browse - .active_tab_mut() - .expect("tab") - .load(&listing); - tui.state.show_browse = true; - let _ = screen_of(&mut tui); - let selected = |tui: &MainTui| { - tui.state.browse.active_tab().expect("tab").selected_row - }; - let rows = |tui: &MainTui| { - tui.state.browse.active_tab().expect("tab").rows().len() - }; - - press(&mut tui, KeyCode::Char('L')); - assert_eq!(rows(&tui), 6, "every folder open"); - press(&mut tui, KeyCode::Char('J')); - press(&mut tui, KeyCode::Char('J')); - assert_eq!(selected(&tui), 4, "beta, skipping alpha's files"); - press(&mut tui, KeyCode::Char('K')); - assert_eq!(selected(&tui), 1, "back to alpha"); - press(&mut tui, KeyCode::Char('H')); - assert_eq!(rows(&tui), 1, "just music"); - - press(&mut tui, KeyCode::Char('/')); - for c in "flac".chars() { - press(&mut tui, KeyCode::Char(c)); - } - assert_eq!(rows(&tui), 3, "music, alpha, a2.flac"); - let screen = screen_of(&mut tui); - assert!(screen.contains("filter: flac_"), "{screen}"); - ctrl(&mut tui, 'd'); - assert_eq!( - tui.state.browse.active_tab().expect("tab").filter(), - "flac", - "a control chord pages, it does not type" - ); - press(&mut tui, KeyCode::Enter); - press(&mut tui, KeyCode::Char('j')); - press(&mut tui, KeyCode::Char('j')); - assert_eq!(selected(&tui), 2, "j moves again once the filter is kept"); - press(&mut tui, KeyCode::Esc); - assert_eq!(rows(&tui), 1, "Esc clears the filter first"); - assert!(tui.state.show_browse); - press(&mut tui, KeyCode::Esc); - assert!(!tui.state.show_browse, "then hides the popup"); - } - - #[test] - fn slash_and_u_filter_a_rooms_log_and_its_members() { - let mut tui = attach(Arc::new(TalkativeSession::default())); - in_a_room(&mut tui, 30); - press(&mut tui, KeyCode::Char('u')); - for c in "user2".chars() { - press(&mut tui, KeyCode::Char(c)); - } - press(&mut tui, KeyCode::Enter); - press(&mut tui, KeyCode::Char('j')); - assert_eq!( - tui.state.rooms.selected_user().as_deref(), - Some("user21"), - "j moves within the matching members" - ); - let screen = screen_of(&mut tui); - assert!(screen.contains("Users 10/30"), "{screen}"); - - press(&mut tui, KeyCode::Char('/')); - for c in "line 01".chars() { - press(&mut tui, KeyCode::Char(c)); - } - let screen = screen_of(&mut tui); - assert!(screen.contains("line 010"), "{screen}"); - assert!(!screen.contains("line 029"), "{screen}"); - assert!(screen.contains("filter: line 01_"), "{screen}"); - ctrl(&mut tui, 'u'); - assert_eq!(tui.state.rooms.log_filter, "line 01", "^u scrolls"); - press(&mut tui, KeyCode::Enter); - press(&mut tui, KeyCode::Esc); - assert!(tui.state.rooms.log_filter.is_empty()); - assert!(tui.state.rooms.user_filter.is_empty()); - assert_eq!(tui.state.rooms.view, RoomsView::Chat, "still in the room"); - press(&mut tui, KeyCode::Esc); - assert_eq!(tui.state.rooms.view, RoomsView::List, "then the list"); - } - - #[test] - fn slash_filters_the_open_conversation() { - let mut tui = attach(Arc::new(TalkativeSession::default())); - for text in ["hello there", "see you", "hello again"] { - tui.state.messages.push(crate::models::ChatMessage { - direction: MessageDirection::Incoming, - peer: "bob".to_string(), - text: text.to_string(), - at: chrono::Local::now(), - }); - } - tui.state.chat_peer = Some("bob".to_string()); - tui.state.show_messages = true; - press(&mut tui, KeyCode::Char('/')); - for c in "hello".chars() { - press(&mut tui, KeyCode::Char(c)); - } - let screen = screen_of(&mut tui); - assert!(screen.contains("hello again"), "{screen}"); - assert!(!screen.contains("see you"), "{screen}"); - press(&mut tui, KeyCode::Enter); - press(&mut tui, KeyCode::Esc); - assert!(tui.state.chat_filter.is_empty() && tui.state.show_messages); - press(&mut tui, KeyCode::Esc); - assert!(!tui.state.show_messages); - } - - fn in_a_room(tui: &mut MainTui, messages: usize) { - tui.state.rooms.apply_event( - soulseek_rs::RoomEvent::Joined { - room: "jazz".to_string(), - users: (0..30).map(|i| format!("user{i:02}")).collect(), - }, - None, - ); - tui.state.rooms.focus_or_open("jazz"); - for i in 0..messages { - tui.state.rooms.apply_event( - soulseek_rs::RoomEvent::Message { - room: "jazz".to_string(), - username: "alice".to_string(), - message: format!("line {i:03}"), - }, - Some("jazz"), - ); - } - tui.state.show_rooms = true; - let _ = screen_of(tui); - } - - #[test] - fn page_up_scrolls_a_room_log_back_and_end_returns_to_the_newest() { - let mut tui = with_session(TalkativeSession::default()); - in_a_room(&mut tui, 120); - - let screen = screen_of(&mut tui); - assert!(screen.contains("line 119"), "tails by default: {screen}"); - assert!(!screen.contains("line 000"), "{screen}"); - - press(&mut tui, KeyCode::PageUp); - let screen = screen_of(&mut tui); - assert!(!screen.contains("line 119"), "scrolled back: {screen}"); - - press(&mut tui, KeyCode::Home); - let screen = screen_of(&mut tui); - assert!(screen.contains("line 000"), "the oldest: {screen}"); - press(&mut tui, KeyCode::PageUp); - let screen = screen_of(&mut tui); - assert!(screen.contains("line 000"), "clamped at the top: {screen}"); - - press(&mut tui, KeyCode::End); - let screen = screen_of(&mut tui); - assert!(screen.contains("line 119"), "{screen}"); - - // The member list keeps its own keys, and ctrl-x is not x. - press(&mut tui, KeyCode::Down); - assert_eq!(tui.state.rooms.user_selected, 1); - ctrl(&mut tui, 'x'); - assert_eq!(tui.state.rooms.open.len(), 1, "still in the room"); - } - - #[test] - fn page_keys_move_through_the_room_list() { - let mut tui = with_session(TalkativeSession::default()); - tui.state.rooms.apply_event( - soulseek_rs::RoomEvent::List( - (0..60) - .map(|i| soulseek_rs::types::RoomInfo { - name: format!("room{i:02}"), - user_count: 60 - i, - }) - .collect(), - ), - None, - ); - tui.state.show_rooms = true; - let _ = screen_of(&mut tui); - - press(&mut tui, KeyCode::End); - assert_eq!(tui.state.rooms.list_selected, 59); - press(&mut tui, KeyCode::PageUp); - assert!(tui.state.rooms.list_selected < 59); - press(&mut tui, KeyCode::Char('g')); - assert_eq!(tui.state.rooms.list_selected, 0); - ctrl(&mut tui, 'f'); - assert!(tui.state.rooms.list_selected > 1); - } - - #[test] - fn page_up_scrolls_a_conversation_and_switching_chats_resets_it() { - let mut tui = tui((0..80) - .map(|i| ChatMessageDto { - peer: "bob".into(), - outgoing: i % 2 == 0, - text: format!("msg {i:03}"), - at: i, - }) - .chain(std::iter::once(ChatMessageDto { - peer: "carol".into(), - outgoing: false, - text: "hello".into(), - at: 100, - })) - .collect()); - tui.state.chat_peer = Some("bob".to_string()); - tui.state.show_messages = true; - let screen = screen_of(&mut tui); - assert!(screen.contains("msg 079"), "{screen}"); - - press(&mut tui, KeyCode::PageUp); - let screen = screen_of(&mut tui); - assert!(!screen.contains("msg 079"), "scrolled back: {screen}"); - press(&mut tui, KeyCode::Char('g')); - let screen = screen_of(&mut tui); - assert!(screen.contains("msg 000"), "{screen}"); - - press(&mut tui, KeyCode::Tab); - assert_eq!(tui.state.active_chat_peer(), Some("carol")); - assert!( - tui.state.chat_view.following(), - "a fresh chat starts at its end" - ); - press(&mut tui, KeyCode::BackTab); - let screen = screen_of(&mut tui); - assert!(screen.contains("msg 079"), "{screen}"); - } - - #[test] - fn the_shortcut_bar_wraps_instead_of_cutting_keys_off() { - let mut tui = furnished_tui(); - let screen = screen_of(&mut tui); - assert!(screen.contains("[q → quit]"), "{screen}"); - let bar_rows = - screen.lines().filter(|line| line.contains(" → ")).count(); - assert_eq!(bar_rows, 2, "{screen}"); - - let wide = screen_sized(&mut tui, 300, 40); - let bar_rows = wide.lines().filter(|line| line.contains(" → ")).count(); - assert_eq!(bar_rows, 1, "{wide}"); - } - - #[test] - fn the_searches_pane_lists_its_clear_all_and_rerun_keys() { - let mut tui = furnished_tui(); - tui.state.focused_pane = FocusedPane::Searches; - let screen = screen_sized(&mut tui, 300, 40); - assert!(screen.contains("[C → clear all]"), "{screen}"); - assert!(screen.contains("[S → search again]"), "{screen}"); - press(&mut tui, KeyCode::Char('?')); - let screen = screen_of(&mut tui); - assert!(screen.contains("run the search again"), "{screen}"); - } - - #[test] - fn the_downloads_pane_lists_its_clear_everything_key() { - let mut tui = furnished_tui(); - tui.state.focused_pane = FocusedPane::Downloads; - let screen = screen_sized(&mut tui, 300, 40); - assert!(screen.contains("[c → clear finished]"), "{screen}"); - assert!(screen.contains("[C → clear all]"), "{screen}"); - press(&mut tui, KeyCode::Char('?')); - let screen = screen_of(&mut tui); - assert!(screen.contains("cancelling the live ones"), "{screen}"); - } - - #[test] - fn shift_w_brings_every_pane_back_and_leaves_zoom() { - let mut tui = furnished_tui(); - tui.state.focused_pane = FocusedPane::Searches; - assert!(!screen_sized(&mut tui, 300, 40).contains("[W → ")); - press(&mut tui, KeyCode::Char('w')); - press(&mut tui, KeyCode::Char('z')); - assert!(tui.state.layout.zoomed); - assert!(!tui.state.layout.is_visible(FocusedPane::Searches)); - let screen = screen_sized(&mut tui, 300, 40); - assert!(screen.contains("[W → reset layout]"), "{screen}"); - press(&mut tui, KeyCode::Char('W')); - assert!(!tui.state.layout.zoomed); - assert!( - FocusedPane::ALL - .iter() - .all(|p| tui.state.layout.is_visible(*p)) - ); - } - - #[test] - fn page_keys_work_in_the_transfers_and_searches_lists_too() { - let mut tui = furnished_tui(); - for i in 0..30 { - tui.state.downloads.push(crate::models::DownloadEntry { - download: queued("bob", &format!("{i}.mp3")), - receiver: None, - }); - } - tui.state.focused_pane = FocusedPane::Downloads; - tui.state.downloads_table_state.select(Some(0)); - let _ = screen_of(&mut tui); // lays the panes out, which sets the page size - - press(&mut tui, KeyCode::End); - assert_eq!(tui.state.downloads_table_state.selected(), Some(30)); - press(&mut tui, KeyCode::Home); - assert_eq!(tui.state.downloads_table_state.selected(), Some(0)); - press(&mut tui, KeyCode::PageDown); - let page = tui.state.downloads_table_state.selected().expect("row"); - assert!(page > 1 && page < 30, "a pane of rows, not one: {page}"); - ctrl(&mut tui, 'd'); - assert_eq!(tui.state.downloads.len(), 31, "ctrl-d pages, d deletes"); - assert!(tui.state.downloads_table_state.selected() > Some(page)); - - tui.state.focused_pane = FocusedPane::Searches; - press(&mut tui, KeyCode::Char('G')); - assert_eq!(tui.state.searches_table_state.selected(), Some(0)); - press(&mut tui, KeyCode::Char('g')); - assert_eq!(tui.state.searches_table_state.selected(), Some(0)); - assert_eq!(tui.state.searches.len(), 1, "g and G only move"); - } - - #[test] - fn clicking_where_a_hidden_pane_was_focuses_whatever_is_there_now() { - let mut tui = furnished_tui(); - let _ = screen_of(&mut tui); - let searches = tui.state.searches_pane_area.expect("laid out"); - let inside = ratatui::crossterm::event::MouseEvent { - kind: ratatui::crossterm::event::MouseEventKind::Down( - ratatui::crossterm::event::MouseButton::Left, - ), - column: searches.x + 1, - row: searches.y + 1, - modifiers: KeyModifiers::NONE, - }; - tui.handle_mouse_event(inside); - assert_eq!(tui.state.focused_pane, FocusedPane::Searches); - - press(&mut tui, KeyCode::Char('w')); - let _ = screen_of(&mut tui); - tui.handle_mouse_event(inside); - assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); - } - - #[test] - fn dragging_the_border_under_results_resizes_its_row() { - let mut tui = furnished_tui(); - let _ = screen_of(&mut tui); - let results = tui.state.results_pane_area.expect("laid out"); - let divider = tui.state.hsplit_divider.expect("a divider"); - let before = results.height; - let focused = tui.state.focused_pane; - - // Two drags: the row shrinks by the rows the pointer moved up. - mouse_drag_row( - &mut tui, - (results.x + 1, divider.y), - (results.x + 1, divider.y - 2), - ); - assert_eq!(tui.state.layout.top_height, Some(before - 2)); - assert_eq!( - tui.state.focused_pane, focused, - "a grab is not a focus click" - ); - - let _ = screen_of(&mut tui); - assert_eq!( - tui.state.results_pane_area.expect("laid out").height, - before - 2 - ); - - // A drag far past the row stops at the smallest pane, not zero. - mouse_drag_row( - &mut tui, - (results.x + 1, divider.y - 2), - (results.x + 1, 0), - ); - assert!(tui.state.layout.top_height.expect("resized") >= 3); - - press(&mut tui, KeyCode::Char('W')); - assert_eq!(tui.state.layout.top_height, None, "W resets the layout"); - } - - #[test] - fn dragging_a_row_panes_right_edge_gives_it_width() { - use ratatui::crossterm::event::{MouseButton, MouseEventKind}; - - let mut tui = furnished_tui(); - let _ = screen_of(&mut tui); - let searches = tui.state.searches_pane_area.expect("laid out"); - let seam = tui - .state - .vsplit_dividers - .iter() - .find(|(pane, _)| *pane == FocusedPane::Searches) - .map(|(_, rect)| *rect) - .expect("a seam beside Searches"); - tui.state.focused_pane = FocusedPane::Downloads; - - mouse_drag_row( - &mut tui, - (seam.x, searches.y + 1), - (seam.x + 4, searches.y + 1), - ); - assert_eq!(tui.state.layout.searches_width, Some(searches.width + 4)); - assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); - - // The widths are applied, and Info narrows to whatever is left. - let _ = screen_of(&mut tui); - assert_eq!( - tui.state.searches_pane_area.expect("laid out").width, - searches.width + 4 - ); - assert!(tui.state.info_pane_area.expect("laid out").width >= 3); - - // Releasing the button ends the drag: later motions just move the - // pointer. - mouse( - &mut tui, - MouseEventKind::Drag(MouseButton::Left), - seam.x + 40, - searches.y + 1, - ); - assert_eq!(tui.state.layout.searches_width, Some(searches.width + 4)); - } - - #[test] - fn the_info_pane_describes_the_highlighted_result() { - let mut tui = results_tui(3); - tui.state.results_items[1].filename = - "@@x\\Music\\Album\\Second Track.mp3".to_string(); - tui.state.results_items[1].bitrate = Some(320); - tui.state.results_table_state.select(Some(1)); - - let screen = screen_of(&mut tui); - assert!(screen.contains("@@x/Music/Album"), "{screen}"); - assert!(screen.contains("Bitrate"), "{screen}"); - - tui.state.focused_pane = FocusedPane::Downloads; - let screen = screen_of(&mut tui); - assert!(!screen.contains("@@x/Music/Album"), "{screen}"); - } - - /// The queries the window has sent so far, once at least `count` of them - /// are out: a search goes to the session from a worker thread, so a test - /// has to give it a moment. - fn searched(session: &TalkativeSession, count: usize) -> Vec { - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - let seen = session.searched.lock().expect("not poisoned").clone(); - if seen.len() >= count || std::time::Instant::now() > deadline { - return seen; - } - std::thread::sleep(Duration::from_millis(10)); - } - } - - /// A window over last session's search list: the queries came back from - /// disk, with nothing under them. - fn restored(session: Arc, queries: &[&str]) -> MainTui { - let mut tui = attach(session); - let queries: Vec = - queries.iter().map(ToString::to_string).collect(); - restore_searches(&mut tui.state, &queries); - tui.state.focused_pane = FocusedPane::Searches; - tui - } - - #[test] - fn shift_s_runs_the_highlighted_search_again_in_its_place() { - // The list survives a restart, but a query on it was only ever a - // reminder: seeing what the network has now meant typing it again. - let session = Arc::new(TalkativeSession::default()); - let mut tui = - restored(session.clone(), &["boards of canada", "aphex twin"]); - tui.state.searches_table_state.select(Some(1)); - - press(&mut tui, KeyCode::Char('S')); - - assert_eq!( - searched(&session, 1), - vec!["aphex twin".to_string()], - "the highlighted query went out again, untyped" - ); - let queries: Vec<&str> = tui - .state - .searches - .iter() - .map(|s| s.query.as_str()) - .collect(); - assert_eq!( - queries, - ["boards of canada", "aphex twin"], - "it keeps its row: no duplicate, nothing moved" - ); - assert_eq!(tui.state.searches[1].status, SearchStatus::Active); - assert_eq!(tui.state.selected_search_index, Some(1)); - assert_eq!( - tui.state.focused_pane, - FocusedPane::Results, - "and the window turns to where the results will land" - ); - assert!(tui.state.results_items.is_empty()); - } - - #[test] - fn shift_s_stops_a_run_still_collecting_before_starting_over() { - let mut tui = furnished_tui(); - tui.state.searches[0].status = SearchStatus::Active; - tui.state.searches[0].start_time = std::time::Instant::now() - .checked_sub(Duration::from_secs(2)) - .expect("two seconds ago"); - let previous = tui.state.searches[0].cancel_flag.clone(); - tui.state.focused_pane = FocusedPane::Searches; - tui.state.searches_table_state.select(Some(0)); - - press(&mut tui, KeyCode::Char('S')); - - assert!( - previous.load(std::sync::atomic::Ordering::Relaxed), - "the worker that was collecting is told to stop" - ); - let fresh = &tui.state.searches[0]; - assert!(!fresh.cancel_flag.load(std::sync::atomic::Ordering::Relaxed)); - assert!(fresh.results.is_empty(), "stale rows do not linger"); - assert_eq!(tui.state.searches.len(), 1); - } - - #[test] - fn shift_s_is_only_a_key_over_the_searches_list() { - let session = Arc::new(TalkativeSession::default()); - let mut tui = restored(session.clone(), &["aphex twin"]); - - // Over Results the same key means nothing, so a stray shift does not - // throw a finished search away. - tui.state.focused_pane = FocusedPane::Results; - press(&mut tui, KeyCode::Char('S')); - assert_eq!(tui.state.searches[0].status, SearchStatus::Completed); - std::thread::sleep(Duration::from_millis(50)); - assert!(searched(&session, 0).is_empty(), "nothing went out"); - } - - #[test] - fn a_held_shift_s_is_one_search_not_one_per_repeat() { - let session = Arc::new(TalkativeSession::default()); - let mut tui = restored(session.clone(), &["aphex twin"]); - press(&mut tui, KeyCode::Char('S')); - press(&mut tui, KeyCode::Char('1')); - press(&mut tui, KeyCode::Char('S')); - assert_eq!(searched(&session, 1).len(), 1); - std::thread::sleep(Duration::from_millis(50)); - assert_eq!(searched(&session, 1).len(), 1, "the repeat is ignored"); - } -} +mod tests; diff --git a/soulseek-rs/src/ui/main_tui/render/mod.rs b/soulseek-rs/src/ui/main_tui/render/mod.rs new file mode 100644 index 0000000..bf16054 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/render/mod.rs @@ -0,0 +1,509 @@ +mod shortcuts; + +use super::MainTui; +use crate::models::{CommandBarMode, FocusedPane, PaneLayout, RoomsView}; +use crate::ui::panes::{ + InfoSubject, ResultsPaneParams, render_browse_pane, render_chat_pane, + render_download_info_pane, render_downloads_pane, render_results_pane, + render_rooms_pane, render_searches_pane, selected_transfer, +}; +use crate::ui::{ + GLYPH_CURSOR, accent_style, body_style, dimmed_style, info_style, mask, + pack_shortcuts, pane_block, plain_title, primary_style, + render_download_stats, warning_style, +}; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Layout, Position, Rect}, + style::Modifier, + text::{Line, Span}, + widgets::Paragraph, +}; +impl MainTui { + /// Draw the whole window. + pub fn render(&mut self, frame: &mut Frame) { + let shortcuts = self.shortcut_rows(frame.area().width); + let mut constraints = vec![ + Constraint::Length(3), // Status bar + Constraint::Fill(1), // Main content + ]; + if self.state.command_bar_active { + constraints.push(Constraint::Length(3)); // Command bar + } + constraints.push(Constraint::Length(shortcuts.len() as u16 + 2)); + let main_chunks = Layout::vertical(constraints).split(frame.area()); + + let daemon = self.client.daemon_endpoint(); + render_download_stats( + frame, + main_chunks[0], + &self.state.downloads, + self.state.active_downloads_count, + daemon.as_deref(), + ); + + self.render_content(frame, main_chunks[1]); + + // Render command bar if active (vim-style, above shortcuts) + if self.state.command_bar_active { + self.render_command_bar(frame, main_chunks[2]); + self.render_shortcuts(frame, main_chunks[3], shortcuts); + } else { + self.render_shortcuts(frame, main_chunks[2], shortcuts); + } + + self.render_overlays(frame); + } + + /// Results across the top, where a long file name has the whole width; + /// Searches, Downloads and Info in a row underneath. A hidden pane drops + /// out of its row and the rest widen; a zoomed one has the area alone. + fn render_content(&mut self, frame: &mut Frame, area: Rect) { + // Only what is drawn can be clicked. + self.state.searches_pane_area = None; + self.state.results_pane_area = None; + self.state.downloads_pane_area = None; + self.state.info_pane_area = None; + self.state.hsplit_divider = None; + self.state.vsplit_dividers.clear(); + self.state.row_area = None; + + if self.state.layout.zoomed { + self.render_pane(frame, area, self.state.focused_pane); + return; + } + self.state.content_area = Some(area); + + // Transfers carry the most columns, so they get the most width. + let row_panes: Vec<(FocusedPane, u16)> = + [(FocusedPane::Searches, 5), (FocusedPane::Downloads, 10)] + .into_iter() + .filter(|(pane, _)| self.state.layout.is_visible(*pane)) + .collect(); + + let row_area = if self.state.layout.is_visible(FocusedPane::Results) { + // With only Info left in the row, the results deserve more of + // the height. + let row_weight = if row_panes.is_empty() { 1 } else { 2 }; + let top = match self.state.layout.top_height { + Some(rows) => Constraint::Length(shrink(rows, area.height)), + None => Constraint::Fill(3), + }; + let [top, bottom] = + Layout::vertical([top, Constraint::Fill(row_weight)]) + .areas(area); + // The two touching border rows between the halves; either can + // be dragged. + self.state.hsplit_divider = Some(Rect::new( + area.x, + top.y.saturating_add(top.height).saturating_sub(1), + area.width, + 2, + )); + self.render_pane(frame, top, FocusedPane::Results); + bottom + } else { + area + }; + self.state.row_area = Some(row_area); + + let mut constraints: Vec = row_panes + .iter() + .map(|(pane, weight)| { + let dragged = match *pane { + FocusedPane::Searches => self.state.layout.searches_width, + FocusedPane::Downloads => self.state.layout.downloads_width, + FocusedPane::Results => None, + }; + match dragged { + Some(cols) => { + Constraint::Length(shrink(cols, row_area.width)) + } + None => Constraint::Fill(*weight), + } + }) + .collect(); + constraints.push(Constraint::Fill(6)); // Info + let chunks = Layout::horizontal(constraints).split(row_area); + for (chunk, (pane, _)) in chunks.iter().zip(&row_panes) { + // The seam at this pane's right edge: its border column and the + // next pane's, both draggable. + self.state.vsplit_dividers.push(( + *pane, + Rect::new( + chunk.x.saturating_add(chunk.width).saturating_sub(1), + row_area.y, + 2, + row_area.height, + ), + )); + self.render_pane(frame, *chunk, *pane); + } + self.state.info_pane_area = Some(chunks[row_panes.len()]); + self.render_info_pane(frame, chunks[row_panes.len()]); + } + + fn render_pane( + &mut self, + frame: &mut Frame, + area: Rect, + pane: FocusedPane, + ) { + let focused = self.state.focused_pane == pane; + match pane { + FocusedPane::Searches => { + self.state.searches_pane_area = Some(area); + render_searches_pane( + frame, + area, + &self.state.searches, + &mut self.state.searches_table_state, + focused, + self.state.searches_query_offset, + ); + } + FocusedPane::Results => { + self.state.results_pane_area = Some(area); + self.render_results_pane(frame, area, focused); + } + FocusedPane::Downloads => { + self.state.downloads_pane_area = Some(area); + render_downloads_pane( + frame, + area, + &self.state.downloads, + &self.state.uploads, + &mut self.state.downloads_table_state, + focused, + self.state.downloads_name_offset, + ); + } + } + } + + fn render_results_pane( + &mut self, + frame: &mut Frame, + area: Rect, + focused: bool, + ) { + // When a filter is active the rendered rows are a subset, so the pane + // also needs the mapping back to unfiltered indices to render the + // selection checkboxes correctly. + let (results_items, results_original_indices) = + if self.state.results_filter_query.is_empty() { + (&self.state.results_items, None) + } else { + ( + &self.state.results_filtered_items, + Some(self.state.results_filtered_indices.as_slice()), + ) + }; + + let active_search_query = self + .state + .selected_search_index + .and_then(|idx| self.state.searches.get(idx)) + .map(|search| search.query.as_str()); + + render_results_pane( + frame, + area, + ResultsPaneParams { + items: results_items, + table_state: &mut self.state.results_table_state, + selected_indices: &self.state.results_selected_indices, + original_indices: results_original_indices, + filter_query: &self.state.results_filter_query, + is_filtering: self.state.results_is_filtering, + focused, + active_search_query, + name_offset: self.state.results_name_offset, + }, + ); + } + + /// The Info pane follows the focus: the highlighted result, or the + /// highlighted transfer. + fn render_info_pane(&self, frame: &mut Frame, area: Rect) { + let selected = if self.state.focused_pane == FocusedPane::Results { + self.highlighted_result().map(InfoSubject::Result) + } else { + selected_transfer( + self.state.downloads_table_state.selected(), + &self.state.downloads, + &self.state.uploads, + ) + }; + render_download_info_pane( + frame, + area, + selected, + matches!( + self.state.focused_pane, + FocusedPane::Results | FocusedPane::Downloads + ), + ); + } + fn render_overlays(&mut self, frame: &mut Frame) { + // Messages inbox overlays everything when open. + if self.state.show_messages { + self.render_messages_popup(frame); + } + + // Browse tree overlays everything when open. + if self.state.show_browse && !self.state.browse.is_empty() { + let area = centered_rect(80, 80, frame.area()); + self.state.popup_area = Some(area); + frame.render_widget(ratatui::widgets::Clear, area); + render_browse_pane( + frame, + area, + &self.state.browse, + &mut self.state.browse_table_state, + self.spinner_state, + &self.state.downloads, + ); + } + + // Settings popup overlays everything when open. + if self.state.settings.is_some() { + self.render_settings_popup(frame); + } + + // Chat rooms overlay everything when open. + if self.state.show_rooms { + let area = centered_rect(85, 80, frame.area()); + self.state.popup_area = Some(area); + frame.render_widget(ratatui::widgets::Clear, area); + render_rooms_pane( + frame, + area, + &mut self.state.rooms, + &mut self.state.rooms_list_table_state, + ); + } + + // The keys list only opens from the main view, so it is on top of + // nothing; drawn last so that stays true if that ever changes. + if self.state.show_help { + self.render_help_popup(frame); + } + } + + fn render_settings_popup(&self, frame: &mut Frame) { + use crate::models::{SettingsMode, SettingsRow}; + let Some(settings) = self.state.settings.as_ref() else { + return; + }; + let area = centered_rect(70, 60, frame.area()); + frame.render_widget(ratatui::widgets::Clear, area); + + let selected = settings.selected_row(); + let mut lines: Vec = Vec::new(); + let entry = |on: bool, label: &str, value: String| { + Line::from(vec![ + Span::styled(if on { "> " } else { " " }, accent_style()), + Span::styled(label.to_string(), dimmed_style()), + Span::styled(value, primary_style()), + ]) + }; + let typing = |label: &str, value: String| { + Line::from(vec![ + Span::styled("> ", accent_style()), + Span::styled(label.to_string(), dimmed_style()), + Span::styled(value, primary_style()), + Span::styled(GLYPH_CURSOR, accent_style()), + ]) + }; + let section = |name: &str| Line::styled(name.to_string(), info_style()); + + // One column of labels, padded here rather than by hand in each + // string. + let field = |label: &str, value: String| { + entry(false, &format!(" {label:<14}"), value) + }; + let account = &settings.account; + lines.push(section("Account")); + lines.push(field("Signed in as", account.username.clone())); + lines.push(field("Sharing", account.shares.clone())); + lines.push(field( + "Reachable on", + account.listen_port.map_or_else( + || "not accepting connections".to_string(), + |port| format!("port {port}"), + ), + )); + if let Some(daemon) = &account.daemon { + lines.push(field("Daemon", daemon.clone())); + } + lines.push(Line::from("")); + + lines.push(match settings.mode { + SettingsMode::NewPassword => { + typing("New password: ", mask(&settings.input)) + } + SettingsMode::RepeatPassword(_) => { + typing("Repeat it: ", mask(&settings.input)) + } + _ => entry( + selected == SettingsRow::ChangePassword, + "Change password…", + String::new(), + ), + }); + if settings.mode == SettingsMode::ConfirmingLogout { + lines.push(entry( + true, + &format!("Log out of {}? ", account.username), + "y / n".to_string(), + )); + } else if settings.can_log_out() { + lines.push(entry( + selected == SettingsRow::Logout, + "Log out…", + String::new(), + )); + } + lines.push(Line::from("")); + + lines.push(section("Folders")); + lines.push(if settings.mode == SettingsMode::EditingDownloadDir { + typing("Download folder: ", settings.input.clone()) + } else { + entry( + selected == SettingsRow::DownloadDir, + "Download folder: ", + settings.download_dir.clone(), + ) + }); + lines.push(Line::styled( + format!("Shared folders ({}):", settings.share_dirs.len()), + dimmed_style(), + )); + for (i, dir) in settings.share_dirs.iter().enumerate() { + lines.push(entry( + selected == SettingsRow::Share(i), + "", + dir.clone(), + )); + } + if settings.share_dirs.is_empty() { + lines.push(Line::styled( + " (nothing shared — press 'a' to add a folder)", + dimmed_style(), + )); + } + if settings.mode == SettingsMode::AddingShare { + lines.push(typing(" Add share: ", settings.input.clone())); + } + if let Some(status) = &settings.status { + lines.push(Line::from("")); + lines.push(Line::styled(status.clone(), warning_style())); + } + + let block = pane_block(true).title(plain_title("Settings", true)); + frame.render_widget( + ratatui::widgets::Paragraph::new(lines) + .block(block) + .wrap(ratatui::widgets::Wrap { trim: false }), + area, + ); + } + + fn render_messages_popup(&mut self, frame: &mut Frame) { + // The per-conversation chat box supersedes the old flat message list; + // it renders the messages and the compose line itself. + let area = centered_rect(70, 60, frame.area()); + frame.render_widget(ratatui::widgets::Clear, area); + self.state.popup_area = Some(area); + let own = self.client.username(); + render_chat_pane(frame, area, &mut self.state, &own); + } + fn render_command_bar(&self, frame: &mut Frame, area: Rect) { + let prefix = match self.state.command_bar_mode { + CommandBarMode::Search => "search: ", + CommandBarMode::Message => "message (to: recipient text): ", + CommandBarMode::Browse => "browse user: ", + }; + let block = pane_block(true); + // Derive from the block's inner rect so borders and padding stay in + // one place; the cursor follows whatever the pane reserves. + let inner = block.inner(area); + let prefix_width = prefix.chars().count() as u16; + let input_width = inner.width.saturating_sub(prefix_width); + let (visible_input, cursor_column) = visible_input_at_cursor( + &self.state.command_bar_input, + self.state.command_bar_cursor_position, + input_width, + ); + let command_line = Line::from(vec![ + Span::styled(prefix, accent_style()), + Span::styled(visible_input, primary_style()), + ]); + + frame.render_widget(Paragraph::new(command_line).block(block), area); + + if inner.width > 0 && inner.height > 0 { + let cursor_x = inner + .x + .saturating_add(prefix_width) + .saturating_add(cursor_column) + .min(inner.x.saturating_add(inner.width.saturating_sub(1))); + frame.set_cursor_position(Position::new(cursor_x, inner.y)); + } + } +} + +/// A dragged pane size kept inside the space the terminal now offers: never +/// below [`MIN_PANE`], and never so big that what shares the split with it +/// drops below `MIN_PANE` too. +pub(super) fn shrink(dragged: u16, total: u16) -> u16 { + let max = total.saturating_sub(MIN_PANE).max(MIN_PANE); + dragged.max(MIN_PANE).min(max) +} + +/// The smallest pane a drag can leave anyone: a border and a row or column +/// of content each side. +pub(super) const MIN_PANE: u16 = 3; +/// A `Rect` centered within `area`, sized to the given percentages. +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let vertical = Layout::vertical([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + Layout::horizontal([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1])[1] +} +fn visible_input_at_cursor( + input: &str, + cursor_position: usize, + width: u16, +) -> (String, u16) { + if width == 0 { + return (String::new(), 0); + } + + let cursor_position = input.floor_char_boundary(cursor_position); + let cursor_character_index = input[..cursor_position].chars().count(); + let max_cursor_column = usize::from(width.saturating_sub(1)); + let start_character_index = + cursor_character_index.saturating_sub(max_cursor_column); + + let visible_input = input + .chars() + .skip(start_character_index) + .take(usize::from(width)) + .collect(); + let cursor_column = cursor_character_index + .saturating_sub(start_character_index) + .min(max_cursor_column); + + (visible_input, cursor_column as u16) +} diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render/shortcuts.rs similarity index 52% rename from soulseek-rs/src/ui/main_tui/render.rs rename to soulseek-rs/src/ui/main_tui/render/shortcuts.rs index a397e4b..3c96557 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render/shortcuts.rs @@ -1,21 +1,15 @@ -use super::MainTui; -use crate::models::{CommandBarMode, FocusedPane, PaneLayout, RoomsView}; -use crate::ui::panes::{ - InfoSubject, ResultsPaneParams, render_browse_pane, render_chat_pane, - render_download_info_pane, render_downloads_pane, render_results_pane, - render_rooms_pane, render_searches_pane, selected_transfer, -}; -use crate::ui::{ - GLYPH_CURSOR, accent_style, body_style, dimmed_style, info_style, mask, - pack_shortcuts, pane_block, plain_title, primary_style, - render_download_stats, warning_style, -}; -use ratatui::{ - Frame, - layout::{Alignment, Constraint, Layout, Position, Rect}, - style::Modifier, - text::{Line, Span}, - widgets::Paragraph, +//! The keys the window advertises: the shortcut bar along the foot, the `?` +//! overlay behind it, and the unread badge that rides in the bar. +//! +//! A child module of `render`, so it still reaches the window's state and +//! `render`'s own helpers directly; only what `render` calls back into is +//! widened to `pub(super)`. + +use super::{ + Alignment, CommandBarMode, Constraint, FocusedPane, Frame, Layout, Line, + MainTui, Modifier, PaneLayout, Paragraph, Rect, RoomsView, Span, + accent_style, body_style, dimmed_style, info_style, pack_shortcuts, + pane_block, plain_title, warning_style, }; /// The keys overlay, grouped by where a key applies. Two columns, because a @@ -182,236 +176,10 @@ fn settings_shortcuts( } impl MainTui { - /// Draw the whole window. - pub fn render(&mut self, frame: &mut Frame) { - let shortcuts = self.shortcut_rows(frame.area().width); - let mut constraints = vec![ - Constraint::Length(3), // Status bar - Constraint::Fill(1), // Main content - ]; - if self.state.command_bar_active { - constraints.push(Constraint::Length(3)); // Command bar - } - constraints.push(Constraint::Length(shortcuts.len() as u16 + 2)); - let main_chunks = Layout::vertical(constraints).split(frame.area()); - - let daemon = self.client.daemon_endpoint(); - render_download_stats( - frame, - main_chunks[0], - &self.state.downloads, - self.state.active_downloads_count, - daemon.as_deref(), - ); - - self.render_content(frame, main_chunks[1]); - - // Render command bar if active (vim-style, above shortcuts) - if self.state.command_bar_active { - self.render_command_bar(frame, main_chunks[2]); - self.render_shortcuts(frame, main_chunks[3], shortcuts); - } else { - self.render_shortcuts(frame, main_chunks[2], shortcuts); - } - - self.render_overlays(frame); - } - - /// Results across the top, where a long file name has the whole width; - /// Searches, Downloads and Info in a row underneath. A hidden pane drops - /// out of its row and the rest widen; a zoomed one has the area alone. - fn render_content(&mut self, frame: &mut Frame, area: Rect) { - // Only what is drawn can be clicked. - self.state.searches_pane_area = None; - self.state.results_pane_area = None; - self.state.downloads_pane_area = None; - self.state.info_pane_area = None; - self.state.hsplit_divider = None; - self.state.vsplit_dividers.clear(); - self.state.row_area = None; - - if self.state.layout.zoomed { - self.render_pane(frame, area, self.state.focused_pane); - return; - } - self.state.content_area = Some(area); - - // Transfers carry the most columns, so they get the most width. - let row_panes: Vec<(FocusedPane, u16)> = - [(FocusedPane::Searches, 5), (FocusedPane::Downloads, 10)] - .into_iter() - .filter(|(pane, _)| self.state.layout.is_visible(*pane)) - .collect(); - - let row_area = if self.state.layout.is_visible(FocusedPane::Results) { - // With only Info left in the row, the results deserve more of - // the height. - let row_weight = if row_panes.is_empty() { 1 } else { 2 }; - let top = match self.state.layout.top_height { - Some(rows) => Constraint::Length(shrink(rows, area.height)), - None => Constraint::Fill(3), - }; - let [top, bottom] = - Layout::vertical([top, Constraint::Fill(row_weight)]) - .areas(area); - // The two touching border rows between the halves; either can - // be dragged. - self.state.hsplit_divider = Some(Rect::new( - area.x, - top.y.saturating_add(top.height).saturating_sub(1), - area.width, - 2, - )); - self.render_pane(frame, top, FocusedPane::Results); - bottom - } else { - area - }; - self.state.row_area = Some(row_area); - - let mut constraints: Vec = row_panes - .iter() - .map(|(pane, weight)| { - let dragged = match *pane { - FocusedPane::Searches => self.state.layout.searches_width, - FocusedPane::Downloads => self.state.layout.downloads_width, - FocusedPane::Results => None, - }; - match dragged { - Some(cols) => { - Constraint::Length(shrink(cols, row_area.width)) - } - None => Constraint::Fill(*weight), - } - }) - .collect(); - constraints.push(Constraint::Fill(6)); // Info - let chunks = Layout::horizontal(constraints).split(row_area); - for (chunk, (pane, _)) in chunks.iter().zip(&row_panes) { - // The seam at this pane's right edge: its border column and the - // next pane's, both draggable. - self.state.vsplit_dividers.push(( - *pane, - Rect::new( - chunk.x.saturating_add(chunk.width).saturating_sub(1), - row_area.y, - 2, - row_area.height, - ), - )); - self.render_pane(frame, *chunk, *pane); - } - self.state.info_pane_area = Some(chunks[row_panes.len()]); - self.render_info_pane(frame, chunks[row_panes.len()]); - } - - fn render_pane( - &mut self, - frame: &mut Frame, - area: Rect, - pane: FocusedPane, - ) { - let focused = self.state.focused_pane == pane; - match pane { - FocusedPane::Searches => { - self.state.searches_pane_area = Some(area); - render_searches_pane( - frame, - area, - &self.state.searches, - &mut self.state.searches_table_state, - focused, - self.state.searches_query_offset, - ); - } - FocusedPane::Results => { - self.state.results_pane_area = Some(area); - self.render_results_pane(frame, area, focused); - } - FocusedPane::Downloads => { - self.state.downloads_pane_area = Some(area); - render_downloads_pane( - frame, - area, - &self.state.downloads, - &self.state.uploads, - &mut self.state.downloads_table_state, - focused, - self.state.downloads_name_offset, - ); - } - } - } - - fn render_results_pane( - &mut self, - frame: &mut Frame, - area: Rect, - focused: bool, - ) { - // When a filter is active the rendered rows are a subset, so the pane - // also needs the mapping back to unfiltered indices to render the - // selection checkboxes correctly. - let (results_items, results_original_indices) = - if self.state.results_filter_query.is_empty() { - (&self.state.results_items, None) - } else { - ( - &self.state.results_filtered_items, - Some(self.state.results_filtered_indices.as_slice()), - ) - }; - - let active_search_query = self - .state - .selected_search_index - .and_then(|idx| self.state.searches.get(idx)) - .map(|search| search.query.as_str()); - - render_results_pane( - frame, - area, - ResultsPaneParams { - items: results_items, - table_state: &mut self.state.results_table_state, - selected_indices: &self.state.results_selected_indices, - original_indices: results_original_indices, - filter_query: &self.state.results_filter_query, - is_filtering: self.state.results_is_filtering, - focused, - active_search_query, - name_offset: self.state.results_name_offset, - }, - ); - } - - /// The Info pane follows the focus: the highlighted result, or the - /// highlighted transfer. - fn render_info_pane(&self, frame: &mut Frame, area: Rect) { - let selected = if self.state.focused_pane == FocusedPane::Results { - self.highlighted_result().map(InfoSubject::Result) - } else { - selected_transfer( - self.state.downloads_table_state.selected(), - &self.state.downloads, - &self.state.uploads, - ) - }; - render_download_info_pane( - frame, - area, - selected, - matches!( - self.state.focused_pane, - FocusedPane::Results | FocusedPane::Downloads - ), - ); - } - /// The keys list, two columns side by side where the terminal is wide /// enough and one on top of the other where it is not, scrolling when /// even that is taller than the window. - fn render_help_popup(&mut self, frame: &mut Frame) { + pub(super) fn render_help_popup(&mut self, frame: &mut Frame) { let screen = frame.area(); // Borders and padding on either side of the text. let frame_width = 4; @@ -455,182 +223,6 @@ impl MainTui { } } - fn render_overlays(&mut self, frame: &mut Frame) { - // Messages inbox overlays everything when open. - if self.state.show_messages { - self.render_messages_popup(frame); - } - - // Browse tree overlays everything when open. - if self.state.show_browse && !self.state.browse.is_empty() { - let area = centered_rect(80, 80, frame.area()); - self.state.popup_area = Some(area); - frame.render_widget(ratatui::widgets::Clear, area); - render_browse_pane( - frame, - area, - &self.state.browse, - &mut self.state.browse_table_state, - self.spinner_state, - &self.state.downloads, - ); - } - - // Settings popup overlays everything when open. - if self.state.settings.is_some() { - self.render_settings_popup(frame); - } - - // Chat rooms overlay everything when open. - if self.state.show_rooms { - let area = centered_rect(85, 80, frame.area()); - self.state.popup_area = Some(area); - frame.render_widget(ratatui::widgets::Clear, area); - render_rooms_pane( - frame, - area, - &mut self.state.rooms, - &mut self.state.rooms_list_table_state, - ); - } - - // The keys list only opens from the main view, so it is on top of - // nothing; drawn last so that stays true if that ever changes. - if self.state.show_help { - self.render_help_popup(frame); - } - } - - fn render_settings_popup(&self, frame: &mut Frame) { - use crate::models::{SettingsMode, SettingsRow}; - let Some(settings) = self.state.settings.as_ref() else { - return; - }; - let area = centered_rect(70, 60, frame.area()); - frame.render_widget(ratatui::widgets::Clear, area); - - let selected = settings.selected_row(); - let mut lines: Vec = Vec::new(); - let entry = |on: bool, label: &str, value: String| { - Line::from(vec![ - Span::styled(if on { "> " } else { " " }, accent_style()), - Span::styled(label.to_string(), dimmed_style()), - Span::styled(value, primary_style()), - ]) - }; - let typing = |label: &str, value: String| { - Line::from(vec![ - Span::styled("> ", accent_style()), - Span::styled(label.to_string(), dimmed_style()), - Span::styled(value, primary_style()), - Span::styled(GLYPH_CURSOR, accent_style()), - ]) - }; - let section = |name: &str| Line::styled(name.to_string(), info_style()); - - // One column of labels, padded here rather than by hand in each - // string. - let field = |label: &str, value: String| { - entry(false, &format!(" {label:<14}"), value) - }; - let account = &settings.account; - lines.push(section("Account")); - lines.push(field("Signed in as", account.username.clone())); - lines.push(field("Sharing", account.shares.clone())); - lines.push(field( - "Reachable on", - account.listen_port.map_or_else( - || "not accepting connections".to_string(), - |port| format!("port {port}"), - ), - )); - if let Some(daemon) = &account.daemon { - lines.push(field("Daemon", daemon.clone())); - } - lines.push(Line::from("")); - - lines.push(match settings.mode { - SettingsMode::NewPassword => { - typing("New password: ", mask(&settings.input)) - } - SettingsMode::RepeatPassword(_) => { - typing("Repeat it: ", mask(&settings.input)) - } - _ => entry( - selected == SettingsRow::ChangePassword, - "Change password…", - String::new(), - ), - }); - if settings.mode == SettingsMode::ConfirmingLogout { - lines.push(entry( - true, - &format!("Log out of {}? ", account.username), - "y / n".to_string(), - )); - } else if settings.can_log_out() { - lines.push(entry( - selected == SettingsRow::Logout, - "Log out…", - String::new(), - )); - } - lines.push(Line::from("")); - - lines.push(section("Folders")); - lines.push(if settings.mode == SettingsMode::EditingDownloadDir { - typing("Download folder: ", settings.input.clone()) - } else { - entry( - selected == SettingsRow::DownloadDir, - "Download folder: ", - settings.download_dir.clone(), - ) - }); - lines.push(Line::styled( - format!("Shared folders ({}):", settings.share_dirs.len()), - dimmed_style(), - )); - for (i, dir) in settings.share_dirs.iter().enumerate() { - lines.push(entry( - selected == SettingsRow::Share(i), - "", - dir.clone(), - )); - } - if settings.share_dirs.is_empty() { - lines.push(Line::styled( - " (nothing shared — press 'a' to add a folder)", - dimmed_style(), - )); - } - if settings.mode == SettingsMode::AddingShare { - lines.push(typing(" Add share: ", settings.input.clone())); - } - if let Some(status) = &settings.status { - lines.push(Line::from("")); - lines.push(Line::styled(status.clone(), warning_style())); - } - - let block = pane_block(true).title(plain_title("Settings", true)); - frame.render_widget( - ratatui::widgets::Paragraph::new(lines) - .block(block) - .wrap(ratatui::widgets::Wrap { trim: false }), - area, - ); - } - - fn render_messages_popup(&mut self, frame: &mut Frame) { - // The per-conversation chat box supersedes the old flat message list; - // it renders the messages and the compose line itself. - let area = centered_rect(70, 60, frame.area()); - frame.render_widget(ratatui::widgets::Clear, area); - self.state.popup_area = Some(area); - let own = self.client.username(); - render_chat_pane(frame, area, &mut self.state, &own); - } - /// Context shortcuts for the chat-rooms popup. fn rooms_shortcuts(&self) -> Vec<(&'static str, &'static str)> { if self.state.rooms.composing { @@ -829,7 +421,7 @@ impl MainTui { /// The legend packed into rows for a window `width` wide, so a narrow /// terminal grows the bar instead of cutting keys off its right edge. - fn shortcut_rows(&self, width: u16) -> Vec> { + pub(super) fn shortcut_rows(&self, width: u16) -> Vec> { // Borders and padding, and the unread badge's column when it shows. let badge = self.unread_badge().width() as u16; let reserved = 4 + if badge > 0 { badge + 1 } else { 0 }; @@ -848,7 +440,7 @@ impl MainTui { ) } - fn render_shortcuts( + pub(super) fn render_shortcuts( &self, frame: &mut Frame, area: Rect, @@ -888,57 +480,11 @@ impl MainTui { ); } } - - fn render_command_bar(&self, frame: &mut Frame, area: Rect) { - let prefix = match self.state.command_bar_mode { - CommandBarMode::Search => "search: ", - CommandBarMode::Message => "message (to: recipient text): ", - CommandBarMode::Browse => "browse user: ", - }; - let block = pane_block(true); - // Derive from the block's inner rect so borders and padding stay in - // one place; the cursor follows whatever the pane reserves. - let inner = block.inner(area); - let prefix_width = prefix.chars().count() as u16; - let input_width = inner.width.saturating_sub(prefix_width); - let (visible_input, cursor_column) = visible_input_at_cursor( - &self.state.command_bar_input, - self.state.command_bar_cursor_position, - input_width, - ); - let command_line = Line::from(vec![ - Span::styled(prefix, accent_style()), - Span::styled(visible_input, primary_style()), - ]); - - frame.render_widget(Paragraph::new(command_line).block(block), area); - - if inner.width > 0 && inner.height > 0 { - let cursor_x = inner - .x - .saturating_add(prefix_width) - .saturating_add(cursor_column) - .min(inner.x.saturating_add(inner.width.saturating_sub(1))); - frame.set_cursor_position(Position::new(cursor_x, inner.y)); - } - } } /// Columns between the two halves of the keys list. const HELP_GAP: u16 = 2; -/// A dragged pane size kept inside the space the terminal now offers: never -/// below [`MIN_PANE`], and never so big that what shares the split with it -/// drops below `MIN_PANE` too. -pub(super) fn shrink(dragged: u16, total: u16) -> u16 { - let max = total.saturating_sub(MIN_PANE).max(MIN_PANE); - dragged.max(MIN_PANE).min(max) -} - -/// The smallest pane a drag can leave anyone: a border and a row or column -/// of content each side. -pub(super) const MIN_PANE: u16 = 3; - /// The widest key in a column, so the descriptions line up. fn key_column_width(sections: &[(&str, &[(&str, &str)])]) -> usize { sections @@ -990,22 +536,6 @@ fn help_lines(sections: &[(&str, &[(&str, &str)])]) -> Vec> { lines } -/// A `Rect` centered within `area`, sized to the given percentages. -fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { - let vertical = Layout::vertical([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(area); - Layout::horizontal([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(vertical[1])[1] -} - /// Build the shortcuts-bar unread badge: `✉ N` for unread private messages and /// `💬 N` for unread chat-room messages. Returns an empty line when nothing is /// unread so the caller can skip it. @@ -1038,33 +568,6 @@ fn unread_indicator( Line::from(Span::styled(format!("⟨ {} ⟩", counts.join(" ")), style)) } -fn visible_input_at_cursor( - input: &str, - cursor_position: usize, - width: u16, -) -> (String, u16) { - if width == 0 { - return (String::new(), 0); - } - - let cursor_position = input.floor_char_boundary(cursor_position); - let cursor_character_index = input[..cursor_position].chars().count(); - let max_cursor_column = usize::from(width.saturating_sub(1)); - let start_character_index = - cursor_character_index.saturating_sub(max_cursor_column); - - let visible_input = input - .chars() - .skip(start_character_index) - .take(usize::from(width)) - .collect(); - let cursor_column = cursor_character_index - .saturating_sub(start_character_index) - .min(max_cursor_column); - - (visible_input, cursor_column as u16) -} - #[cfg(test)] mod tests { use super::unread_indicator; diff --git a/soulseek-rs/src/ui/main_tui/tests/browse.rs b/soulseek-rs/src/ui/main_tui/tests/browse.rs new file mode 100644 index 0000000..f50e313 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/browse.rs @@ -0,0 +1,172 @@ +//! The browse popup, over a peer's shares and over our own. + +use super::*; + +#[test] +fn shift_b_shows_what_this_session_shares_and_r_re_indexes_it() { + let session = Arc::new(TalkativeSession::default()); + *session.listing.lock().expect("not poisoned") = + vec![soulseek_rs::SharedDirectory { + name: "Music\\Album".to_string(), + files: vec![soulseek_rs::SharedFileEntry { + name: "track.mp3".to_string(), + size: 4096, + attributes: Vec::new(), + }], + }]; + let mut tui = attach(session.clone()); + + press(&mut tui, KeyCode::Char('B')); + assert!(tui.state.show_browse, "the popup opens"); + let tab = tui.state.browse.active_tab().expect("tab"); + assert!(tab.own, "on this session's own index"); + assert_eq!(tab.username, "tester"); + assert_eq!(tab.file_count, 1); + let screen = screen_of(&mut tui); + assert!(screen.contains("My shares"), "{screen}"); + press(&mut tui, KeyCode::Char('L')); + let screen = screen_of(&mut tui); + assert!( + screen.contains("Album") && screen.contains("track.mp3"), + "the virtual path a peer would see, folder by folder: {screen}" + ); + + // Nothing here is downloadable from ourselves. + press(&mut tui, KeyCode::Char('d')); + assert!(tui.state.downloads.is_empty()); + + // `r` re-scans the disk rather than re-asking a peer, and the tab + // picks up what the fresh scan found. + session.listing.lock().expect("not poisoned").clear(); + press(&mut tui, KeyCode::Char('r')); + assert!( + session.shares_set.lock().expect("not poisoned").is_some(), + "the session was asked to re-index" + ); + assert_eq!( + tui.state.browse.active_tab().expect("tab").file_count, + 0, + "the view follows the new index" + ); + + // A second B focuses the tab it already opened. + press(&mut tui, KeyCode::Esc); + press(&mut tui, KeyCode::Char('B')); + assert_eq!(tui.state.browse.tabs.len(), 1); +} + +#[test] +fn o_leaves_the_share_view_for_the_folders_that_feed_it() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + press(&mut tui, KeyCode::Char('B')); + press(&mut tui, KeyCode::Char('o')); + assert!(!tui.state.show_browse, "the popup steps aside"); + assert!(tui.state.settings.is_some(), "for the settings pane"); +} + +#[test] +fn page_keys_walk_the_browse_tree_and_ctrl_d_does_not_download() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session); + browsed(&mut tui, 40); + let rows = tui.state.browse.active_tab().expect("tab").rows().len(); + assert_eq!(rows, 41, "the folder and its files"); + + press(&mut tui, KeyCode::End); + let selected = |tui: &MainTui| { + tui.state.browse.active_tab().expect("tab").selected_row + }; + assert_eq!(selected(&tui), 40); + press(&mut tui, KeyCode::Home); + assert_eq!(selected(&tui), 0); + press(&mut tui, KeyCode::PageDown); + let page = selected(&tui); + assert!(page > 1 && page < 40, "a page of rows: {page}"); + ctrl(&mut tui, 'd'); + assert!(selected(&tui) > page, "ctrl-d pages"); + assert!( + tui.state.downloads.is_empty() + && tui.state.downloads_receiver_channel.is_none(), + "and does not download the folder" + ); + press(&mut tui, KeyCode::Char('G')); + assert_eq!(selected(&tui), 40); + assert!(tui.state.show_browse, "still open"); +} + +#[test] +fn folder_keys_and_the_filter_move_through_a_browsed_tree() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + tui.state.browse.open("bob"); + let listing = vec![ + soulseek_rs::SharedDirectory { + name: "music\\alpha".to_string(), + files: vec![ + soulseek_rs::SharedFileEntry { + name: "a1.mp3".to_string(), + size: 1, + attributes: Vec::new(), + }, + soulseek_rs::SharedFileEntry { + name: "a2.flac".to_string(), + size: 2, + attributes: Vec::new(), + }, + ], + }, + soulseek_rs::SharedDirectory { + name: "music\\beta".to_string(), + files: vec![soulseek_rs::SharedFileEntry { + name: "b1.mp3".to_string(), + size: 3, + attributes: Vec::new(), + }], + }, + ]; + tui.state + .browse + .active_tab_mut() + .expect("tab") + .load(&listing); + tui.state.show_browse = true; + let _ = screen_of(&mut tui); + let selected = |tui: &MainTui| { + tui.state.browse.active_tab().expect("tab").selected_row + }; + let rows = |tui: &MainTui| { + tui.state.browse.active_tab().expect("tab").rows().len() + }; + + press(&mut tui, KeyCode::Char('L')); + assert_eq!(rows(&tui), 6, "every folder open"); + press(&mut tui, KeyCode::Char('J')); + press(&mut tui, KeyCode::Char('J')); + assert_eq!(selected(&tui), 4, "beta, skipping alpha's files"); + press(&mut tui, KeyCode::Char('K')); + assert_eq!(selected(&tui), 1, "back to alpha"); + press(&mut tui, KeyCode::Char('H')); + assert_eq!(rows(&tui), 1, "just music"); + + press(&mut tui, KeyCode::Char('/')); + for c in "flac".chars() { + press(&mut tui, KeyCode::Char(c)); + } + assert_eq!(rows(&tui), 3, "music, alpha, a2.flac"); + let screen = screen_of(&mut tui); + assert!(screen.contains("filter: flac_"), "{screen}"); + ctrl(&mut tui, 'd'); + assert_eq!( + tui.state.browse.active_tab().expect("tab").filter(), + "flac", + "a control chord pages, it does not type" + ); + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Char('j')); + press(&mut tui, KeyCode::Char('j')); + assert_eq!(selected(&tui), 2, "j moves again once the filter is kept"); + press(&mut tui, KeyCode::Esc); + assert_eq!(rows(&tui), 1, "Esc clears the filter first"); + assert!(tui.state.show_browse); + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.show_browse, "then hides the popup"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/layout.rs b/soulseek-rs/src/ui/main_tui/tests/layout.rs new file mode 100644 index 0000000..8381637 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/layout.rs @@ -0,0 +1,386 @@ +//! The window itself: which panes are up, how big, and what the chrome +//! says while they move. + +use super::*; + +#[test] +fn the_results_pane_has_the_whole_width_to_itself() { + let mut tui = furnished_tui(); + let screen = screen_of(&mut tui); + + let results = row_with(&screen, "[2] Results"); + assert!(results.starts_with('╭'), "{results}"); + assert!(results.trim_end().ends_with('╮'), "{results}"); + assert!(!results.contains("[1]"), "nothing beside it: {results}"); + + // The other three share the row underneath, in this order. + let row = row_with(&screen, "[1] Searches"); + let searches = row.find("[1]").expect("searches"); + let downloads = row.find("[3]").expect("downloads"); + let info = row.find("[Info]").expect("info"); + assert!(searches < downloads && downloads < info, "{row}"); +} + +#[test] +fn tab_and_shift_tab_walk_the_panes_in_legend_order() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); + press(&mut tui, KeyCode::BackTab); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); +} + +#[test] +fn w_hides_the_focused_pane_and_its_number_brings_it_back() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + + press(&mut tui, KeyCode::Char('w')); + + let screen = screen_of(&mut tui); + assert!(!screen.contains("[1] Searches"), "{screen}"); + assert!(screen.contains("[2] Results"), "{screen}"); + assert!(screen.contains("[3] Downloads"), "{screen}"); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); + assert_eq!(tui.state.searches_pane_area, None, "nothing to click"); + // The transfers widen into the space. + let row = row_with(&screen, "[3] Downloads"); + assert!(row.starts_with('╭'), "{row}"); + + press(&mut tui, KeyCode::Char('1')); + + let screen = screen_of(&mut tui); + assert!(screen.contains("[1] Searches"), "{screen}"); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); +} + +#[test] +fn hiding_the_results_gives_the_row_the_whole_height() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('w')); + let screen = screen_of(&mut tui); + assert!(!screen.contains("[2] Results"), "{screen}"); + // The row now starts right under the status bar. + let status = screen + .lines() + .position(|line| line.contains("Status")) + .expect("status bar"); + let row = screen + .lines() + .position(|line| line.contains("[1] Searches")) + .expect("searches"); + assert_eq!(row, status + 3, "{screen}"); +} + +#[test] +fn the_last_pane_standing_cannot_be_hidden() { + // Results goes, then Downloads; Searches is what is left, and a + // third press leaves it there. + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('w')); + press(&mut tui, KeyCode::Char('w')); + press(&mut tui, KeyCode::Char('w')); + let screen = screen_of(&mut tui); + assert!(screen.contains("[1] Searches"), "{screen}"); + assert!(!screen.contains("[3] Downloads"), "{screen}"); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); +} + +#[test] +fn z_zooms_the_focused_pane_and_esc_or_z_leaves_it() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('z')); + + let screen = screen_of(&mut tui); + assert!(screen.contains("[2] Results"), "{screen}"); + assert!(!screen.contains("[1] Searches"), "{screen}"); + assert!(!screen.contains("[3] Downloads"), "{screen}"); + assert!(!screen.contains("[Info]"), "{screen}"); + assert!(screen.contains("[z → unzoom]"), "{screen}"); + + // Tab while zoomed switches which pane fills the window. + press(&mut tui, KeyCode::Tab); + let screen = screen_of(&mut tui); + assert!(screen.contains("[3] Downloads"), "{screen}"); + assert!(!screen.contains("[2] Results"), "{screen}"); + + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.layout.zoomed); + let screen = screen_of(&mut tui); + assert!(screen.contains("[2] Results"), "{screen}"); + assert!(screen.contains("[Info]"), "{screen}"); + + press(&mut tui, KeyCode::Char('z')); + press(&mut tui, KeyCode::Char('z')); + assert!(!tui.state.layout.zoomed, "z toggles"); +} + +#[test] +fn a_search_brings_a_hidden_results_pane_back() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('w')); + assert!(!tui.state.layout.is_visible(FocusedPane::Results)); + + press(&mut tui, KeyCode::Char('s')); + press(&mut tui, KeyCode::Char('x')); + press(&mut tui, KeyCode::Enter); + + assert!(tui.state.layout.is_visible(FocusedPane::Results)); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); +} + +#[test] +fn question_mark_opens_the_keys_list_and_closes_it_again() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('?')); + + let screen = screen_of(&mut tui); + assert!(screen.contains("Keys"), "{screen}"); + assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); + assert!(screen.contains("clear every finished one"), "{screen}"); + + // Other keys are swallowed while it is open. + press(&mut tui, KeyCode::Char('q')); + assert!(tui.state.exit.is_none(), "q closes the list, not the app"); + assert!(!tui.state.show_help); + + press(&mut tui, KeyCode::Char('?')); + press(&mut tui, KeyCode::Char('w')); + assert!(tui.state.show_help, "w did not reach the panes"); + assert!(tui.state.layout.is_visible(FocusedPane::Results)); + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.show_help); +} + +#[test] +fn on_a_small_terminal_the_keys_list_stacks_and_scrolls() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('?')); + + let screen = screen_sized(&mut tui, 80, 24); + let panes = row_with(&screen, "Panes"); + assert!(!panes.contains("Searches"), "one column: {panes}"); + assert!(screen.contains("focus a pane, hidden or not"), "{screen}"); + assert!(!screen.contains("next room, chat or user"), "{screen}"); + + press(&mut tui, KeyCode::End); + let screen = screen_sized(&mut tui, 80, 24); + assert!(screen.contains("next room, chat or user"), "{screen}"); + assert!(!screen.contains("next / previous pane"), "{screen}"); + + press(&mut tui, KeyCode::Home); + let screen = screen_sized(&mut tui, 80, 24); + assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); + + // A wide window shows both columns at once, nothing to scroll. It + // has to be tall enough to hold the taller column outright; in a + // shorter one End follows the tail and the headings scroll off. + press(&mut tui, KeyCode::End); + let screen = screen_sized(&mut tui, 160, 44); + let panes = row_with(&screen, "Panes"); + assert!(panes.contains("Searches"), "two columns: {panes}"); + assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); +} + +#[test] +fn a_click_where_a_pane_just_hid_does_not_focus_it() { + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let searches = tui.state.searches_pane_area.expect("laid out"); + // Hidden, but not yet redrawn: the old area is still on record. + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Char('w')); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); + tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent { + kind: ratatui::crossterm::event::MouseEventKind::Down( + ratatui::crossterm::event::MouseButton::Left, + ), + column: searches.x + 1, + row: searches.y + 1, + modifiers: KeyModifiers::NONE, + }); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); +} + +#[test] +fn the_shortcut_bar_wraps_instead_of_cutting_keys_off() { + let mut tui = furnished_tui(); + let screen = screen_of(&mut tui); + assert!(screen.contains("[q → quit]"), "{screen}"); + let bar_rows = screen.lines().filter(|line| line.contains(" → ")).count(); + assert_eq!(bar_rows, 2, "{screen}"); + + let wide = screen_sized(&mut tui, 300, 40); + let bar_rows = wide.lines().filter(|line| line.contains(" → ")).count(); + assert_eq!(bar_rows, 1, "{wide}"); +} + +#[test] +fn the_searches_pane_lists_its_clear_all_and_rerun_keys() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + let screen = screen_sized(&mut tui, 300, 40); + assert!(screen.contains("[C → clear all]"), "{screen}"); + assert!(screen.contains("[S → search again]"), "{screen}"); + press(&mut tui, KeyCode::Char('?')); + let screen = screen_of(&mut tui); + assert!(screen.contains("run the search again"), "{screen}"); +} + +#[test] +fn the_downloads_pane_lists_its_clear_everything_key() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Downloads; + let screen = screen_sized(&mut tui, 300, 40); + assert!(screen.contains("[c → clear finished]"), "{screen}"); + assert!(screen.contains("[C → clear all]"), "{screen}"); + press(&mut tui, KeyCode::Char('?')); + let screen = screen_of(&mut tui); + assert!(screen.contains("cancelling the live ones"), "{screen}"); +} + +#[test] +fn shift_w_brings_every_pane_back_and_leaves_zoom() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + assert!(!screen_sized(&mut tui, 300, 40).contains("[W → ")); + press(&mut tui, KeyCode::Char('w')); + press(&mut tui, KeyCode::Char('z')); + assert!(tui.state.layout.zoomed); + assert!(!tui.state.layout.is_visible(FocusedPane::Searches)); + let screen = screen_sized(&mut tui, 300, 40); + assert!(screen.contains("[W → reset layout]"), "{screen}"); + press(&mut tui, KeyCode::Char('W')); + assert!(!tui.state.layout.zoomed); + assert!( + FocusedPane::ALL + .iter() + .all(|p| tui.state.layout.is_visible(*p)) + ); +} + +#[test] +fn clicking_where_a_hidden_pane_was_focuses_whatever_is_there_now() { + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let searches = tui.state.searches_pane_area.expect("laid out"); + let inside = ratatui::crossterm::event::MouseEvent { + kind: ratatui::crossterm::event::MouseEventKind::Down( + ratatui::crossterm::event::MouseButton::Left, + ), + column: searches.x + 1, + row: searches.y + 1, + modifiers: KeyModifiers::NONE, + }; + tui.handle_mouse_event(inside); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); + + press(&mut tui, KeyCode::Char('w')); + let _ = screen_of(&mut tui); + tui.handle_mouse_event(inside); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); +} + +#[test] +fn dragging_the_border_under_results_resizes_its_row() { + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let results = tui.state.results_pane_area.expect("laid out"); + let divider = tui.state.hsplit_divider.expect("a divider"); + let before = results.height; + let focused = tui.state.focused_pane; + + // Two drags: the row shrinks by the rows the pointer moved up. + mouse_drag_row( + &mut tui, + (results.x + 1, divider.y), + (results.x + 1, divider.y - 2), + ); + assert_eq!(tui.state.layout.top_height, Some(before - 2)); + assert_eq!( + tui.state.focused_pane, focused, + "a grab is not a focus click" + ); + + let _ = screen_of(&mut tui); + assert_eq!( + tui.state.results_pane_area.expect("laid out").height, + before - 2 + ); + + // A drag far past the row stops at the smallest pane, not zero. + mouse_drag_row( + &mut tui, + (results.x + 1, divider.y - 2), + (results.x + 1, 0), + ); + assert!(tui.state.layout.top_height.expect("resized") >= 3); + + press(&mut tui, KeyCode::Char('W')); + assert_eq!(tui.state.layout.top_height, None, "W resets the layout"); +} + +#[test] +fn dragging_a_row_panes_right_edge_gives_it_width() { + use ratatui::crossterm::event::{MouseButton, MouseEventKind}; + + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let searches = tui.state.searches_pane_area.expect("laid out"); + let seam = tui + .state + .vsplit_dividers + .iter() + .find(|(pane, _)| *pane == FocusedPane::Searches) + .map(|(_, rect)| *rect) + .expect("a seam beside Searches"); + tui.state.focused_pane = FocusedPane::Downloads; + + mouse_drag_row( + &mut tui, + (seam.x, searches.y + 1), + (seam.x + 4, searches.y + 1), + ); + assert_eq!(tui.state.layout.searches_width, Some(searches.width + 4)); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); + + // The widths are applied, and Info narrows to whatever is left. + let _ = screen_of(&mut tui); + assert_eq!( + tui.state.searches_pane_area.expect("laid out").width, + searches.width + 4 + ); + assert!(tui.state.info_pane_area.expect("laid out").width >= 3); + + // Releasing the button ends the drag: later motions just move the + // pointer. + mouse( + &mut tui, + MouseEventKind::Drag(MouseButton::Left), + seam.x + 40, + searches.y + 1, + ); + assert_eq!(tui.state.layout.searches_width, Some(searches.width + 4)); +} + +#[test] +fn the_info_pane_describes_the_highlighted_result() { + let mut tui = results_tui(3); + tui.state.results_items[1].filename = + "@@x\\Music\\Album\\Second Track.mp3".to_string(); + tui.state.results_items[1].bitrate = Some(320); + tui.state.results_table_state.select(Some(1)); + + let screen = screen_of(&mut tui); + assert!(screen.contains("@@x/Music/Album"), "{screen}"); + assert!(screen.contains("Bitrate"), "{screen}"); + + tui.state.focused_pane = FocusedPane::Downloads; + let screen = screen_of(&mut tui); + assert!(!screen.contains("@@x/Music/Album"), "{screen}"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/mod.rs b/soulseek-rs/src/ui/main_tui/tests/mod.rs new file mode 100644 index 0000000..b203767 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/mod.rs @@ -0,0 +1,560 @@ +//! The window driven the way a terminal drives it: keys and mouse events in, +//! a rendered screen out, over a stand-in session. +//! +//! This file is the harness the topic modules below share — the session +//! double, the window constructors, and the press/screen helpers. + +mod browse; +mod layout; +mod navigation; +mod rooms; +mod searches; +mod session; +mod settings; +mod transfers; + +use super::*; + +use crate::daemon::proto::ChatMessageDto; + +use crate::models::{FocusedPane, MessageDirection, RoomsView, SearchStatus}; + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// A stand-in for a daemon: it already holds a conversation, a transfer +/// queue, and a search cache, none of which this window created. +#[derive(Default)] +struct TalkativeSession { + history: Vec, + downloads: Vec, + searches: std::sync::Mutex>, + /// What `try_get_search_results` hands back to the window. + results: std::sync::Mutex>, + /// Every query a window put on the wire, in order. + searched: std::sync::Mutex>, + /// How many times a window asked for the search list. + all_searches_calls: std::sync::atomic::AtomicUsize, + /// How many times a window pulled a full result set across. Against a + /// daemon each pull is the whole set over the socket, so the tests + /// count them. + result_fetches: std::sync::atomic::AtomicUsize, + /// Whether this session is a daemon's. Local sessions do not sync. + shared: bool, + /// What `set_download_directory` was last asked for, if anything. + download_dir_set: std::sync::Mutex>, + /// What `set_shared_directories` was last asked for, if anything. + shares_set: std::sync::Mutex>>, + /// What `change_password` was last asked for, if anything. + password_set: std::sync::Mutex>, + /// The share index this session serves to peers. + listing: std::sync::Mutex>, + download_cancelled: std::sync::Mutex>, + upload_cancelled: std::sync::Mutex>, + download_removed: std::sync::Mutex>, +} + +fn queued(username: &str, filename: &str) -> soulseek_rs::types::Download { + at_status(username, filename, soulseek_rs::DownloadStatus::Queued) +} + +fn completed(username: &str, filename: &str) -> soulseek_rs::types::Download { + at_status(username, filename, soulseek_rs::DownloadStatus::Completed) +} + +fn uploading(username: &str, filename: &str) -> soulseek_rs::types::UploadInfo { + soulseek_rs::types::UploadInfo { + username: username.to_string(), + filename: filename.to_string(), + size: 10, + bytes_sent: 1, + speed_bytes_per_sec: 1.0, + status: soulseek_rs::types::UploadStatus::InProgress, + } +} + +fn at_status( + username: &str, + filename: &str, + status: soulseek_rs::DownloadStatus, +) -> soulseek_rs::types::Download { + let (sender, _) = std::sync::mpsc::channel(); + soulseek_rs::types::Download { + username: username.to_string(), + filename: filename.to_string(), + token: 1, + size: 4096, + download_directory: "/tmp".to_string(), + status, + sender, + queue_position: None, + metadata: soulseek_rs::types::DownloadMetadata::default(), + } +} + +fn key(ch: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE) +} + +impl SessionApi for TalkativeSession { + fn message_history(&self) -> Vec { + self.history.clone() + } + fn daemon_endpoint(&self) -> Option { + self.shared.then(|| "test-daemon".to_string()) + } + fn username(&self) -> String { + "tester".to_string() + } + fn listen_port(&self) -> Option { + None + } + fn session_loss(&self) -> Option { + None + } + fn search_with_cancel( + &self, + query: &str, + _timeout: Duration, + _cancel: Option>, + ) -> soulseek_rs::Result> { + self.searched + .lock() + .expect("not poisoned") + .push(query.to_string()); + Ok(Vec::new()) + } + fn get_search_results(&self, _key: &str) -> Vec { + Vec::new() + } + fn all_searches(&self) -> Vec { + self.all_searches_calls + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.searches.lock().expect("not poisoned").clone() + } + fn forget_search(&self, query: &str) -> bool { + let mut searches = self.searches.lock().expect("not poisoned"); + let before = searches.len(); + searches.retain(|search| search.query != query); + searches.len() != before + } + fn get_search_results_count(&self, _key: &str) -> usize { + 0 + } + fn try_get_search_results( + &self, + _key: &str, + ) -> Option> { + self.result_fetches + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Some(self.results.lock().expect("not poisoned").clone()) + } + fn start_wishlist_search(&self, _query: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn wishlist_interval(&self) -> Duration { + Duration::from_mins(12) + } + fn download( + &self, + _filename: String, + _username: String, + _size: u64, + _directory: String, + ) -> soulseek_rs::Result<( + soulseek_rs::types::Download, + std::sync::mpsc::Receiver, + )> { + Err(soulseek_rs::SoulseekRs::NotConnected) + } + fn download_with_metadata( + &self, + _filename: String, + _username: String, + _size: u64, + _directory: String, + _metadata: soulseek_rs::types::DownloadMetadata, + ) -> soulseek_rs::Result<( + soulseek_rs::types::Download, + std::sync::mpsc::Receiver, + )> { + Err(soulseek_rs::SoulseekRs::NotConnected) + } + fn get_all_downloads(&self) -> Vec { + self.downloads.clone() + } + fn pause_download(&self, _u: &str, _f: &str) -> bool { + false + } + fn resume_download(&self, _u: &str, _f: &str) -> bool { + false + } + fn remove_queued_download(&self, _u: &str, _f: &str) -> bool { + false + } + fn remove_download(&self, u: &str, f: &str) -> bool { + self.download_removed + .lock() + .expect("not poisoned") + .push((u.to_string(), f.to_string())); + true + } + fn uploads(&self) -> Vec { + Vec::new() + } + fn take_upload_events(&self) -> Vec { + Vec::new() + } + fn cancel_upload(&self, u: &str, f: &str) -> bool { + self.upload_cancelled + .lock() + .expect("not poisoned") + .push((u.to_string(), f.to_string())); + true + } + fn cancel_download(&self, u: &str, f: &str) -> bool { + self.download_cancelled + .lock() + .expect("not poisoned") + .push((u.to_string(), f.to_string())); + true + } + fn set_upload_slots(&self, _slots: usize) {} + fn check_privileges(&self) -> soulseek_rs::Result<()> { + Ok(()) + } + fn own_privilege_seconds(&self) -> Option { + None + } + fn request_room_list(&self) -> soulseek_rs::Result<()> { + Ok(()) + } + fn join_room(&self, _room: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn leave_room(&self, _room: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn say_in_room( + &self, + _room: &str, + _message: &str, + ) -> soulseek_rs::Result<()> { + Ok(()) + } + fn room_members(&self, _room: &str) -> Vec { + Vec::new() + } + fn room_member_stats( + &self, + _room: &str, + ) -> Vec { + Vec::new() + } + fn take_room_events(&self) -> Vec { + Vec::new() + } + fn send_private_message( + &self, + _u: &str, + _m: &str, + ) -> soulseek_rs::Result<()> { + Ok(()) + } + fn take_private_messages(&self) -> Vec { + Vec::new() + } + fn browse_user(&self, _username: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn take_browse_result( + &self, + _username: &str, + ) -> Option> { + None + } + fn request_user_info(&self, _username: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn watch_user(&self, _username: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn unwatch_user(&self, _username: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn watched_users(&self) -> Vec { + Vec::new() + } + fn user_info(&self, _username: &str) -> Option { + None + } + fn shared_counts(&self) -> (u32, u32) { + (3, 12) + } + fn change_password(&self, password: &str) -> soulseek_rs::Result<()> { + *self.password_set.lock().expect("not poisoned") = + Some(password.to_string()); + Ok(()) + } + fn shared_listing(&self) -> Vec { + self.listing.lock().expect("not poisoned").clone() + } + fn shared_directories(&self) -> Vec { + Vec::new() + } + fn set_shared_directories( + &self, + directories: Vec, + ) -> soulseek_rs::Result<()> { + *self.shares_set.lock().expect("not poisoned") = Some(directories); + Ok(()) + } + fn set_download_directory( + &self, + directory: String, + ) -> soulseek_rs::Result<()> { + *self.download_dir_set.lock().expect("not poisoned") = Some(directory); + Ok(()) + } +} + +fn tui(history: Vec) -> MainTui { + with_session(TalkativeSession { + history, + shared: true, + ..TalkativeSession::default() + }) +} + +fn shared(searches: Vec) -> TalkativeSession { + TalkativeSession { + searches: std::sync::Mutex::new(searches), + shared: true, + ..TalkativeSession::default() + } +} + +fn search_of(query: &str, files: usize, age: u64) -> crate::api::SessionSearch { + crate::api::SessionSearch { + query: query.to_string(), + files, + started_secs_ago: Some(age), + } +} + +fn with_session(session: TalkativeSession) -> MainTui { + attach(Arc::new(session)) +} + +fn attach(session: Arc) -> MainTui { + MainTui::new( + session, + "/tmp".to_string(), + Duration::from_secs(1), + None, + None, + ) +} + +fn files_from_bob(names: &[&str]) -> soulseek_rs::SearchResult { + soulseek_rs::SearchResult { + token: 0, + files: names + .iter() + .map(|name| soulseek_rs::types::File { + username: "bob".to_string(), + name: (*name).to_string(), + size: 42, + attribs: std::collections::HashMap::new(), + }) + .collect(), + slots: 1, + speed: 0, + username: "bob".to_string(), + } +} + +/// An attached window that selected a two-file search and has already +/// ticked past its one necessary fetch. +fn window_that_pulled_two_files() -> (Arc, MainTui) { + let session = Arc::new(TalkativeSession { + results: std::sync::Mutex::new(vec![files_from_bob(&[ + "@@x\\a.mp3", + "@@x\\b.mp3", + ])]), + ..shared(vec![search_of("aphex twin", 2, 9_999)]) + }); + let mut tui = attach(session.clone()); + tui.state.selected_search_index = Some(0); + tui.update_search_results(); + tui.update_search_results(); + (session, tui) +} + +fn results(n: usize) -> Vec { + (0..n) + .map(|i| crate::models::FileDisplayData { + filename: format!("@@x\\Music\\{i:02}.mp3"), + username: "bob".to_string(), + ..Default::default() + }) + .collect() +} + +fn results_tui(n: usize) -> MainTui { + let mut tui = with_session(TalkativeSession::default()); + tui.state.results_items = results(n); + tui.state.focused_pane = FocusedPane::Results; + // 88 cells wide gives the name column 11 cells and the folder 7. + tui.state.results_pane_area = + Some(ratatui::layout::Rect::new(0, 0, 88, 13)); + tui +} + +fn press(tui: &mut MainTui, code: KeyCode) { + tui.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); +} + +fn ctrl(tui: &mut MainTui, ch: char) { + tui.handle_key_event(KeyEvent::new( + KeyCode::Char(ch), + KeyModifiers::CONTROL, + )); +} + +fn selected(tui: &MainTui) -> Option { + tui.state.results_table_state.selected() +} + +fn screen_sized(tui: &mut MainTui, width: u16, height: u16) -> String { + let backend = ratatui::backend::TestBackend::new(width, height); + let mut terminal = ratatui::Terminal::new(backend).expect("backend"); + terminal.draw(|frame| tui.render(frame)).expect("draw"); + terminal.backend().to_string() +} + +fn screen_of(tui: &mut MainTui) -> String { + screen_sized(tui, 160, 40) +} + +fn mouse( + tui: &mut MainTui, + kind: ratatui::crossterm::event::MouseEventKind, + column: u16, + row: u16, +) { + tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }); +} + +fn mouse_drag_row(tui: &mut MainTui, from: (u16, u16), to: (u16, u16)) { + use ratatui::crossterm::event::{MouseButton, MouseEventKind}; + mouse(tui, MouseEventKind::Down(MouseButton::Left), from.0, from.1); + mouse(tui, MouseEventKind::Drag(MouseButton::Left), to.0, to.1); + mouse(tui, MouseEventKind::Up(MouseButton::Left), to.0, to.1); +} + +/// The screen row carrying a pane's legend, `[2] Results` say, without +/// the quotes the test backend wraps each row in. +fn row_with<'a>(screen: &'a str, needle: &str) -> &'a str { + let line = screen + .lines() + .find(|line| line.contains(needle)) + .unwrap_or_else(|| panic!("no row with {needle:?} in\n{screen}")); + let start = line.find('"').map_or(0, |i| i + 1); + let end = line.rfind('"').unwrap_or(line.len()); + &line[start..end] +} + +/// A window with a search, its results, and a transfer, so every pane +/// has something to show. +fn furnished_tui() -> MainTui { + let mut tui = results_tui(3); + tui.state.searches.push(crate::models::SearchEntry { + query: "aphex twin".to_string(), + status: SearchStatus::Completed, + results: results(3), + known_files: 3, + owned: true, + start_time: std::time::Instant::now(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); + tui.state.selected_search_index = Some(0); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "@@x\\Music\\00.mp3"), + receiver: None, + }); + tui +} + +fn browsed(tui: &mut MainTui, files: usize) { + tui.state.browse.open("bob"); + let listing = vec![soulseek_rs::SharedDirectory { + name: "Music".to_string(), + files: (0..files) + .map(|i| soulseek_rs::SharedFileEntry { + name: format!("{i:02}.mp3"), + size: 1, + attributes: Vec::new(), + }) + .collect(), + }]; + tui.state + .browse + .active_tab_mut() + .expect("tab") + .load(&listing); + tui.state.show_browse = true; + let _ = screen_of(tui); +} + +fn in_a_room(tui: &mut MainTui, messages: usize) { + tui.state.rooms.apply_event( + soulseek_rs::RoomEvent::Joined { + room: "jazz".to_string(), + users: (0..30).map(|i| format!("user{i:02}")).collect(), + }, + None, + ); + tui.state.rooms.focus_or_open("jazz"); + for i in 0..messages { + tui.state.rooms.apply_event( + soulseek_rs::RoomEvent::Message { + room: "jazz".to_string(), + username: "alice".to_string(), + message: format!("line {i:03}"), + }, + Some("jazz"), + ); + } + tui.state.show_rooms = true; + let _ = screen_of(tui); +} + +/// The queries the window has sent so far, once at least `count` of them +/// are out: a search goes to the session from a worker thread, so a test +/// has to give it a moment. +fn searched(session: &TalkativeSession, count: usize) -> Vec { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + let seen = session.searched.lock().expect("not poisoned").clone(); + if seen.len() >= count || std::time::Instant::now() > deadline { + return seen; + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// A window over last session's search list: the queries came back from +/// disk, with nothing under them. +fn restored(session: Arc, queries: &[&str]) -> MainTui { + let mut tui = attach(session); + let queries: Vec = + queries.iter().map(ToString::to_string).collect(); + restore_searches(&mut tui.state, &queries); + tui.state.focused_pane = FocusedPane::Searches; + tui +} diff --git a/soulseek-rs/src/ui/main_tui/tests/navigation.rs b/soulseek-rs/src/ui/main_tui/tests/navigation.rs new file mode 100644 index 0000000..fca9606 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/navigation.rs @@ -0,0 +1,236 @@ +//! Moving through a list: rows, pages, and the sideways scroll that +//! walks a name too long for its column. + +use super::*; + +#[test] +fn end_and_home_jump_to_the_last_and_first_result() { + let mut tui = results_tui(30); + press(&mut tui, KeyCode::End); + assert_eq!(selected(&tui), Some(29)); + press(&mut tui, KeyCode::Home); + assert_eq!(selected(&tui), Some(0)); + press(&mut tui, KeyCode::Char('G')); + assert_eq!(selected(&tui), Some(29)); + press(&mut tui, KeyCode::Char('g')); + assert_eq!(selected(&tui), Some(0)); +} + +#[test] +fn page_keys_move_a_pane_of_rows_and_stop_at_the_edges() { + let mut tui = results_tui(30); + press(&mut tui, KeyCode::PageDown); + assert_eq!(selected(&tui), Some(10)); + ctrl(&mut tui, 'f'); + assert_eq!(selected(&tui), Some(20)); + press(&mut tui, KeyCode::PageDown); + assert_eq!(selected(&tui), Some(29), "clamps instead of wrapping"); + press(&mut tui, KeyCode::PageUp); + assert_eq!(selected(&tui), Some(19)); + ctrl(&mut tui, 'b'); + assert_eq!(selected(&tui), Some(9)); + press(&mut tui, KeyCode::PageUp); + assert_eq!(selected(&tui), Some(0)); + assert!( + !tui.state.show_browse && !tui.state.command_bar_active, + "ctrl-b pages up instead of opening browse" + ); +} + +#[test] +fn ctrl_d_and_ctrl_u_move_half_a_pane() { + let mut tui = results_tui(30); + ctrl(&mut tui, 'd'); + assert_eq!(selected(&tui), Some(5)); + ctrl(&mut tui, 'u'); + assert_eq!(selected(&tui), Some(0)); +} + +#[test] +fn navigation_keys_do_nothing_without_results() { + let mut tui = results_tui(0); + tui.state.results_table_state.select(None); + press(&mut tui, KeyCode::End); + press(&mut tui, KeyCode::PageDown); + ctrl(&mut tui, 'd'); + assert_eq!(selected(&tui), None); +} + +#[test] +fn l_and_h_scroll_the_highlighted_name_up_to_its_end() { + let mut tui = results_tui(3); + tui.state.results_items[1].filename = "x".repeat(40); + tui.state.results_table_state.select(Some(1)); + press(&mut tui, KeyCode::Char('l')); + assert_eq!(tui.state.results_name_offset, 8); + press(&mut tui, KeyCode::Right); + assert_eq!(tui.state.results_name_offset, 16); + press(&mut tui, KeyCode::Char('$')); + assert_eq!(tui.state.results_name_offset, 30); + press(&mut tui, KeyCode::Char('l')); + assert_eq!(tui.state.results_name_offset, 30, "stops at the end"); + press(&mut tui, KeyCode::Char('h')); + assert_eq!(tui.state.results_name_offset, 22); + press(&mut tui, KeyCode::Left); + assert_eq!(tui.state.results_name_offset, 14); + press(&mut tui, KeyCode::Char('0')); + assert_eq!(tui.state.results_name_offset, 0); + press(&mut tui, KeyCode::Char('h')); + assert_eq!(tui.state.results_name_offset, 0); +} + +#[test] +fn scrolling_follows_the_highlighted_row_not_the_longest_one() { + let mut tui = results_tui(3); + tui.state.results_items[0].filename = "short.mp3".to_string(); + tui.state.results_items[1].filename = "x".repeat(40); + tui.state.results_items[2].filename = "short.mp3".to_string(); + press(&mut tui, KeyCode::Char('$')); + assert_eq!(tui.state.results_name_offset, 0, "row 0 fits already"); + tui.state.results_table_state.select(Some(1)); + press(&mut tui, KeyCode::Char('$')); + assert_eq!(tui.state.results_name_offset, 30); + tui.state.results_table_state.select(Some(2)); + press(&mut tui, KeyCode::Char('h')); + assert_eq!( + tui.state.results_name_offset, 0, + "a step left lands within the row now highlighted" + ); +} + +#[test] +fn picking_another_search_starts_its_results_unscrolled() { + let mut tui = results_tui(3); + tui.state.results_items[1].filename = "x".repeat(40); + tui.state.results_table_state.select(Some(1)); + press(&mut tui, KeyCode::Char('$')); + assert_eq!(tui.state.results_name_offset, 30); + + tui.state.searches.push(crate::models::SearchEntry { + query: "other".to_string(), + status: SearchStatus::Active, + results: results(2), + known_files: 2, + owned: true, + start_time: std::time::Instant::now(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); + tui.state.searches_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Enter); + assert_eq!(tui.state.results_name_offset, 0); +} + +#[test] +fn ctrl_keys_are_ignored_outside_the_results_pane() { + let mut tui = results_tui(3); + tui.state.searches.push(crate::models::SearchEntry { + query: "keep me".to_string(), + status: SearchStatus::Active, + results: Vec::new(), + known_files: 0, + owned: true, + start_time: std::time::Instant::now(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); + tui.state.searches_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Searches; + ctrl(&mut tui, 'd'); + assert_eq!(tui.state.searches.len(), 1, "ctrl-d is not d"); + ctrl(&mut tui, 'b'); + assert!(!tui.state.command_bar_active, "ctrl-b is not b"); + ctrl(&mut tui, 'q'); + assert!(tui.state.exit.is_none(), "ctrl-q is not q"); +} + +#[test] +fn ctrl_keys_page_while_a_filter_is_being_typed() { + let mut tui = results_tui(30); + press(&mut tui, KeyCode::Char('/')); + press(&mut tui, KeyCode::Char('m')); + ctrl(&mut tui, 'd'); + assert_eq!(selected(&tui), Some(5)); + assert_eq!(tui.state.results_filter_query, "m", "no d appended"); + press(&mut tui, KeyCode::End); + assert_eq!(selected(&tui), Some(29)); +} + +#[test] +fn space_after_a_filter_that_matches_nothing_does_not_panic() { + let mut tui = results_tui(3); + press(&mut tui, KeyCode::Char('/')); + press(&mut tui, KeyCode::Char('z')); + press(&mut tui, KeyCode::Enter); + assert!(tui.state.results_filtered_items.is_empty()); + press(&mut tui, KeyCode::Char(' ')); + assert!(tui.state.results_selected_indices.is_empty()); +} + +#[test] +fn l_and_h_scroll_a_transfer_name_and_a_query_too() { + let mut tui = furnished_tui(); + tui.state.downloads[0].download.filename = "y".repeat(120); + tui.state.searches[0].query = "z".repeat(60); + let _ = screen_of(&mut tui); + + tui.state.focused_pane = FocusedPane::Downloads; + press(&mut tui, KeyCode::Char('l')); + assert_eq!(tui.state.downloads_name_offset, 8); + press(&mut tui, KeyCode::Char('$')); + let end = tui.state.downloads_name_offset; + assert!(end > 8, "the end is past a step: {end}"); + let screen = screen_of(&mut tui); + assert!(row_with(&screen, "…yyyy").contains("…yyyy"), "{screen}"); + press(&mut tui, KeyCode::Char('0')); + assert_eq!(tui.state.downloads_name_offset, 0); + + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Right); + assert_eq!(tui.state.searches_query_offset, 8); + press(&mut tui, KeyCode::End); + assert_eq!( + tui.state.searches_table_state.selected(), + Some(0), + "End is still a row key, $ is the name key" + ); + press(&mut tui, KeyCode::Char('$')); + assert!(tui.state.searches_query_offset > 8); + let screen = screen_of(&mut tui); + assert!(screen.contains("…zzzz"), "{screen}"); + assert!( + tui.state.results_name_offset == 0, + "each list scrolls on its own" + ); +} + +#[test] +fn page_keys_work_in_the_transfers_and_searches_lists_too() { + let mut tui = furnished_tui(); + for i in 0..30 { + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", &format!("{i}.mp3")), + receiver: None, + }); + } + tui.state.focused_pane = FocusedPane::Downloads; + tui.state.downloads_table_state.select(Some(0)); + let _ = screen_of(&mut tui); // lays the panes out, which sets the page size + + press(&mut tui, KeyCode::End); + assert_eq!(tui.state.downloads_table_state.selected(), Some(30)); + press(&mut tui, KeyCode::Home); + assert_eq!(tui.state.downloads_table_state.selected(), Some(0)); + press(&mut tui, KeyCode::PageDown); + let page = tui.state.downloads_table_state.selected().expect("row"); + assert!(page > 1 && page < 30, "a pane of rows, not one: {page}"); + ctrl(&mut tui, 'd'); + assert_eq!(tui.state.downloads.len(), 31, "ctrl-d pages, d deletes"); + assert!(tui.state.downloads_table_state.selected() > Some(page)); + + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Char('G')); + assert_eq!(tui.state.searches_table_state.selected(), Some(0)); + press(&mut tui, KeyCode::Char('g')); + assert_eq!(tui.state.searches_table_state.selected(), Some(0)); + assert_eq!(tui.state.searches.len(), 1, "g and G only move"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/rooms.rs b/soulseek-rs/src/ui/main_tui/tests/rooms.rs new file mode 100644 index 0000000..4cd7af0 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/rooms.rs @@ -0,0 +1,164 @@ +//! Rooms and conversations: their logs, their lists, and their filters. + +use super::*; + +#[test] +fn slash_and_u_filter_a_rooms_log_and_its_members() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + in_a_room(&mut tui, 30); + press(&mut tui, KeyCode::Char('u')); + for c in "user2".chars() { + press(&mut tui, KeyCode::Char(c)); + } + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Char('j')); + assert_eq!( + tui.state.rooms.selected_user().as_deref(), + Some("user21"), + "j moves within the matching members" + ); + let screen = screen_of(&mut tui); + assert!(screen.contains("Users 10/30"), "{screen}"); + + press(&mut tui, KeyCode::Char('/')); + for c in "line 01".chars() { + press(&mut tui, KeyCode::Char(c)); + } + let screen = screen_of(&mut tui); + assert!(screen.contains("line 010"), "{screen}"); + assert!(!screen.contains("line 029"), "{screen}"); + assert!(screen.contains("filter: line 01_"), "{screen}"); + ctrl(&mut tui, 'u'); + assert_eq!(tui.state.rooms.log_filter, "line 01", "^u scrolls"); + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Esc); + assert!(tui.state.rooms.log_filter.is_empty()); + assert!(tui.state.rooms.user_filter.is_empty()); + assert_eq!(tui.state.rooms.view, RoomsView::Chat, "still in the room"); + press(&mut tui, KeyCode::Esc); + assert_eq!(tui.state.rooms.view, RoomsView::List, "then the list"); +} + +#[test] +fn slash_filters_the_open_conversation() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + for text in ["hello there", "see you", "hello again"] { + tui.state.messages.push(crate::models::ChatMessage { + direction: MessageDirection::Incoming, + peer: "bob".to_string(), + text: text.to_string(), + at: chrono::Local::now(), + }); + } + tui.state.chat_peer = Some("bob".to_string()); + tui.state.show_messages = true; + press(&mut tui, KeyCode::Char('/')); + for c in "hello".chars() { + press(&mut tui, KeyCode::Char(c)); + } + let screen = screen_of(&mut tui); + assert!(screen.contains("hello again"), "{screen}"); + assert!(!screen.contains("see you"), "{screen}"); + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Esc); + assert!(tui.state.chat_filter.is_empty() && tui.state.show_messages); + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.show_messages); +} + +#[test] +fn page_up_scrolls_a_room_log_back_and_end_returns_to_the_newest() { + let mut tui = with_session(TalkativeSession::default()); + in_a_room(&mut tui, 120); + + let screen = screen_of(&mut tui); + assert!(screen.contains("line 119"), "tails by default: {screen}"); + assert!(!screen.contains("line 000"), "{screen}"); + + press(&mut tui, KeyCode::PageUp); + let screen = screen_of(&mut tui); + assert!(!screen.contains("line 119"), "scrolled back: {screen}"); + + press(&mut tui, KeyCode::Home); + let screen = screen_of(&mut tui); + assert!(screen.contains("line 000"), "the oldest: {screen}"); + press(&mut tui, KeyCode::PageUp); + let screen = screen_of(&mut tui); + assert!(screen.contains("line 000"), "clamped at the top: {screen}"); + + press(&mut tui, KeyCode::End); + let screen = screen_of(&mut tui); + assert!(screen.contains("line 119"), "{screen}"); + + // The member list keeps its own keys, and ctrl-x is not x. + press(&mut tui, KeyCode::Down); + assert_eq!(tui.state.rooms.user_selected, 1); + ctrl(&mut tui, 'x'); + assert_eq!(tui.state.rooms.open.len(), 1, "still in the room"); +} + +#[test] +fn page_keys_move_through_the_room_list() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.rooms.apply_event( + soulseek_rs::RoomEvent::List( + (0..60) + .map(|i| soulseek_rs::types::RoomInfo { + name: format!("room{i:02}"), + user_count: 60 - i, + }) + .collect(), + ), + None, + ); + tui.state.show_rooms = true; + let _ = screen_of(&mut tui); + + press(&mut tui, KeyCode::End); + assert_eq!(tui.state.rooms.list_selected, 59); + press(&mut tui, KeyCode::PageUp); + assert!(tui.state.rooms.list_selected < 59); + press(&mut tui, KeyCode::Char('g')); + assert_eq!(tui.state.rooms.list_selected, 0); + ctrl(&mut tui, 'f'); + assert!(tui.state.rooms.list_selected > 1); +} + +#[test] +fn page_up_scrolls_a_conversation_and_switching_chats_resets_it() { + let mut tui = tui((0..80) + .map(|i| ChatMessageDto { + peer: "bob".into(), + outgoing: i % 2 == 0, + text: format!("msg {i:03}"), + at: i, + }) + .chain(std::iter::once(ChatMessageDto { + peer: "carol".into(), + outgoing: false, + text: "hello".into(), + at: 100, + })) + .collect()); + tui.state.chat_peer = Some("bob".to_string()); + tui.state.show_messages = true; + let screen = screen_of(&mut tui); + assert!(screen.contains("msg 079"), "{screen}"); + + press(&mut tui, KeyCode::PageUp); + let screen = screen_of(&mut tui); + assert!(!screen.contains("msg 079"), "scrolled back: {screen}"); + press(&mut tui, KeyCode::Char('g')); + let screen = screen_of(&mut tui); + assert!(screen.contains("msg 000"), "{screen}"); + + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.active_chat_peer(), Some("carol")); + assert!( + tui.state.chat_view.following(), + "a fresh chat starts at its end" + ); + press(&mut tui, KeyCode::BackTab); + let screen = screen_of(&mut tui); + assert!(screen.contains("msg 079"), "{screen}"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/searches.rs b/soulseek-rs/src/ui/main_tui/tests/searches.rs new file mode 100644 index 0000000..0d40e7f --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/searches.rs @@ -0,0 +1,89 @@ +//! Running a search the list already holds. + +use super::*; + +#[test] +fn shift_s_runs_the_highlighted_search_again_in_its_place() { + // The list survives a restart, but a query on it was only ever a + // reminder: seeing what the network has now meant typing it again. + let session = Arc::new(TalkativeSession::default()); + let mut tui = + restored(session.clone(), &["boards of canada", "aphex twin"]); + tui.state.searches_table_state.select(Some(1)); + + press(&mut tui, KeyCode::Char('S')); + + assert_eq!( + searched(&session, 1), + vec!["aphex twin".to_string()], + "the highlighted query went out again, untyped" + ); + let queries: Vec<&str> = tui + .state + .searches + .iter() + .map(|s| s.query.as_str()) + .collect(); + assert_eq!( + queries, + ["boards of canada", "aphex twin"], + "it keeps its row: no duplicate, nothing moved" + ); + assert_eq!(tui.state.searches[1].status, SearchStatus::Active); + assert_eq!(tui.state.selected_search_index, Some(1)); + assert_eq!( + tui.state.focused_pane, + FocusedPane::Results, + "and the window turns to where the results will land" + ); + assert!(tui.state.results_items.is_empty()); +} + +#[test] +fn shift_s_stops_a_run_still_collecting_before_starting_over() { + let mut tui = furnished_tui(); + tui.state.searches[0].status = SearchStatus::Active; + tui.state.searches[0].start_time = std::time::Instant::now() + .checked_sub(Duration::from_secs(2)) + .expect("two seconds ago"); + let previous = tui.state.searches[0].cancel_flag.clone(); + tui.state.focused_pane = FocusedPane::Searches; + tui.state.searches_table_state.select(Some(0)); + + press(&mut tui, KeyCode::Char('S')); + + assert!( + previous.load(std::sync::atomic::Ordering::Relaxed), + "the worker that was collecting is told to stop" + ); + let fresh = &tui.state.searches[0]; + assert!(!fresh.cancel_flag.load(std::sync::atomic::Ordering::Relaxed)); + assert!(fresh.results.is_empty(), "stale rows do not linger"); + assert_eq!(tui.state.searches.len(), 1); +} + +#[test] +fn shift_s_is_only_a_key_over_the_searches_list() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = restored(session.clone(), &["aphex twin"]); + + // Over Results the same key means nothing, so a stray shift does not + // throw a finished search away. + tui.state.focused_pane = FocusedPane::Results; + press(&mut tui, KeyCode::Char('S')); + assert_eq!(tui.state.searches[0].status, SearchStatus::Completed); + std::thread::sleep(Duration::from_millis(50)); + assert!(searched(&session, 0).is_empty(), "nothing went out"); +} + +#[test] +fn a_held_shift_s_is_one_search_not_one_per_repeat() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = restored(session.clone(), &["aphex twin"]); + press(&mut tui, KeyCode::Char('S')); + press(&mut tui, KeyCode::Char('1')); + press(&mut tui, KeyCode::Char('S')); + assert_eq!(searched(&session, 1).len(), 1); + std::thread::sleep(Duration::from_millis(50)); + assert_eq!(searched(&session, 1).len(), 1, "the repeat is ignored"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/session.rs b/soulseek-rs/src/ui/main_tui/tests/session.rs new file mode 100644 index 0000000..a409630 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/session.rs @@ -0,0 +1,289 @@ +//! A window over a session it did not start: what a daemon's state +//! looks like arriving in a window, and what the window pushes back. + +use super::*; + +#[test] +fn an_attached_session_opens_with_the_conversation_already_in_it() { + // A TUI attached to a daemon has no state file of its own; the + // conversation has to come from the session, or it starts blank while + // the daemon has been collecting messages for hours. + let tui = tui(vec![ + ChatMessageDto { + peer: "bob".into(), + outgoing: false, + text: "hi".into(), + at: 1, + }, + ChatMessageDto { + peer: "bob".into(), + outgoing: true, + text: "hello".into(), + at: 2, + }, + ]); + + assert_eq!(tui.state.messages.len(), 2); + assert_eq!(tui.state.messages[0].peer, "bob"); + assert_eq!(tui.state.messages[0].direction, MessageDirection::Incoming); + assert_eq!( + tui.state.messages[1].direction, + MessageDirection::Outgoing, + "both halves of the conversation, not just what arrived" + ); + assert_eq!(tui.state.chat_peers(), ["bob"]); +} + +#[test] +fn a_window_shows_transfers_it_did_not_start() { + // Two windows on one daemon are two views of one queue. A transfer + // queued in the other one has to appear here, or closing a window + // would look like losing its downloads. + let mut tui = with_session(TalkativeSession { + downloads: vec![queued("bob", "@@x\\theirs.mp3")], + shared: true, + ..TalkativeSession::default() + }); + assert!( + tui.state.downloads.is_empty(), + "nothing until the first poll" + ); + + tui.update_downloads(); + + assert_eq!(tui.state.downloads.len(), 1); + assert_eq!(tui.state.downloads[0].download.filename, "@@x\\theirs.mp3"); + assert!( + tui.state.downloads[0].receiver.is_none(), + "someone else's transfer has no channel of ours" + ); + + // Polling again must not double it up. + tui.update_downloads(); + assert_eq!(tui.state.downloads.len(), 1); +} + +#[test] +fn a_transfer_the_session_forgets_leaves_every_window() { + let mut tui = with_session(TalkativeSession { + downloads: vec![queued("bob", "gone.mp3")], + shared: true, + ..TalkativeSession::default() + }); + tui.update_downloads(); + assert_eq!(tui.state.downloads.len(), 1); + + // The session no longer has it — cancelled from the other window. + tui.client = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + tui.update_downloads(); + assert!( + tui.state.downloads.is_empty(), + "a queue is shared in both directions" + ); +} + +#[test] +fn a_window_shows_searches_run_in_another() { + let mut tui = + with_session(shared(vec![search_of("their query", 7, 9_999)])); + tui.update_search_results(); + + assert_eq!(tui.state.searches.len(), 1); + assert_eq!(tui.state.searches[0].query, "their query"); + + // And it is not added a second time on the next frame. + tui.update_search_results(); + assert_eq!(tui.state.searches.len(), 1); +} + +#[test] +fn a_search_another_window_is_still_running_shows_as_active() { + // It had said "Done" the moment it appeared, because an imported + // search carried no notion of still collecting — so the other window + // looked finished while results were still arriving. + let mut tui = with_session(shared(vec![search_of("still going", 3, 0)])); + tui.update_search_results(); + + assert_eq!(tui.state.searches[0].status, SearchStatus::Active); + assert_eq!( + tui.state.searches[0].known_files, 3, + "and its count comes across before the results do" + ); +} + +#[test] +fn a_search_that_has_run_its_window_shows_as_done() { + let mut tui = with_session(shared(vec![search_of("finished", 12, 9_999)])); + tui.update_search_results(); + assert_eq!(tui.state.searches[0].status, SearchStatus::Completed); +} + +#[test] +fn removing_a_search_removes_it_from_the_session() { + // Removing it only from this window would last until the next sync, + // which would put it straight back — and in a shared session it would + // come back for every other window too. + let mut tui = with_session(shared(vec![search_of("unwanted", 1, 9_999)])); + tui.update_search_results(); + assert_eq!(tui.state.searches.len(), 1); + + tui.remove_search_at_index(0); + tui.update_search_results(); + + assert!(tui.state.searches.is_empty(), "and it stays gone"); +} + +#[test] +fn clearing_every_search_clears_them_in_the_session_too() { + let mut tui = with_session(shared(vec![ + search_of("one", 1, 9_999), + search_of("two", 2, 9_999), + ])); + tui.update_search_results(); + assert_eq!(tui.state.searches.len(), 2); + + tui.clear_all_searches(); + tui.update_search_results(); + + assert!( + tui.state.searches.is_empty(), + "clearing has to reach the session, or every row comes back" + ); +} + +#[test] +fn a_search_dismissed_in_another_window_leaves_this_one() { + let session = shared(vec![search_of("theirs", 4, 9_999)]); + let mut tui = with_session(session); + tui.update_search_results(); + assert_eq!(tui.state.searches.len(), 1); + + // The other window dismissed it. + tui.client.forget_search("theirs"); + tui.update_search_results(); + + assert!(tui.state.searches.is_empty()); +} + +#[test] +fn a_tick_asks_the_session_for_its_searches_once() { + // Against a daemon every call is a round trip, and the daemon answers + // it by walking its whole search cache — twice per frame was half the + // cost of attaching at all. + let session = Arc::new(shared(vec![search_of("q", 0, 9_999)])); + let mut tui = attach(session.clone()); + + tui.update_search_results(); + + assert_eq!( + session + .all_searches_calls + .load(std::sync::atomic::Ordering::Relaxed), + 1, + "one sync means one listing, not one per use of it" + ); +} + +#[test] +fn a_selected_search_with_nothing_new_is_not_pulled_across_again() { + // The result set only accumulates, so an unchanged count means a + // fetch would hand back what this window already holds — against a + // daemon that is the entire result set over the socket, every frame, + // forever. + let (session, tui) = window_that_pulled_two_files(); + + assert_eq!(tui.state.searches[0].results.len(), 2); + assert_eq!( + session + .result_fetches + .load(std::sync::atomic::Ordering::Relaxed), + 1, + "the set crosses once; after that the unchanged count spares it" + ); +} + +#[test] +fn fresh_results_still_reach_a_window_that_skipped_a_fetch() { + let (session, mut tui) = window_that_pulled_two_files(); + + // A late responder lands in the session. + *session.searches.lock().expect("not poisoned") = + vec![search_of("aphex twin", 3, 9_999)]; + *session.results.lock().expect("not poisoned") = + vec![files_from_bob(&["@@x\\a.mp3", "@@x\\b.mp3", "@@x\\c.mp3"])]; + tui.update_search_results(); + + assert_eq!( + tui.state.searches[0].results.len(), + 3, + "a changed count must bring the new results across" + ); +} + +#[test] +fn a_local_window_fetches_results_without_counting_on_the_session() { + // Locally nothing maintains known_files, so the count gate must stay + // out of the way: a search whose count still reads zero has to get + // its results anyway. + let session = Arc::new(TalkativeSession { + results: std::sync::Mutex::new(vec![files_from_bob(&[ + "@@x\\a.mp3", + "@@x\\b.mp3", + ])]), + shared: false, + ..TalkativeSession::default() + }); + let mut tui = attach(session); + tui.state.searches.push(crate::models::SearchEntry { + query: "mine".to_string(), + status: SearchStatus::Active, + results: Vec::new(), + known_files: 0, + owned: true, + start_time: std::time::Instant::now(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); + + tui.update_search_results(); + + assert_eq!( + tui.state.searches[0].results.len(), + 2, + "a zero count matching zero held rows must not read as \ + nothing-to-fetch on a local session" + ); +} + +#[test] +fn a_window_with_its_own_session_is_left_alone() { + // Nothing to share, so nothing is synced — and in particular the + // completed transfers restored from disk, which the session has never + // heard of, are not mistaken for cancelled and deleted. + let mut tui = with_session(TalkativeSession { + downloads: Vec::new(), + shared: false, + ..TalkativeSession::default() + }); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "from-disk.mp3"), + receiver: None, + }); + + tui.update_downloads(); + + assert_eq!( + tui.state.downloads.len(), + 1, + "a local window's history must survive the tick" + ); +} + +#[test] +fn a_local_session_starts_from_its_own_snapshot_instead() { + // Locally the session knows no history and the state file is the whole + // record, so nothing is invented. + assert!(tui(Vec::new()).state.messages.is_empty()); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/settings.rs b/soulseek-rs/src/ui/main_tui/tests/settings.rs new file mode 100644 index 0000000..795267b --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/settings.rs @@ -0,0 +1,201 @@ +//! The settings popup: the account it names, the folders it applies, +//! and the password and logout it can reach. + +use super::*; + +/// Attached, both folders in the settings form name paths on the +/// *daemon's* machine: they go over the socket exactly as typed, and +/// nothing is created or validated against this machine's filesystem. +#[test] +fn applying_settings_attached_pushes_both_folders_to_the_daemon() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let scratch = tempfile::tempdir().expect("a temp dir"); + let config_path = scratch.path().join("config.toml"); + let mut tui = MainTui::new( + session.clone(), + "/daemon/old".to_string(), + Duration::from_secs(1), + None, + Some(config_path.clone()), + ); + + let daemon_only = scratch.path().join("only-on-the-daemon"); + let daemon_dir = daemon_only.to_str().expect("utf-8").to_string(); + tui.open_settings(); + { + let settings = tui.state.settings.as_mut().expect("open"); + settings.download_dir.clone_from(&daemon_dir); + settings.share_dirs = vec!["/not-here-either".to_string()]; + } + tui.apply_settings(); + + assert_eq!( + *session.download_dir_set.lock().expect("not poisoned"), + Some(daemon_dir.clone()), + "the folder is pushed to the daemon" + ); + assert_eq!( + *session.shares_set.lock().expect("not poisoned"), + Some(vec!["/not-here-either".to_string()]), + "shares go over as typed — the daemon's filesystem judges them" + ); + assert!( + !daemon_only.exists(), + "no directory is created on this machine for a daemon's path" + ); + assert_eq!(tui.download_dir, daemon_dir); + + let saved = crate::persist::config::FileConfig::load(&config_path) + .expect("config readable"); + assert_eq!( + saved.download_dir.as_deref(), + Some(daemon_dir.as_str()), + "the change still lands in config.toml for the next restart" + ); +} + +/// Locally the same form works on this machine: the folder is created +/// here and share paths that do not exist here are dropped before the +/// session hears about them. +#[test] +fn applying_settings_locally_stays_on_this_machine() { + let session = Arc::new(TalkativeSession { + shared: false, + ..TalkativeSession::default() + }); + let scratch = tempfile::tempdir().expect("a temp dir"); + let config_path = scratch.path().join("config.toml"); + let share = scratch.path().join("music"); + std::fs::create_dir_all(&share).expect("share dir"); + let mut tui = MainTui::new( + session.clone(), + "/tmp".to_string(), + Duration::from_secs(1), + None, + Some(config_path), + ); + + let downloads = scratch.path().join("downloads"); + let downloads_dir = downloads.to_str().expect("utf-8").to_string(); + tui.open_settings(); + { + let settings = tui.state.settings.as_mut().expect("open"); + settings.download_dir.clone_from(&downloads_dir); + settings.share_dirs = vec![ + share.to_str().expect("utf-8").to_string(), + "/definitely-not-a-real-share".to_string(), + ]; + } + tui.apply_settings(); + + // The seam is told in both modes; a local session accepts and takes + // the directory per download instead of storing it. + assert_eq!( + *session.download_dir_set.lock().expect("not poisoned"), + Some(downloads_dir.clone()) + ); + assert_eq!( + *session.shares_set.lock().expect("not poisoned"), + Some(vec![share.to_str().expect("utf-8").to_string()]), + "paths missing on this machine are dropped before applying" + ); + assert!(downloads.is_dir(), "the folder is created here"); + assert_eq!(tui.download_dir, downloads_dir); +} + +/// The account block answers "who is this session, and what is it +/// offering" without a trip to another screen. +#[test] +fn the_settings_popup_names_the_account_and_its_shares() { + let mut tui = with_session(TalkativeSession::default()); + press(&mut tui, KeyCode::Char('o')); + + let screen = screen_sized(&mut tui, 100, 30); + assert!(screen.contains("tester"), "{screen}"); + assert!(screen.contains("12 files in 3 folders"), "{screen}"); +} + +/// Typed twice the same way, the new password reaches the session — and +/// attached to a daemon it is the daemon that stores it. +#[test] +fn a_password_typed_twice_reaches_the_session() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let mut tui = attach(session.clone()); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Enter); // the change-password row is first + for _ in 0..2 { + for c in "hunter2".chars() { + press(&mut tui, KeyCode::Char(c)); + } + press(&mut tui, KeyCode::Enter); + } + + assert_eq!( + *session.password_set.lock().expect("not poisoned"), + Some("hunter2".to_string()) + ); + let settings = tui.state.settings.as_ref().expect("still open"); + assert_eq!(settings.status.as_deref(), Some("Password changed")); +} + +/// A mistyped repeat is caught here rather than on the server, where the +/// account would be left with a password nobody knows. +#[test] +fn a_password_typed_two_different_ways_is_not_sent() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let mut tui = attach(session.clone()); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Enter); + for typed in ["hunter2", "hunter3"] { + for c in typed.chars() { + press(&mut tui, KeyCode::Char(c)); + } + press(&mut tui, KeyCode::Enter); + } + + assert_eq!(*session.password_set.lock().expect("not poisoned"), None); +} + +/// Logging out ends the window, but as a log-out: the caller brings the +/// login screen back rather than returning to the shell. +#[test] +fn logging_out_asks_first_and_then_closes_the_window() { + let mut tui = with_session(TalkativeSession::default()); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Down); // change password -> log out + press(&mut tui, KeyCode::Enter); + assert!(tui.state.exit.is_none(), "the confirm comes first"); + + press(&mut tui, KeyCode::Char('y')); + assert_eq!(tui.state.exit, Some(TuiExit::Logout)); +} + +/// Attached, the login is the daemon's: there is no logout row to land +/// on, so the row below the password is the download folder. +#[test] +fn an_attached_window_offers_no_logout() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let mut tui = attach(session); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Down); + press(&mut tui, KeyCode::Enter); + + let settings = tui.state.settings.as_ref().expect("still open"); + assert_eq!( + settings.mode, + crate::models::SettingsMode::EditingDownloadDir + ); + assert!(tui.state.exit.is_none()); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/transfers.rs b/soulseek-rs/src/ui/main_tui/tests/transfers.rs new file mode 100644 index 0000000..6957d3c --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/transfers.rs @@ -0,0 +1,239 @@ +//! What the transfer lists do when a key lands on a row. + +use super::*; + +#[test] +fn x_cancels_the_highlighted_download() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session.clone()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: at_status( + "bob", + "song.mp3", + soulseek_rs::DownloadStatus::InProgress { + bytes_downloaded: 1, + total_bytes: 4096, + speed_bytes_per_sec: 1.0, + }, + ), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('x')); + + assert_eq!( + *session.download_cancelled.lock().expect("not poisoned"), + vec![("bob".to_string(), "song.mp3".to_string())] + ); + assert!( + session + .upload_cancelled + .lock() + .expect("not poisoned") + .is_empty(), + "the download row cancels no upload" + ); +} + +#[test] +fn shift_c_clears_the_whole_transfer_list() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session.clone()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "waiting.mp3"), + receiver: None, + }); + tui.state.downloads.push(crate::models::DownloadEntry { + download: at_status( + "carol", + "live.mp3", + soulseek_rs::DownloadStatus::InProgress { + bytes_downloaded: 1, + total_bytes: 4096, + speed_bytes_per_sec: 1.0, + }, + ), + receiver: None, + }); + tui.state.downloads.push(crate::models::DownloadEntry { + download: completed("dave", "done.mp3"), + receiver: None, + }); + tui.state.uploads.push(uploading("alice", "served.mp3")); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('C')); + + assert!(tui.state.downloads.is_empty(), "every row goes"); + assert_eq!( + *session.download_cancelled.lock().expect("not poisoned"), + vec![ + ("bob".to_string(), "waiting.mp3".to_string()), + ("carol".to_string(), "live.mp3".to_string()), + ], + "live downloads are cancelled; the finished one is not" + ); + assert_eq!( + *session.download_removed.lock().expect("not poisoned"), + vec![ + ("bob".to_string(), "waiting.mp3".to_string()), + ("carol".to_string(), "live.mp3".to_string()), + ("dave".to_string(), "done.mp3".to_string()), + ], + "the session forgets every download, not just this window's rows" + ); + assert_eq!( + *session.upload_cancelled.lock().expect("not poisoned"), + vec![("alice".to_string(), "served.mp3".to_string())], + "a streaming upload is cancelled too" + ); +} + +#[test] +fn d_clears_a_cancelled_row() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: at_status( + "bob", + "gone.mp3", + soulseek_rs::DownloadStatus::Cancelled, + ), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('d')); + + assert!(tui.state.downloads.is_empty(), "the row goes"); +} + +#[test] +fn x_on_a_finished_download_asks_nothing() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session.clone()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: completed("bob", "done.mp3"), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('x')); + + assert!( + session + .download_cancelled + .lock() + .expect("not poisoned") + .is_empty(), + "a finished download is not cancelled" + ); +} + +#[test] +fn x_on_an_upload_row_still_cancels_the_upload() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session.clone()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "song.mp3"), + receiver: None, + }); + tui.state.uploads.push(uploading("alice", "served.mp3")); + tui.state.downloads_table_state.select(Some(1)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('x')); + + assert_eq!( + *session.upload_cancelled.lock().expect("not poisoned"), + vec![("alice".to_string(), "served.mp3".to_string())] + ); + assert!( + session + .download_cancelled + .lock() + .expect("not poisoned") + .is_empty(), + "the upload row cancels no download" + ); +} + +#[test] +fn b_browses_the_user_of_the_highlighted_download() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "song.mp3"), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('b')); + + assert!(tui.state.show_browse, "the browse popup opens"); + assert_eq!( + tui.state.browse.active_tab().map(|b| b.username.as_str()), + Some("bob"), + "the download's user is the one browsed" + ); + assert!( + !tui.state.command_bar_active, + "b on a transfer goes straight to the user, not the prompt" + ); +} + +#[test] +fn b_on_an_upload_row_browses_that_user() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.uploads.push(uploading("alice", "served.mp3")); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('b')); + + assert_eq!( + tui.state.browse.active_tab().map(|b| b.username.as_str()), + Some("alice") + ); +} + +#[test] +fn d_clears_a_finished_row_but_not_a_running_one() { + // Finished transfers are what a list fills up with after a restart, so + // `d` has to be able to dismiss one — restored or not. A running + // transfer is not ours to discard, and stays put. + let mut tui = with_session(TalkativeSession::default()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: completed("bob", "done.mp3"), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('d')); + + assert!(tui.state.downloads.is_empty(), "the row goes too"); + assert_eq!(tui.state.downloads_table_state.selected(), None); + + tui.state.downloads.push(crate::models::DownloadEntry { + download: at_status( + "bob", + "still.mp3", + soulseek_rs::DownloadStatus::InProgress { + bytes_downloaded: 1, + total_bytes: 2, + speed_bytes_per_sec: 1.0, + }, + ), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + + tui.handle_key_event(key('d')); + + assert_eq!(tui.state.downloads.len(), 1, "it is not disposable"); +} From 5581fa6d10d82d8bd04fcf2da98f5c41e005883f Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 12:04:08 +0200 Subject: [PATCH 53/55] ci: back-merge master into develop after every release release-plz owns the workspace version and the changelog, and writes both on master. Nothing carried either back, so develop still declared 14.1.2 five releases after v14.1.2 shipped. Nightly builds the tip of develop, which meant every nightly binary reported --version 14.1.2 against a released 18.0.0, and the changelog on the branch the work happens on stopped at v14.1.2 too. The job merges master whole rather than cherry-picking the bump, so the changelog travels with it, and hangs off `release` rather than `binaries` so the sync does not wait on six binary builds. develop never edits those lines itself, which is what keeps the merge clean; a conflict fails the job rather than resolving itself quietly. Nothing in the repository triggers on a push to develop, so this neither loops back into the workflow nor spends a CI run. --- .github/workflows/release.yml | 44 +++++++++++++++++++++++++++++++++++ RELEASE.md | 9 ++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2826d61..ac7f993 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -222,3 +222,47 @@ jobs: exit 1 } gh workflow run formula.yml --repo michel/homebrew-tap + + # release-plz writes the version bump and the changelog on master, and until + # now nothing carried them back: develop was still declaring 14.1.2 five + # releases after v14.1.2 shipped. Nightly builds the tip of develop, so every + # nightly binary reported a version that had not been current for weeks, and + # the changelog on the branch the work happens on stopped at v14.1.2. + # + # Hangs off `release` rather than `binaries` so the sync does not wait on six + # binary builds, and merges master whole rather than cherry-picking the bump + # so that the changelog comes along with it. develop never edits those lines + # itself, which is what keeps the merge clean; on the day it does conflict + # this job fails loudly and the merge wants doing by hand. + # + # Nothing in this repository triggers on a push to develop, so pushing here + # neither loops back into this workflow nor spends a CI run. + backmerge: + name: Back-merge into develop + needs: release + if: ${{ !cancelled() && needs.release.outputs.tag != '' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: develop + token: ${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Merge master into develop + env: + TAG: ${{ needs.release.outputs.tag }} + run: | + # Spelled out rather than read from master's tip: this history carries + # one person's attribution, and whoever happened to click merge on the + # release PR is not the right source for it. + git config user.name "Michel de Graaf" + git config user.email "michel@re-invention.nl" + # checkout was pointed at develop, and which other tracking refs it + # leaves behind is its own business — ask for master by name. + git fetch origin master + # A manual binary rebuild reaches this job with develop already + # containing master; git says "Already up to date" and the push is a + # no-op, so the dispatch path needs no guard of its own. + git merge --no-ff origin/master -m "merge: back-merge $TAG into develop" + git push origin develop diff --git a/RELEASE.md b/RELEASE.md index e4839e5..86f9777 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -29,10 +29,17 @@ Releases are automated by [release-plz](https://release-plz.dev). You do not run | `soulseek-rs-vX.Y.Z-aarch64-pc-windows-msvc.zip` | | - rewrites `Formula/soulseek-rs.rb` in the `michel/homebrew-tap` repository so - `brew install michel/tap/soulseek-rs` picks up the new macOS and Linux archives. + `brew install michel/tap/soulseek-rs` picks up the new macOS and Linux archives, + - merges `master` back into `develop`, so the branch the work happens on carries the + new version and changelog rather than drifting behind them. Both crates always share one version, one tag, and one changelog — see `release-plz.toml`. +The version lives in one place, `[workspace.package]` in the root `Cargo.toml`, and +release-plz owns it. Do not edit it by hand on `develop`: the back-merge relies on +`develop` never touching that line, and an edit there turns every release into a +conflict. + ## Nightly builds `.github/workflows/nightly.yml` builds the tip of `develop` at 02:17 UTC every From 1b898adeeb4cb01bdc86de6b70293ec8ae00dd4d Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 12:08:00 +0200 Subject: [PATCH 54/55] docs: stop pinning a library version the instructions cannot keep current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four places told people to write `soulseek-rs-lib = "8"` (RELEASE.md said "6"), and every one of them had been left behind by ten majors or more. The failure is silent rather than loud: 6.x and 8.0.0 are both real published versions, so cargo resolves the snippet happily and hands over a library from August instead of refusing it, and one of the four is soulseek-rs-lib's own README, which is the page crates.io renders. `cargo add` writes whatever version is current at the time it runs, so there is no longer a number in the docs to go stale. The manifest pin in soulseek-rs/Cargo.toml stays as it is — release-plz owns that one and keeps it in step with the release. --- README.md | 2 +- RELEASE.md | 5 ++--- soulseek-rs-lib/README.md | 7 +++---- website/src/pages/install.tsx | 9 +-------- 4 files changed, 7 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index c2daa6a..db9eb9d 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ cargo install soulseek-rs # or from crates.io From source, `cargo build --release` leaves the binary at `target/release/soulseek-rs`. To build your own client on the protocol library, -add `soulseek-rs-lib = "8"` to your `Cargo.toml`. +run `cargo add soulseek-rs-lib`. ## Usage diff --git a/RELEASE.md b/RELEASE.md index 86f9777..b6356d0 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -104,7 +104,6 @@ verify it against the adjacent `.sha256`, and extract the binary. ## Using the library -```toml -[dependencies] -soulseek-rs-lib = "6" +```bash +cargo add soulseek-rs-lib ``` diff --git a/soulseek-rs-lib/README.md b/soulseek-rs-lib/README.md index 33a7756..6b41dc1 100644 --- a/soulseek-rs-lib/README.md +++ b/soulseek-rs-lib/README.md @@ -11,11 +11,10 @@ network. It can be used to build custom Soulseek clients. ## Usage -Add this to your `Cargo.toml`: +Add it to your `Cargo.toml`: -```toml -[dependencies] -soulseek-rs-lib = "8" +```bash +cargo add soulseek-rs-lib ``` ## Example diff --git a/website/src/pages/install.tsx b/website/src/pages/install.tsx index e14f800..f17cf70 100644 --- a/website/src/pages/install.tsx +++ b/website/src/pages/install.tsx @@ -357,14 +357,7 @@ const Steps = () => ( Write your own client on soulseek-rs-lib: the protocol implementation, separate from the TUI.

- +

Full API reference on docs.rs. The lib API From 35f50981ab6dbbb44a24a49db6e345829e65f56f Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 12:08:07 +0200 Subject: [PATCH 55/55] ci: redeploy the site when a release changes the version it displays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit website/vite.config.ts reads the root Cargo.toml at build time and stamps it into __APP_VERSION__, which the footer and the page chrome render. Pages only fired on `website/**`, and a release touches Cargo.toml and CHANGELOG.md and nothing else, so the site never rebuilt for one: the footer kept advertising the previous version until somebody happened to edit the website for other reasons. That it currently reads v18.0.0 is luck — a docs commit landed the day after the release. Cargo.toml is left out of the pull_request paths on purpose. A dependency bump changes nothing the site renders, and it is the deploy that needs the trigger. --- .github/workflows/pages.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index b583dab..500434c 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,9 +1,15 @@ name: Pages +# The site stamps its footer version out of the root Cargo.toml at build time +# (website/vite.config.ts reads it into __APP_VERSION__), so a release that only +# touches Cargo.toml has to redeploy or the footer keeps advertising the previous +# version until someone happens to edit the site. Cargo.toml is deliberately not +# in the pull_request paths: a dependency bump there changes nothing the site +# renders, and the deploy is what needs the trigger. on: push: branches: [master] - paths: ['website/**', '.github/workflows/pages.yml'] + paths: ['website/**', '.github/workflows/pages.yml', 'Cargo.toml'] pull_request: branches: ['**'] paths: ['website/**', '.github/workflows/pages.yml']