Merge branch 'develop'

This commit is contained in:
Michel de Graaf
2026-09-10 12:14:13 +02:00
134 changed files with 17512 additions and 5364 deletions
+20
View File
@@ -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"
+7
View File
@@ -110,6 +110,13 @@ jobs:
SOULSEEK_E2E_REQUIRED: "1"
run: cargo test -p soulseek-rs --test cli_e2e -- --nocapture
# The window over a real session: keys in, a screen out, a peer on the
# other end. Nothing else opens the TUI against a server.
- name: Run TUI e2e tests (required against soulfind)
env:
SOULSEEK_E2E_REQUIRED: "1"
run: cargo test -p soulseek-rs --test tui_e2e -- --nocapture
# The control protocol as a third party meets it: the TCP transport, the
# token, and the gate. None of it is reachable through the command
# surface, so the CLI suite cannot cover it in either mode.
+249
View File
@@ -0,0 +1,249 @@
name: Nightly
on:
schedule:
- cron: '17 2 * * *'
workflow_dispatch:
permissions:
contents: read
# A manual run queued behind the scheduled run must still execute: it is the
# recovery path when a runner or GitHub Releases had a transient failure.
concurrency:
group: nightly
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
jobs:
source:
name: Resolve develop
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
sha: ${{ steps.commit.outputs.sha }}
steps:
# workflow_dispatch has a branch selector, but a Nightly run always means
# develop. Resolve it once so every target builds the identical commit
# even if develop moves while the matrix is starting.
- uses: actions/checkout@v4
with:
ref: develop
persist-credentials: false
- name: Record source commit
id: commit
shell: bash
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
binaries:
name: ${{ matrix.target }}
needs: source
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
- target: aarch64-unknown-linux-musl
os: ubuntu-24.04-arm
- target: x86_64-apple-darwin
os: macos-latest
- target: aarch64-apple-darwin
os: macos-latest
- target: x86_64-pc-windows-msvc
os: windows-latest
- target: aarch64-pc-windows-msvc
os: windows-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.source.outputs.sha }}
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: nightly-${{ matrix.target }}
- name: Install musl toolchain
if: endsWith(matrix.target, '-musl')
run: sudo apt-get update && sudo apt-get install -y musl-tools
# Dry-run produces the same archives and checksums as the stable release
# workflow without publishing them one matrix job at a time. The publish
# job exposes the complete, verified set together.
- name: Build archive and checksum
id: package
uses: taiki-e/upload-rust-binary-action@v1
with:
bin: soulseek-rs
target: ${{ matrix.target }}
build-tool: cargo
locked: true
archive: soulseek-rs-nightly-$target
include: README.md,LICENSE
checksum: sha256
ref: refs/tags/nightly
dry-run: true
dry-run-intended: true
- name: Store packaged binary
uses: actions/upload-artifact@v4
with:
name: nightly-${{ matrix.target }}
path: |
${{ steps.package.outputs.tar || steps.package.outputs.zip }}
${{ steps.package.outputs.sha256 }}
if-no-files-found: error
retention-days: 1
publish:
name: Publish prerelease
needs: [source, binaries]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Collect packaged binaries
uses: actions/download-artifact@v4
with:
pattern: nightly-*
path: dist
merge-multiple: true
- name: Verify the complete asset set
shell: bash
run: |
set -euo pipefail
expected=(
soulseek-rs-nightly-x86_64-unknown-linux-musl.tar.gz
soulseek-rs-nightly-aarch64-unknown-linux-musl.tar.gz
soulseek-rs-nightly-x86_64-apple-darwin.tar.gz
soulseek-rs-nightly-aarch64-apple-darwin.tar.gz
soulseek-rs-nightly-x86_64-pc-windows-msvc.zip
soulseek-rs-nightly-aarch64-pc-windows-msvc.zip
soulseek-rs-nightly-x86_64-unknown-linux-musl.sha256
soulseek-rs-nightly-aarch64-unknown-linux-musl.sha256
soulseek-rs-nightly-x86_64-apple-darwin.sha256
soulseek-rs-nightly-aarch64-apple-darwin.sha256
soulseek-rs-nightly-x86_64-pc-windows-msvc.sha256
soulseek-rs-nightly-aarch64-pc-windows-msvc.sha256
)
printf '%s\n' "${expected[@]}" | sort > "$RUNNER_TEMP/expected-assets"
find dist -maxdepth 1 -type f -exec basename {} \; | sort > "$RUNNER_TEMP/actual-assets"
diff -u "$RUNNER_TEMP/expected-assets" "$RUNNER_TEMP/actual-assets"
(cd dist && sha256sum --check ./*.sha256)
- name: Publish the nightly release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
SOURCE_SHA: ${{ needs.source.outputs.sha }}
shell: bash
run: |
set -euo pipefail
notes="$RUNNER_TEMP/nightly-notes.md"
short_sha=${SOURCE_SHA:0:12}
{
printf 'Automated development build from the develop branch.\n\n'
printf 'Source: [%s](https://github.com/%s/commit/%s)\n\n' "$short_sha" "$GITHUB_REPOSITORY" "$SOURCE_SHA"
printf 'This prerelease and its nightly tag are replaced by each successful scheduled or manual run. It may be less stable than a numbered release.\n'
} > "$notes"
release_exists=false
if gh release view nightly >/dev/null 2>&1; then
release_exists=true
# Hide the old set while assets and the mutable tag are replaced;
# installers either see a complete release or fail before changing
# their existing binary.
gh release edit nightly --draft --prerelease --latest=false
fi
# 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
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
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 \
--title 'Nightly (develop)' --notes-file "$notes"
else
gh release edit nightly --draft --prerelease --latest=false \
--target "$SOURCE_SHA" --title 'Nightly (develop)' --notes-file "$notes"
fi
gh release view nightly --json assets --jq '.assets[].name' > "$RUNNER_TEMP/old-assets"
while IFS= read -r asset; do
[ -z "$asset" ] || gh release delete-asset nightly "$asset" --yes
done < "$RUNNER_TEMP/old-assets"
gh release upload nightly dist/*
gh release view nightly --json assets --jq '.assets[].name' | sort > "$RUNNER_TEMP/published-assets"
diff -u "$RUNNER_TEMP/expected-assets" "$RUNNER_TEMP/published-assets"
empty_assets=$(gh release view nightly --json assets --jq '[.assets[] | select(.size == 0)] | length')
[ "$empty_assets" -eq 0 ]
gh release edit nightly --draft=false --prerelease --latest=false \
--target "$SOURCE_SHA" --title 'Nightly (develop)' --notes-file "$notes"
smoke-test:
name: Install published nightly
needs: publish
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
ref: nightly
persist-credentials: false
- name: Install with curl
env:
SOULSEEK_RS_INSTALL_DIR: ${{ runner.temp }}/curl-install
run: |
sh website/public/install.sh --nightly
"$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version
- name: Install with wget and no curl on PATH
env:
SOULSEEK_RS_INSTALL_DIR: ${{ runner.temp }}/wget-install
shell: bash
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/wget-path"
for tool in basename cat chmod cp gzip mkdir mktemp mv rm sha256sum tar uname wget; do
ln -s "$(command -v "$tool")" "$RUNNER_TEMP/wget-path/$tool"
done
PATH="$RUNNER_TEMP/wget-path" /bin/sh website/public/install.sh --nightly
"$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version
+10 -1
View File
@@ -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']
@@ -30,6 +36,9 @@ jobs:
- name: Install
run: bun install --frozen-lockfile
- name: Test installer
run: bun run test:install
- name: Typecheck
run: bun run typecheck
+44
View File
@@ -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
+3
View File
@@ -1 +1,4 @@
/target
# Editor and assistant scratch, not repository content
.claude/
+6
View File
@@ -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 <michel@re-invention.nl> <noreply@anthropic.com>
+49
View File
@@ -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.
+111 -33
View File
@@ -10,12 +10,19 @@
Search the network, share your files, browse someone's collection, join a room.
It runs over ssh on the machine where your music already lives.
Soulseek is a closed-source P2P network from the 2000s, still used by music
enthusiasts to share niche music. This repository is that client plus
[`soulseek-rs-lib`](./soulseek-rs-lib), the protocol library under it.
[`soulseek-rs-lib`](./soulseek-rs-lib), the protocol library under it. Check the website: **[re-invention.nl/soulseek-rs](https://re-invention.nl/soulseek-rs/)** for more information/documentation.
***Quick install:***
`curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh`
**[re-invention.nl/soulseek-rs](https://re-invention.nl/soulseek-rs/)**: what it
does, how to install it, and every `config.toml` setting.
## Demo
@@ -23,6 +30,10 @@ does, how to install it, and every `config.toml` setting.
## 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
@@ -58,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
@@ -69,7 +87,27 @@ project is identifiable on the network.
## Installation
Homebrew ships a prebuilt binary, so no Rust toolchain:
The installer picks the right macOS or Linux binary, verifies its sha256, and
installs the latest stable release (through Homebrew when it is available):
```bash
curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh
```
To install the current successful build from `develop` instead, pass the
installer's `--nightly` option through to `sh`:
```bash
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**
workflow is run by hand. It bypasses Homebrew, and may be less stable than a
numbered release. A failed download, checksum, or startup check leaves an
existing binary untouched. Windows archives for both channels are on the
[releases page](https://github.com/michel/soulseek-rs/releases).
The other stable routes need no installer script:
```bash
brew install michel/tap/soulseek-rs # macOS and Linux
@@ -78,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
@@ -92,14 +130,23 @@ 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
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, `w` hides it so the others widen
(its number brings it back), and `W` puts the whole layout 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.
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
@@ -277,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 <NAME> # a peer's status and share counts
soulseek-rs shares list|add|remove|status|reindex
soulseek-rs config path|list|get|set
@@ -294,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
@@ -328,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
@@ -504,6 +554,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 |
@@ -519,10 +570,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
@@ -546,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.
@@ -565,7 +636,13 @@ unread counter. `c` opens the chat-rooms popup: a `/`-filterable room list
busiest first, `Enter` to join, several rooms open at once as tabs
(`Tab`/`Shift-Tab` to switch, `x` to leave, `l` back to the list), and `↑`/`↓`
through the member list with `b` to browse someone or `m` to message them.
Unread counts appear on the tabs and on the `c chat (n)` shortcut.
`/` searches the open room's log and `u` its member list; the inbox takes `/`
too. Unread counts appear on the tabs and on the `c chat (n)` shortcut.
`b` browses a user's shared files as a tree: `←`/`→` close and open a folder,
`J`/`K` jump between folders, `H`/`L` close or open every folder, `/` filters
the whole tree by path, and `Enter` or `d` downloads a file or a folder's
files (under a filter, only the ones it shows).
### Connectivity
@@ -606,18 +683,19 @@ cargo fmt
### End-to-end tests
Two suites run against [soulfind](https://github.com/soulfind-dev/soulfind), a
local Soulseek server: `soulseek-rs-lib/tests/e2e.rs` covers the protocol
library, and `soulseek-rs/tests/cli_e2e.rs` drives the binary the way a script
would. Both are **server-optional**, running when a server is available and
skipping otherwise, so `cargo test` stays green everywhere.
Four suites run against [soulfind](https://github.com/soulfind-dev/soulfind),
a local Soulseek server: `soulseek-rs-lib/tests/e2e.rs` covers the protocol
library, `soulseek-rs/tests/cli_e2e.rs` drives the binary the way a script
would, `soulseek-rs/tests/daemon_protocol_e2e.rs` meets the daemon's control
protocol as a third party would, and `soulseek-rs/tests/tui_e2e.rs` drives the
window with key presses and reads the screen back. All are
**server-optional**, running when a server is available and skipping
otherwise, so `cargo test` stays green everywhere.
They locate a server in this order:
1. `SOULSEEK_TEST_SERVER=host:port`: connect to an already-running server, or
2. `SOULFIND_BIN=/path/to/soulfind` (or a `soulfind/bin/soulfind` checkout in a
parent directory), which spawns soulfind on an ephemeral port with a
throwaway database.
Every suite spawns soulfind from `SOULFIND_BIN=/path/to/soulfind` (or a
`soulfind/bin/soulfind` checkout in a parent directory) on an ephemeral port
with a throwaway database. The library and CLI suites take
`SOULSEEK_TEST_SERVER=host:port` first, to run against a server already up.
```bash
# Build soulfind once (see its BUILDING.md), then:
@@ -643,8 +721,8 @@ soulfind instead of skipping.
`cargo clippy --workspace --all-targets -- -D warnings`.
- **Test**: `cargo test --verbose` on Linux, macOS, and Windows.
- **End-to-end**: builds soulfind from source (LDC + `dub build :server`),
points `SOULFIND_BIN` at it, and runs both the library and CLI e2e suites
with `SOULSEEK_E2E_REQUIRED=1`, so a missing server fails instead of skipping.
points `SOULFIND_BIN` at it, and runs all four e2e suites with
`SOULSEEK_E2E_REQUIRED=1`, so a missing server fails instead of skipping.
## Contributing
+29 -4
View File
@@ -29,10 +29,36 @@ 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
day. It resolves the commit once, builds the same six targets as a numbered
release, verifies all twelve archives and checksum files as one set, then
replaces the mutable `nightly` prerelease and tag. The published release is
smoke-tested through both the curl and wget installer paths.
Use **Actions → Nightly → Run workflow** to force a build. The workflow always
checks out `develop`, regardless of the ref selected in the dispatch form. The
equivalent command is:
```bash
gh workflow run nightly.yml --ref develop
```
Nightly does not publish crates or update Homebrew. If a target fails, the
previous nightly release stays available; use a manual run to retry.
## One-time repository setup
- **Settings → Actions → General → Workflow permissions**: enable *Allow GitHub Actions to
@@ -78,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
```
+7
View File
@@ -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.
+132
View File
@@ -0,0 +1,132 @@
# 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 — 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 |
| 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 | 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 | 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 |
| 143 / 144 | AddRoomOperator / Remove | yes | e2e — the guest is promoted |
| 145 / 146 | RoomOperatorshipGranted / Revoked | yes | e2e — the guest is told |
| 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 |
| 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.
## 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.
+1 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 930 KiB

After

Width:  |  Height:  |  Size: 926 KiB

+3 -4
View File
@@ -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
+11
View File
@@ -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()
@@ -1026,6 +1036,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");
+47 -6
View File
@@ -1,12 +1,13 @@
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, 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 +48,14 @@ pub enum PeerMessage {
ShareListRequested,
/// A peer we are browsing sent us their shared-file listing (code 5).
ShareListReceived(Vec<SharedDirectory>),
/// 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,
folder: String,
directories: Vec<SharedDirectory>,
},
/// 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,7 +208,9 @@ 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(UserInfoResponseHandler);
handlers.register_handler(UploadDeniedHandler);
handlers.register_handler(UploadFailedHandler);
handlers.register_handler(PlaceInQueueRequest);
@@ -310,6 +321,24 @@ impl PeerActor {
directories,
});
}
PeerMessage::UserInfoReceived(info) => {
self.forward(ClientOperation::PeerInfoReceived {
username: self.peer_username(),
info,
});
}
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(),
@@ -729,8 +758,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,
@@ -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:?}"),
+19 -1
View File
@@ -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::<Vec<_>>(),
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() {
@@ -427,7 +445,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();
+129 -359
View File
@@ -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;
@@ -24,16 +26,28 @@ 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,
};
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;
@@ -45,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};
@@ -164,6 +179,96 @@ 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<crate::types::RoomTicker>,
},
/// 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<Recommendation>,
unrecommended: Vec<Recommendation>,
},
/// Server-wide recommendations (code 56).
GlobalRecommendationsReceived {
recommended: Vec<Recommendation>,
unrecommended: Vec<Recommendation>,
},
/// Recommendations for one item (code 111).
ItemRecommendationsReceived {
item: String,
recommendations: Vec<Recommendation>,
},
/// Users similar to us (code 110).
SimilarUsersReceived {
users: Vec<SimilarUser>,
},
/// Users who like one item (code 112).
ItemSimilarUsersReceived {
item: String,
usernames: Vec<String>,
},
/// 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,
},
/// Phrases the server refuses to search for (code 160).
ExcludedSearchPhrases(Vec<String>),
/// 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,
users: Vec<String>,
},
/// Who runs a private room (code 148).
PrivateRoomOperators {
room: String,
users: Vec<String>,
},
/// 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 {
@@ -320,6 +425,28 @@ impl ServerActor {
handlers.register_handler(FileSearchHandler);
handlers.register_handler(GetPeerAddressHandler);
handlers.register_handler(ConnectToPeerHandler);
handlers.register_handler(CantConnectToPeerHandler);
handlers.register_handler(RoomMembersHandler);
handlers.register_handler(RoomOperatorsHandler);
// 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);
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(),
@@ -345,363 +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 });
}
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());
}
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);
}
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<Result<bool, SoulseekRs>>,
) {
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 {
username,
token,
query,
})
{
error!("[server] forward IncomingSearch: {}", e);
}
}
fn process_read(&mut self) {
if self.reader.buffer_len() > 0 {
self.extract_and_process_messages();
@@ -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<Result<bool, SoulseekRs>>,
) {
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);
}
}
}
@@ -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");
}
+430
View File
@@ -0,0 +1,430 @@
//! 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.
//!
//! 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.
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+ 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 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<u32>,
min_speed: u32,
ratio: u32,
) -> Option<usize> {
if ratio == 0 {
return None;
}
let speed = own_speed?;
if 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<String, TcpStream>,
/// 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 {
fn default() -> Self {
Self::new(false)
}
}
impl Children {
#[must_use]
pub fn new(accepting: bool) -> Self {
Self {
links: HashMap::new(),
max: MAX_CHILDREN,
accepting,
fed: false,
}
}
/// 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<u32>,
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;
}
#[must_use]
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]
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<String> {
let mut names: Vec<String> = self.links.keys().cloned().collect();
names.sort();
names
}
/// 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.has_room() {
return false;
}
// 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 carried",
username
);
return false;
}
self.links.insert(username.to_string(), stream);
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);
}
}
/// 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.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)
}
/// 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<u8> {
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);
children.set_fed(true);
assert!(!children.accept("alice", ours));
assert!(children.is_empty());
assert!(!children.has_room());
}
#[test]
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);
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();
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_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_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),
None,
"a server that never announced a ratio does not decide the limit"
);
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");
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);
}
#[test]
fn a_search_reaches_every_child() {
let mut children = serving();
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 = serving();
let (child, ours) = pair();
children.accept("alice", ours);
drop(child);
// 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");
}
#[test]
fn a_child_is_told_where_our_branch_sits() {
let mut children = serving();
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);
}
}
+91 -15
View File
@@ -1,9 +1,10 @@
use super::{
Arc, Client, ClientContext, ClientOperation, ConnectionType, DownloadPeer,
DownloadStatus, Listen, Peer, PeerRegistry, Receiver, Result, RwLock,
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,
);
});
}
@@ -133,6 +142,42 @@ 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 = 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.
///
/// The wait is bounded: the actor only answers once it has a working
@@ -240,6 +285,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<RwLock<ClientContext>>,
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<RwLock<ClientContext>>,
@@ -282,6 +346,8 @@ impl Client {
own_username,
);
let brokered = peer_clone.brokered;
let username = peer_clone.username;
match download_peer.download_file(
client_context.clone(),
None,
@@ -292,25 +358,35 @@ impl Client {
"[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);
// 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,
);
}
}
}
}
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,
);
}
}
}
}
+761
View File
@@ -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<String> {
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<String> {
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<bool> {
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<u32> {
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<crate::message::peer::PeerInfo> {
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<SharedDirectory>,
) {
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<Vec<SharedDirectory>> {
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<String> {
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<String> {
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<String> {
let mut rooms: Vec<String> =
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<RoomTicker> {
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<Recommendation>,
unrecommended: Vec<Recommendation>,
) {
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<Recommendation>, Vec<Recommendation>)> {
if global {
self.global_recommendations.clone()
} else {
self.recommendations.clone()
}
}
pub fn apply_item_recommendations(
&mut self,
item: String,
recommendations: Vec<Recommendation>,
) {
self.item_recommendations.insert(item, recommendations);
}
#[must_use]
pub fn item_recommendations(&self, item: &str) -> Vec<Recommendation> {
self.item_recommendations
.get(item)
.cloned()
.unwrap_or_default()
}
pub fn apply_similar_users(&mut self, users: Vec<SimilarUser>) {
self.similar_users = users;
}
#[must_use]
pub fn similar_users(&self) -> Vec<SimilarUser> {
self.similar_users.clone()
}
pub fn apply_item_similar_users(
&mut self,
item: String,
usernames: Vec<String>,
) {
self.item_similar_users.insert(item, usernames);
}
#[must_use]
pub fn item_similar_users(&self, item: &str) -> Vec<String> {
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<UserInterests> {
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<String>) {
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<String> {
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<String> {
let mut users: Vec<String> =
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<u32>,
average_speed: Option<u32>,
shared_files: Option<u32>,
shared_folders: Option<u32>,
) {
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<UserInfo> {
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<String> {
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<RoomUserStats>,
) {
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<RoomUserStats> {
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<RoomInfo> {
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<RoomEvent> {
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<crate::message::Message> {
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<SharedDirectory>,
) {
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<Vec<SharedDirectory>> {
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<String> {
self.pending_connect_tokens
.remove(&token)
.map(|(username, _)| username)
}
pub fn take_expired_connects(&mut self, now: Instant) -> Vec<String> {
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<String> {
let mut protected: HashSet<String> = 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<UserMessage> {
std::mem::take(&mut self.private_messages)
}
}
+26 -2
View File
@@ -297,15 +297,39 @@ pub fn announce(
server: Option<&Sender<ServerMessage>>,
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();
// What the server needs to know is whether it may offer us to another
// 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);
}
/// 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<ClientOperation>) {
@@ -668,7 +692,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<crate::message::Message> = Vec::new();
while let Ok(ServerMessage::SendMessage(m)) = rx.try_recv() {
sent.push(m);
+192 -388
View File
@@ -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::{
@@ -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<crate::message::Message> {
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<FileEntry> = 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,
@@ -191,6 +219,11 @@ pub struct ClientSettings {
/// Directories whose files are shared with (uploaded to) other peers.
/// Empty means nothing is shared.
pub shared_directories: Vec<String>,
/// 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 +256,7 @@ impl Default for ClientSettings {
enable_listen: true,
listen_port: DEFAULT_LISTEN_PORT,
shared_directories: Vec::new(),
accept_children: false,
version: ClientVersion::default(),
}
}
@@ -281,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.
@@ -306,6 +344,18 @@ 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,
token: u32,
folder: String,
directories: Vec<SharedDirectory>,
},
/// A peer we are browsing returned their shared-file listing.
BrowseResult {
username: String,
@@ -313,8 +363,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<u32>),
/// Something happened in the chat-room subsystem (list refreshed, a room
/// joined/left, a message said, a member joined/left).
RoomEvent(RoomEvent),
@@ -340,6 +393,46 @@ 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 {
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 {
global: bool,
recommended: Vec<Recommendation>,
unrecommended: Vec<Recommendation>,
},
/// Recommendations for one item (code 111).
ItemRecommendations {
item: String,
recommendations: Vec<Recommendation>,
},
/// Users the server considers similar to us (code 110).
SimilarUsers(Vec<SimilarUser>),
/// Users who like one item (code 112).
ItemSimilarUsers {
item: String,
usernames: Vec<String>,
},
/// 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,
},
/// Phrases the server refuses to search for (code 160).
ExcludedSearchPhrases(Vec<String>),
/// 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 {
@@ -375,6 +468,18 @@ pub struct ClientContext {
operations: Option<Sender<ClientOperation>>,
/// 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,
/// 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<bool>,
/// The upload speed the server records for us, once it has told us. The
/// child limit is derived from it.
own_average_speed: Option<u32>,
/// 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<String, Search>,
private_messages: Vec<UserMessage>,
/// Correlation tokens for server-brokered (firewalled) connections, mapping
@@ -399,6 +504,10 @@ pub struct ClientContext {
pending_serves: HashMap<String, Vec<u32>>,
/// Shared-file listings received from peers we browsed.
browse_results: HashMap<String, Vec<SharedDirectory>>,
/// One-folder listings received from peers, keyed by peer and folder.
folder_contents: HashMap<(String, String), Vec<SharedDirectory>>,
/// What peers said about themselves (peer code 16), keyed by peer.
peer_infos: HashMap<String, crate::message::peer::PeerInfo>,
pending_browses: HashMap<String, Instant>,
/// Latest snapshot of the public chat-room list (from `RoomList`, code 64).
room_list: Vec<RoomInfo>,
@@ -410,6 +519,31 @@ 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<String, Vec<RoomUserStats>>,
/// Who may enter each private room we belong to (code 133), kept current
/// from the later roster changes.
private_room_members: HashMap<String, Vec<String>>,
/// Who runs each of those rooms (code 148).
private_room_operators: HashMap<String, Vec<String>>,
/// 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<String, Vec<RoomTicker>>,
/// The latest recommendations from our own interests (code 54), as
/// (recommended, recommended-against).
recommendations: Option<(Vec<Recommendation>, Vec<Recommendation>)>,
/// The latest server-wide recommendations (code 56).
global_recommendations: Option<(Vec<Recommendation>, Vec<Recommendation>)>,
/// Per-item recommendations (code 111), keyed by the item asked about.
item_recommendations: HashMap<String, Vec<Recommendation>>,
/// The latest similar-user answer (code 110).
similar_users: Vec<SimilarUser>,
/// Who likes an item (code 112), keyed by the item asked about.
item_similar_users: HashMap<String, Vec<String>>,
/// Interests of other users (code 57), keyed by username.
user_interests: HashMap<String, UserInterests>,
/// 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<String, UserInfo>,
@@ -425,6 +559,9 @@ pub struct ClientContext {
privileged_users: HashSet<String>,
/// Seconds of our own privileges left (code 92), once we have asked.
own_privileges: Option<u32>,
/// 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<String>,
/// Peers waiting for one of our upload slots.
upload_queue: Vec<QueuedUpload>,
/// Arrival counter for the queue's first-come tie-break.
@@ -441,6 +578,15 @@ pub struct ClientContext {
upload_events: Vec<crate::types::UploadInfo>,
actor_system: Arc<ActorSystem>,
}
/// 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<String> {
let mut users = users.to_vec();
users.sort();
users.dedup();
users
}
impl Default for ClientContext {
fn default() -> Self {
Self::new()
@@ -483,386 +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(""),
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(),
pending_browses: HashMap::new(),
room_list: Vec::new(),
room_events: Vec::new(),
room_members: HashMap::new(),
room_member_stats: HashMap::new(),
user_info: HashMap::new(),
watched_users: HashSet::new(),
wishlist_interval: None,
privileged_users: HashSet::new(),
own_privileges: None,
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);
}
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::Message { .. } => {}
}
self.room_events.push(event);
}
/// 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<String> {
let mut users: Vec<String> =
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<u32>,
average_speed: Option<u32>,
shared_files: Option<u32>,
shared_folders: Option<u32>,
) {
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<UserInfo> {
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<String> {
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<RoomUserStats>,
) {
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<RoomUserStats> {
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<RoomInfo> {
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<RoomEvent> {
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<crate::message::Message> {
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<SharedDirectory>,
) {
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<Vec<SharedDirectory>> {
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<String> {
self.pending_connect_tokens
.remove(&token)
.map(|(username, _)| username)
}
pub fn take_expired_connects(&mut self, now: Instant) -> Vec<String> {
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<String> {
let mut protected: HashSet<String> = 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<UserMessage> {
std::mem::take(&mut self.private_messages)
}
}
pub struct Client {
enable_listen: bool,
listen_port: u16,
@@ -877,6 +643,15 @@ pub struct Client {
server_handle: Option<ActorHandle<ServerMessage>>,
context: Arc<RwLock<ClientContext>>,
session: SessionWatch,
/// Tells the peer listener to stop, so a disconnected client releases the
/// port it bound.
listener_stopped: Arc<AtomicBool>,
}
impl Drop for Client {
fn drop(&mut self) {
self.disconnect();
}
}
impl Client {
@@ -890,20 +665,35 @@ 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,
shared_directories: settings.shared_directories,
server_handle: None,
session: SessionWatch::default(),
listener_stopped: Arc::new(AtomicBool::new(false)),
}
}
/// 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<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.children.usernames(),
Err(e) => {
error!("[client] children: {}", e);
Vec::new()
}
}
}
@@ -939,6 +729,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<crate::message::peer::SharedDirectory> {
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) {
@@ -1044,12 +845,15 @@ impl Client {
}
}
mod children;
mod connection;
mod context;
mod distributed;
mod downloads;
mod operations;
mod rooms;
mod search;
mod social;
mod upload_queue;
mod uploads;
@@ -8,8 +8,24 @@ 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
/// (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_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
/// 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<ClientOperation>,
@@ -18,6 +34,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 {
@@ -29,14 +46,16 @@ 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,
);
}
}
}
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,
@@ -84,175 +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.read_safe() {
Ok(ctx) => {
ctx.get_download_by_token(token).cloned()
}
Err(e) => {
error!("[client] DownloadFromPeer read: {}", e);
continue;
}
};
let own_username = own_username.clone();
let client_context_clone = client_context.clone();
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 {
error!(
"Can't find download with token {:?}",
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,
},
);
}
thread::spawn(move || {
let download_peer = DownloadPeer::new(
download.username.clone(),
peer.host.clone(),
peer.port,
token,
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(),
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
);
}
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,
peer.host,
peer.port,
download.token,
e
);
}
}
});
}
ClientOperation::GetPeerAddressResponse {
username,
@@ -353,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(),
@@ -491,17 +287,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,
@@ -509,19 +323,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),
@@ -591,95 +422,12 @@ impl Client {
}
}
}
ClientOperation::IncomingSearch {
username,
token,
query,
} => {
// 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,
),
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,
@@ -725,96 +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(
ctx.server_sender.as_ref(),
&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(
ctx.server_sender.as_ref(),
&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(
ctx.server_sender.as_ref(),
&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,
});
}
}
ClientOperation::ParentClosed { parent, link } => {
let Ok(mut ctx) = client_context.write_safe() else {
continue;
};
if ctx.leaf.closed(&parent, link) {
super::distributed::announce(
ctx.server_sender.as_ref(),
&ctx.leaf.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();
super::distributed::announce(
ctx.server_sender.as_ref(),
&ctx.leaf.branch(),
false,
);
| ClientOperation::ParentClosed { .. }
| ClientOperation::ResetDistributed) => {
Self::on_tree(operation, &client_context);
}
ClientOperation::PrivilegedUsers(users) => {
if let Ok(mut ctx) = client_context.write_safe() {
@@ -834,47 +502,145 @@ impl Client {
ctx.own_privileges = Some(seconds);
}
}
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()
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::PeerInfoReceived { username, info } => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.store_peer_info(username, info);
}
}
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::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(mut ctx) => {
ctx.mark_offer_answered(token);
let Some(job) = ctx.uploads.get(&token) else {
continue;
};
(
ctx.peer_address(&job.downloader),
job.downloader.clone(),
)
Ok(ctx) => {
(ctx.server_sender.clone(), ctx.own_interests())
}
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),
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),
));
}
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);
}
}
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::ShareListRequested { requester_key } => {
@@ -932,57 +698,63 @@ impl Client {
ctx.store_browse_result(username, directories);
}
}
ClientOperation::PeerConnectFailed(id, username) => {
// 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));
}
}
}
});
}
/// 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<RwLock<ClientContext>>) {
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::<Vec<_>>(),
),
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<RwLock<ClientContext>>) {
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 };
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<RwLock<ClientContext>>,
@@ -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<RwLock<ClientContext>>,
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.
_ => {}
}
}
}
@@ -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<RwLock<ClientContext>>,
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));
}
}
}
}
}
@@ -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<RwLock<ClientContext>>,
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.
_ => {}
}
}
}
@@ -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<RwLock<ClientContext>>,
) {
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.
_ => {}
}
}
}
+23 -6
View File
@@ -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()
@@ -250,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(())
}
+61 -10
View File
@@ -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 }
@@ -81,6 +72,66 @@ 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<u32> {
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)
}
/// 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<String> {
self.context
.read_safe()
.map(|ctx| ctx.excluded_search_phrases())
.unwrap_or_default()
}
/// Let responses accumulate for `timeout`, or until cancelled.
///
/// Peers answer a search over their own connections whenever they get round
+542
View File
@@ -0,0 +1,542 @@
//! 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())
}
/// 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<String> {
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<String> {
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<String> {
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
/// 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` 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<crate::message::peer::PeerInfo> {
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.
///
/// 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<Vec<crate::SharedDirectory>> {
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
/// 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.
///
/// # 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<RoomTicker> {
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.
///
/// 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<()> {
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))
}
/// Drop an interest (code 52).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn remove_interest(&self, item: &str) -> Result<()> {
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,
))
}
/// Add a dislike (code 117).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn add_dislike(&self, item: &str) -> Result<()> {
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))
}
/// Drop a dislike (code 118).
///
/// # Errors
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
pub fn remove_dislike(&self, item: &str) -> Result<()> {
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,
))
}
/// 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 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<u32> {
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.
#[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]
pub fn recommendations(
&self,
) -> Option<(Vec<Recommendation>, Vec<Recommendation>)> {
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<Recommendation>, Vec<Recommendation>)> {
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<Recommendation> {
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<SimilarUser> {
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<String> {
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<UserInterests> {
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,
))
}
}
-838
View File
@@ -1,838 +0,0 @@
use super::*;
fn download(
username: &str,
filename: &str,
token: u32,
status: DownloadStatus,
sender: Sender<DownloadStatus>,
) -> 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,
"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 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 (ops_tx, ops_rx) = mpsc::channel();
Client::listen_to_client_operations(
ops_rx,
client.context,
"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"
);
}
#[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,
"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<String> = 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");
}
+52
View File
@@ -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<DownloadStatus>,
) -> 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};
+173
View File
@@ -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");
}
+297
View File
@@ -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<String> = 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"
);
}
+167
View File
@@ -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);
}
@@ -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
));
}
+200
View File
@@ -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"
);
}
+79 -1
View File
@@ -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<Download> {
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();
+8 -6
View File
@@ -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,
};
+7 -2
View File
@@ -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<Message> {
pub fn stance(
root: &str,
level: u32,
has_parent: bool,
accept_children: bool,
) -> Vec<Message> {
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),
]
}
@@ -26,6 +26,37 @@ impl MessageHandler<PeerMessage> 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<PeerMessage> for FolderContentsResponseHandler {
fn get_code(&self) -> u32 {
37
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
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(
@@ -48,7 +79,7 @@ pub fn build_folder_contents(
pub fn parse_folder_contents(
message: &mut Message,
) -> Option<(u32, String, Vec<SharedDirectory>)> {
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)))
+7 -2
View File
@@ -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;
@@ -32,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,
};
@@ -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<SharedDirectory> {
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<SharedDirectory> {
dirs
}
/// Inflate a compressed peer payload positioned at the blob.
pub fn decompress_body(message: &mut Message) -> Option<Message> {
/// 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<Message> {
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)
}
@@ -15,6 +15,71 @@ impl MessageHandler<PeerMessage> 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<usize>,
/// 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<u32>,
}
/// `UserInfoResponse` (peer code 16): a peer's answer about itself.
pub struct UserInfoResponseHandler;
impl MessageHandler<PeerMessage> for UserInfoResponseHandler {
fn get_code(&self) -> u32 {
16
}
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
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");
}
}
@@ -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<ServerMessage> for CantConnectToPeerHandler {
fn get_code(&self) -> u32 {
1001
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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:?}"),
}
}
}
@@ -12,7 +12,7 @@ impl MessageHandler<ServerMessage> for ExcludedSearchPhrasesHandler {
160
}
fn handle(&self, message: &mut Message, _sender: Sender<ServerMessage>) {
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let item_count = message.read_int32();
let mut exluded_phrases: Vec<String> = Vec::new();
@@ -25,6 +25,11 @@ impl MessageHandler<ServerMessage> 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:?}"),
}
}
}
@@ -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<ServerMessage> for GlobalRoomMessageHandler {
fn get_code(&self) -> u32 {
152
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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:?}"),
}
}
}
@@ -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<Recommendation> {
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<String> {
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<ServerMessage> for RecommendationsHandler {
fn get_code(&self) -> u32 {
54
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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<ServerMessage> for GlobalRecommendationsHandler {
fn get_code(&self) -> u32 {
56
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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<ServerMessage> for ItemRecommendationsHandler {
fn get_code(&self) -> u32 {
111
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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<ServerMessage> for SimilarUsersHandler {
fn get_code(&self) -> u32 {
110
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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<ServerMessage> for ItemSimilarUsersHandler {
fn get_code(&self) -> u32 {
112
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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<ServerMessage> for UserInterestsHandler {
fn get_code(&self) -> u32 {
57
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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:?}"),
}
}
}
@@ -301,6 +301,251 @@ 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()
}
/// 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.
#[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 +762,189 @@ fn an_upload_speed_report_carries_the_rate() {
decoded.set_pointer(8);
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();
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");
}
#[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);
}
@@ -28,16 +28,73 @@ impl MessageHandler<ServerMessage> 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<ServerMessage> {
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<u32> {
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"
)));
}
}
+19
View File
@@ -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;
@@ -11,9 +14,11 @@ 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;
mod room_tickers;
mod say_chatroom;
mod user_info;
mod user_joined_room;
@@ -22,6 +27,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 +35,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;
@@ -36,9 +48,16 @@ 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;
pub use room_tickers::{
RoomTickerAddedHandler, RoomTickerRemovedHandler, RoomTickersHandler,
};
pub use say_chatroom::SayChatroomHandler;
pub use user_info::{GetUserStatsHandler, GetUserStatusHandler};
pub use user_joined_room::UserJoinedRoomHandler;
@@ -14,8 +14,32 @@ impl MessageHandler<ServerMessage> for ParentMinSpeedHandler {
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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:?}"),
}
}
}
@@ -14,8 +14,31 @@ impl MessageHandler<ServerMessage> for ParentSpeedRatioHandler {
84
}
fn handle(&self, message: &mut Message, _sender: Sender<ServerMessage>) {
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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:?}"),
}
}
}
@@ -0,0 +1,234 @@
//! 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<String>) {
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<ServerMessage> for RoomMembersHandler {
fn get_code(&self) -> u32 {
133
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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<ServerMessage> for RoomOperatorsHandler {
fn get_code(&self) -> u32 {
148
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let (room, users) = read_roster(message);
let _ =
sender.send(ServerMessage::PrivateRoomOperators { room, users });
}
}
/// 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<ServerMessage> for RoomRosterChangeHandler {
fn get_code(&self) -> u32 {
self.0
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let room = message.read_string();
let username = message.read_string();
let _ = sender.send(ServerMessage::PrivateRoomRosterChanged {
room,
username,
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(pub u32);
impl MessageHandler<ServerMessage> for OwnRoomStandingHandler {
fn get_code(&self) -> u32 {
self.0
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let room = message.read_string();
let _ = sender.send(ServerMessage::OwnRoomStandingChanged {
room,
members: matches!(self.0, 139 | 140),
granted: matches!(self.0, 139 | 145),
});
}
}
/// `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<ServerMessage> for CantCreateRoomHandler {
fn get_code(&self) -> u32 {
1003
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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(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| {
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(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| {
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:?}"),
}
}
}
@@ -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<ServerMessage> for RoomTickersHandler {
fn get_code(&self) -> u32 {
113
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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<ServerMessage> for RoomTickerAddedHandler {
fn get_code(&self) -> u32 {
114
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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<ServerMessage> for RoomTickerRemovedHandler {
fn get_code(&self) -> u32 {
115
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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:?}"),
}
}
}
+346 -119
View File
@@ -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<String, DownloadError> {
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<RwLock<ClientContext>>,
) -> 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<RwLock<ClientContext>>,
download: Option<Download>,
matched_download: &mut Option<Download>,
) -> 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<RwLock<ClientContext>>,
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<RwLock<ClientContext>>,
download: &Download,
part: PartFile,
) -> Result<String, DownloadError> {
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<RwLock<ClientContext>>,
download: &Download,
@@ -527,36 +552,54 @@ impl DownloadPeer {
download: Option<Download>,
stream: Option<TcpStream>,
) -> 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);
}
+21 -45
View File
@@ -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;
@@ -12,7 +13,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 +131,10 @@ fn wait_for_download_registration(
deadline: Instant,
) -> Option<Download> {
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 +215,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 +230,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 +241,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
);
}
}
}
}
}
}
@@ -416,9 +379,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,
});
}
}
}
@@ -452,6 +423,7 @@ impl Listen {
client_sender: Sender<ClientOperation>,
client_context: Arc<RwLock<ClientContext>>,
own_username: String,
stopped: Arc<AtomicBool>,
) {
info!("[listener] listening on {:?}", listener.local_addr());
@@ -463,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: {}",
@@ -491,7 +467,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;
+6
View File
@@ -64,6 +64,10 @@ pub struct Peer {
pub privileged: Option<u8>,
pub unknown: Option<u8>,
pub obfuscated_port: Option<u16>,
/// 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<Self> {
@@ -120,6 +125,7 @@ impl Peer {
privileged: Some(privileged),
unknown: Some(unknown),
obfuscated_port: Some(u16::from(obfuscated_port)),
brokered: true,
})
}
}
+128 -65
View File
@@ -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,11 @@ 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();
// 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)
}
@@ -111,8 +113,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 +146,150 @@ mod tests {
path
}
fn spawn_uploader(
listener: &TcpListener,
path: std::path::PathBuf,
token: u32,
cancel: Arc<AtomicBool>,
) -> (
Arc<AtomicU64>,
std::thread::JoinHandle<std::io::Result<u64>>,
) {
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<u8> = (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();
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<u8> = (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<u8> = (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 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<u8> = (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 (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,
783,
&path,
&sent_counter,
&AtomicU64::new(0),
&AtomicBool::new(false),
)
);
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");
// 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();
assert_eq!(
bytes_sent.load(Ordering::Relaxed),
4096,
"progress counts the resumed prefix"
);
let _ = std::fs::remove_dir_all(dir);
}
@@ -223,23 +299,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);
+12 -8
View File
@@ -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()
+74
View File
@@ -393,6 +393,80 @@ 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<RoomTicker>,
},
/// `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,
},
/// Who may enter a private room (code 133).
PrivateMembers { room: String, users: Vec<String> },
/// Who runs a private room (code 148).
PrivateOperators { room: String, users: Vec<String> },
/// 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.
#[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<String>,
pub hates: Vec<String>,
}
impl Transfer {
+76 -7
View File
@@ -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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
"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<Vec<u8>, String> {
fn inflate_blocks(
r: &mut BitReader,
max_out: usize,
) -> std::result::Result<Vec<u8>, 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<Vec<u8>, String> {
fn inflate_block_no_compression(
r: &mut BitReader,
o: &mut Vec<u8>,
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<u8>,
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<u8>,
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<u8>,
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<u32> = (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");
File diff suppressed because it is too large Load Diff
+409
View File
@@ -0,0 +1,409 @@
//! 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(),
accept_children: false,
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<u8> {
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<Message> {
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<DownloadStatus>, 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<DownloadStatus>,
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<u8> {
(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
);
}
+287
View File
@@ -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": {
@@ -729,6 +741,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 +984,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": {
@@ -1003,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": {
@@ -2089,6 +2344,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",
@@ -2101,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",
+16
View File
@@ -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 <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>`** — `room`, `user`, `status`, `average_speed`,
`shared_files`, `shared_folders`, `slots_full`, `country`. Everything but
@@ -205,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 <text>]`** — 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 <key>`**, and
**`config set <key> <value>`** — `key`, `value`.
+19
View File
@@ -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<String>;
/// 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<SharedDirectory>;
// 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<String>) -> Result<()>;
@@ -178,6 +182,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<Client>` is already an
@@ -409,6 +420,10 @@ impl SessionApi for Client {
Self::shared_directories(self)
}
fn shared_listing(&self) -> Vec<SharedDirectory> {
Self::shared_listing(self)
}
fn set_shared_directories(&self, directories: Vec<String>) -> Result<()> {
Self::set_shared_directories(self, directories)
}
@@ -416,4 +431,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)
}
}
+39
View File
@@ -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<String>,
/// 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 {
@@ -375,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<String>,
},
/// Re-index the shares and report what the network will see
Reindex,
+59
View File
@@ -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<String> {
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",
)),
}
}
+9 -2
View File
@@ -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(_)
+22
View File
@@ -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 {
+37 -16
View File
@@ -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
+1
View File
@@ -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),
+15 -4
View File
@@ -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,
SharedListing, 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::<TransferRef>(generator),
Method::DownloadSetDir => schema_of::<DirectoryParams>(generator),
Method::AccountPassword => schema_of::<PasswordParams>(generator),
Method::UploadSlots => schema_of::<SlotsParams>(generator),
Method::RoomJoin
| Method::RoomLeave
@@ -73,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" }),
}
@@ -101,6 +103,7 @@ fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
Method::SharesStatusOf | Method::SharesSet | Method::SharesReindex => {
schema_of::<SharesStatus>(generator)
}
Method::SharesFiles => schema_of::<SharedListing>(generator),
// Everything else acknowledges and says nothing more.
Method::DaemonStop
| Method::SearchStart
@@ -111,6 +114,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 +174,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.",
@@ -206,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.",
}
+206
View File
@@ -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<RoomTickerDto>,
},
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<String>,
},
/// Who runs a private room.
PrivateOperators {
room: String,
users: Vec<String>,
},
/// 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<RoomTickerDto> 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<RoomEventDto> 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 },
}
}
}
@@ -584,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<SharedDirectory> 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<SharedDirectoryDto> for SharedDirectory {
fn from(dto: SharedDirectoryDto) -> Self {
Self {
+17
View File
@@ -82,6 +82,15 @@ pub struct DirectoriesParams {
pub directories: Vec<String>,
}
/// 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)]
@@ -203,6 +212,14 @@ pub struct BrowseEvent {
pub directories: Vec<SharedDirectoryDto>,
}
/// 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<SharedDirectoryDto>,
}
/// One step in a transfer this client started.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
+2
View File
@@ -111,7 +111,9 @@ wire_vocabulary! {
UserWatch => "user.watch",
UserUnwatch => "user.unwatch",
UserWatched => "user.watched",
AccountPassword => "account.password",
SharesStatusOf => "shares.status",
SharesFiles => "shares.files",
SharesSet => "shares.set",
SharesReindex => "shares.reindex",
}
+82 -5
View File
@@ -11,13 +11,15 @@ 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, SharedDirectoryDto, SharedListing, 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 +29,9 @@ use std::time::{Duration, Instant};
/// Everything a connection needs to answer a request.
pub struct Daemon {
pub session: Arc<dyn SessionApi>,
/// 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<dyn SecretStore>,
pub hub: Arc<Hub>,
pub browses: Arc<PendingBrowses>,
pub downloads: Arc<Mutex<Vec<PendingDownload>>>,
@@ -346,7 +351,36 @@ 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(),
&params.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::SharesFiles => ok(SharedListing {
directories: self
.session
.shared_listing()
.into_iter()
.map(SharedDirectoryDto::from)
.collect(),
}),
Method::SharesSet => {
let directories: DirectoriesParams = parse(params)?;
self.session
@@ -656,6 +690,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 +779,23 @@ mod tests {
#[derive(Default)]
struct SilentSession {
watched: Mutex<Vec<String>>,
/// What `account.password` asked the session to set, if anything.
password: Mutex<Option<String>>,
}
/// 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<SilentSession>) -> 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 +814,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<u16> {
None
}
@@ -953,6 +1027,9 @@ mod tests {
fn shared_counts(&self) -> (u32, u32) {
(0, 0)
}
fn shared_listing(&self) -> Vec<soulseek_rs::SharedDirectory> {
Vec::new()
}
fn shared_directories(&self) -> Vec<String> {
Vec::new()
}
+6
View File
@@ -14,3 +14,9 @@ pub(crate) mod port_mapping;
pub mod remote;
pub mod run;
pub(crate) mod ui;
/// The interactive window, reachable so an integration test can drive it
/// with key events, advance it a frame at a time and read the screen back.
/// Hidden from the documented API: it is a harness door, not a contract.
#[doc(hidden)]
pub use ui::MainTui;
+104 -3
View File
@@ -1,6 +1,6 @@
use crate::models::{BrowseTabs, FileDisplayData, RoomsState, SettingsState};
use chrono::{DateTime, Local};
use ratatui::{layout::Rect, widgets::TableState};
use ratatui::{layout::Rect, text::Line, widgets::TableState};
use soulseek_rs::{DownloadStatus, types::Download};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, mpsc::Receiver, mpsc::Sender};
@@ -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<u16>,
/// 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<u16>,
pub downloads_width: Option<u16>,
}
impl PaneLayout {
@@ -174,6 +182,55 @@ impl LogView {
}
}
/// A log wrapped to a width, kept between frames.
///
/// Wrapping every line of a long log on every draw is what made a busy room
/// lag. Lines only ever arrive at the end, so a draw wraps the new ones and
/// starts over only when the width or `key` (what is being shown, a filter
/// say) changes.
#[derive(Debug, Clone, Default)]
pub struct WrappedLog {
width: usize,
key: String,
/// How many source lines the rows cover.
wrapped: usize,
rows: Vec<Line<'static>>,
}
impl WrappedLog {
/// The rows for `lines`, `wrap` turning one line into its rows (none,
/// for a line a filter drops).
pub fn rows<T>(
&mut self,
width: usize,
key: &str,
lines: &[T],
wrap: impl Fn(&T) -> Vec<Line<'static>>,
) -> &[Line<'static>] {
if width != self.width || key != self.key || lines.len() < self.wrapped
{
self.rows.clear();
self.wrapped = 0;
self.width = width;
self.key = key.to_string();
}
for line in &lines[self.wrapped..] {
self.rows.extend(wrap(line));
}
self.wrapped = lines.len();
&self.rows
}
}
/// 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 {
@@ -238,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<TuiExit>,
pub command_bar_active: bool,
pub command_bar_input: String,
pub command_bar_cursor_position: usize,
@@ -254,6 +312,11 @@ pub struct AppState {
/// Compose buffer for the active conversation.
pub chat_input: String,
pub chat_composing: bool,
/// The open conversation wrapped for drawing.
pub chat_wrapped: WrappedLog,
/// Narrows the open conversation to messages mentioning it.
pub chat_filter: String,
pub chat_filtering: bool,
/// Where the conversation is being read.
pub chat_view: LogView,
@@ -277,6 +340,15 @@ pub struct AppState {
pub searches_pane_area: Option<Rect>,
pub results_pane_area: Option<Rect>,
pub downloads_pane_area: Option<Rect>,
/// 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<Rect>,
pub row_area: Option<Rect>,
pub info_pane_area: Option<Rect>,
/// 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<Rect>,
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<Rect>,
@@ -320,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,
@@ -332,6 +404,9 @@ impl AppState {
chat_peer: None,
chat_input: String::new(),
chat_composing: false,
chat_wrapped: WrappedLog::default(),
chat_filter: String::new(),
chat_filtering: false,
chat_view: LogView::default(),
browse: BrowseTabs::new(),
@@ -349,6 +424,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,
}
}
@@ -501,6 +581,27 @@ mod tests {
layout.visible().collect()
}
#[test]
fn a_wrapped_log_only_wraps_what_arrived_and_starts_over_on_a_change() {
let wrap = |line: &String| vec![Line::raw(line.clone())];
let mut log = WrappedLog::default();
let mut lines = vec!["a".to_string(), "b".to_string()];
assert_eq!(log.rows(40, "", &lines, wrap).len(), 2);
lines.push("c".to_string());
assert_eq!(log.rows(40, "", &lines, wrap).len(), 3);
// A narrower pane or another filter rebuilds; fewer lines do too.
let only_c = |line: &String| {
(line == "c")
.then(|| Line::raw(line.clone()))
.into_iter()
.collect()
};
assert_eq!(log.rows(40, "c", &lines, only_c).len(), 1);
assert_eq!(log.rows(30, "c", &lines, wrap).len(), 3);
lines.truncate(1);
assert_eq!(log.rows(30, "c", &lines, wrap).len(), 1);
}
#[test]
fn every_pane_starts_visible_and_unzoomed() {
let state = AppState::new();
+338 -39
View File
@@ -154,23 +154,20 @@ pub struct BrowseRow {
pub size: Option<u64>,
}
/// Flatten the tree into visible rows, descending only into expanded folders.
#[must_use]
pub fn flatten_browse(
nodes: &[BrowseNode],
expanded: &HashSet<String>,
) -> Vec<BrowseRow> {
let mut rows = Vec::new();
flatten_into(nodes, 0, expanded, &mut rows);
rows
}
/// Emit `nodes` as rows. Without a `needle`, every folder and file, a
/// folder's contents only while it is open. With one, only the paths that
/// contain it and the folders on the way there, open ones still deciding
/// whether their contents show. `emit` false only counts. Says whether
/// anything under `nodes` was kept.
fn flatten_into(
nodes: &[BrowseNode],
depth: usize,
expanded: &HashSet<String>,
needle: Option<&str>,
rows: &mut Vec<BrowseRow>,
) {
emit: bool,
) -> bool {
let mut kept = false;
for node in nodes {
match node {
BrowseNode::Folder {
@@ -178,36 +175,107 @@ fn flatten_into(
path,
children,
} => {
let is_expanded = expanded.contains(path);
rows.push(BrowseRow {
depth,
is_folder: true,
expanded: is_expanded,
name: name.clone(),
path: path.clone(),
size: None,
});
if is_expanded {
flatten_into(children, depth + 1, expanded, rows);
let open = expanded.contains(path);
let mark = rows.len();
if emit {
rows.push(BrowseRow {
depth,
is_folder: true,
expanded: open,
name: name.clone(),
path: path.clone(),
size: None,
});
}
let below = match needle {
None => {
if open {
flatten_into(
children,
depth + 1,
expanded,
None,
rows,
emit,
);
}
true
}
Some(needle) => {
flatten_into(
children,
depth + 1,
expanded,
Some(needle),
rows,
emit && open,
) || path_has(path, needle)
}
};
if below {
kept = true;
} else {
rows.truncate(mark);
}
}
BrowseNode::File { name, path, size } => {
rows.push(BrowseRow {
depth,
is_folder: false,
expanded: false,
name: name.clone(),
path: path.clone(),
size: Some(*size),
});
if needle.is_none_or(|needle| path_has(path, needle)) {
kept = true;
if emit {
rows.push(BrowseRow {
depth,
is_folder: false,
expanded: false,
name: name.clone(),
path: path.clone(),
size: Some(*size),
});
}
}
}
}
}
kept
}
/// Whether `path` contains `needle`, which is already ASCII-lowercase. Case
/// is folded for ASCII only, without allocating: a filter keystroke runs
/// this over every path in a share. A letter beyond ASCII matches as typed.
fn path_has(path: &str, needle: &str) -> bool {
needle.is_empty()
|| path
.as_bytes()
.windows(needle.len())
.any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
}
/// Open every folder with a path containing `needle` somewhere under it,
/// so a filter shows all it matched. Says whether anything under `nodes`
/// matched.
fn open_matching(
nodes: &[BrowseNode],
needle: &str,
expanded: &mut HashSet<String>,
) -> bool {
let mut any = false;
for node in nodes {
match node {
BrowseNode::Folder { path, children, .. } => {
if open_matching(children, needle, expanded)
|| path_has(path, needle)
{
expanded.insert(path.clone());
any = true;
}
}
BrowseNode::File { path, .. } => any |= path_has(path, needle),
}
}
any
}
/// Find a node by its full path.
#[must_use]
pub fn find_node<'a>(
fn find_node<'a>(
nodes: &'a [BrowseNode],
path: &str,
) -> Option<&'a BrowseNode> {
@@ -235,8 +303,7 @@ pub fn find_node<'a>(
/// All file leaves under `node` as `(full_path, size)`, descending the tree
/// structurally (never string-prefix matching).
#[must_use]
pub fn files_under(node: &BrowseNode) -> Vec<(String, u64)> {
fn files_under(node: &BrowseNode) -> Vec<(String, u64)> {
let mut out = Vec::new();
collect_files(node, &mut out);
out
@@ -265,9 +332,22 @@ 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<BrowseNode>,
pub expanded: HashSet<String>,
expanded: HashSet<String>,
/// The tree flattened for the current expansion, kept between frames:
/// a heavy sharer's tree runs to tens of thousands of rows.
rows: Vec<BrowseRow>,
/// Narrows the rows to paths containing it, case-insensitively. Setting
/// it opens every folder with a match; clearing it puts the folders back
/// the way they were, kept in `kept_expanded` meanwhile.
filter: String,
kept_expanded: Option<HashSet<String>>,
/// Whether typing goes to the filter.
pub filtering: bool,
pub selected_row: usize,
pub file_count: usize,
pub folder_count: usize,
@@ -279,9 +359,14 @@ impl BrowseState {
pub fn loading(username: String) -> Self {
Self {
username,
own: false,
status: BrowseStatus::Loading,
tree: Vec::new(),
expanded: HashSet::new(),
rows: Vec::new(),
filter: String::new(),
kept_expanded: None,
filtering: false,
selected_row: 0,
file_count: 0,
folder_count: 0,
@@ -289,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]) {
@@ -310,12 +404,105 @@ impl BrowseState {
self.file_count = built.file_count;
self.folder_count = built.folder_count;
self.selected_row = 0;
self.refresh();
}
/// The flattened visible rows for the current expansion state.
#[must_use]
pub fn rows(&self) -> Vec<BrowseRow> {
flatten_browse(&self.tree, &self.expanded)
pub fn rows(&self) -> &[BrowseRow] {
&self.rows
}
/// Open or close the folder at `path`.
pub fn set_expanded(&mut self, path: &str, expanded: bool) {
if expanded {
self.expanded.insert(path.to_string());
} else {
self.expanded.remove(path);
}
self.refresh();
}
pub fn expand_all(&mut self) {
folder_paths(&self.tree, &mut self.expanded);
self.refresh();
}
pub fn collapse_all(&mut self) {
self.expanded.clear();
self.refresh();
}
#[must_use]
pub fn filter(&self) -> &str {
&self.filter
}
/// Narrow the rows to `filter`, starting over from the top, with every
/// folder holding a match open. An emptied filter puts the folders back
/// as they were before it.
pub fn set_filter(&mut self, filter: String) {
if filter == self.filter {
return;
}
if filter.is_empty() {
if let Some(kept) = self.kept_expanded.take() {
self.expanded = kept;
}
} else {
if self.kept_expanded.is_none() {
self.kept_expanded = Some(self.expanded.clone());
}
self.expanded.clear();
open_matching(
&self.tree,
&filter.to_ascii_lowercase(),
&mut self.expanded,
);
}
self.filter = filter;
self.selected_row = 0;
self.refresh();
}
/// The files under the folder at `path`, only those the filter lets
/// through: what `d` on a filtered folder downloads is what it shows.
#[must_use]
pub fn folder_files(&self, path: &str) -> Vec<(String, u64)> {
let needle = self.filter.to_ascii_lowercase();
find_node(&self.tree, path)
.map(files_under)
.unwrap_or_default()
.into_iter()
.filter(|(path, _)| path_has(path, &needle))
.collect()
}
/// Re-flatten after the tree, the expansion or the filter changed under
/// `rows`.
fn refresh(&mut self) {
let needle =
(!self.filter.is_empty()).then(|| self.filter.to_ascii_lowercase());
self.rows.clear();
flatten_into(
&self.tree,
0,
&self.expanded,
needle.as_deref(),
&mut self.rows,
true,
);
self.selected_row =
self.selected_row.min(self.rows.len().saturating_sub(1));
}
}
fn folder_paths(nodes: &[BrowseNode], out: &mut HashSet<String>) {
for node in nodes {
if let BrowseNode::Folder { path, children, .. } = node {
out.insert(path.clone());
folder_paths(children, out);
}
}
}
@@ -354,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.
@@ -371,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<usize> {
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.
@@ -413,6 +637,16 @@ impl BrowseTabs {
mod tests {
use super::*;
/// The tree flattened for `expanded`, unfiltered.
fn flatten_browse(
nodes: &[BrowseNode],
expanded: &HashSet<String>,
) -> Vec<BrowseRow> {
let mut rows = Vec::new();
flatten_into(nodes, 0, expanded, None, &mut rows, true);
rows
}
fn dir(name: &str, files: &[(&str, u64)]) -> SharedDirectory {
SharedDirectory {
name: name.to_string(),
@@ -556,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();
@@ -579,6 +836,48 @@ mod tests {
assert_eq!(tabs.retry_active(), None);
}
#[test]
fn expanding_and_collapsing_refresh_the_rows_and_keep_the_selection() {
let mut state = BrowseState::loading("bob".to_string());
state.load(&[dir("music\\album", &[("a.mp3", 1), ("b.mp3", 2)])]);
assert_eq!(state.rows().len(), 2, "music open, album closed");
state.set_expanded("music\\album", true);
assert_eq!(state.rows().len(), 4);
state.selected_row = 3;
state.set_expanded("music\\album", false);
assert_eq!(state.rows().len(), 2);
assert_eq!(state.selected_row, 1, "pulled back onto the last row");
}
#[test]
fn a_filter_opens_every_folder_with_a_match_and_folds_still_work() {
let mut state = BrowseState::loading("bob".to_string());
state.load(&[
dir("music\\alpha", &[("a1.mp3", 1), ("a2.flac", 2)]),
dir("music\\beta", &[("b1.mp3", 3)]),
]);
let names = |state: &BrowseState| -> Vec<String> {
state.rows().iter().map(|row| row.name.clone()).collect()
};
state.set_filter("beta".to_string());
assert_eq!(names(&state), ["music", "beta", "b1.mp3"]);
state.set_filter("FLAC".to_string());
assert_eq!(names(&state), ["music", "alpha", "a2.flac"], "any case");
assert_eq!(
state.folder_files("music"),
[("music\\alpha\\a2.flac".to_string(), 2)],
"a folder downloads what it shows"
);
state.set_expanded("music\\alpha", false);
assert_eq!(names(&state), ["music", "alpha"], "folded under a filter");
state.set_filter(String::new());
assert_eq!(state.rows().len(), 3, "the folders as they were before");
state.expand_all();
assert_eq!(state.rows().len(), 6);
state.collapse_all();
assert_eq!(state.rows().len(), 1);
}
#[test]
fn browse_state_load_auto_expands_top_level() {
let mut state = BrowseState::loading("bob".to_string());
+10 -6
View File
@@ -6,11 +6,15 @@ mod settings;
pub use app_state::{
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView,
MessageDirection, PaneLayout, SearchEntry, SearchStatus,
};
pub use browse::{
BrowseState, BrowseStatus, BrowseTabs, files_under, find_node,
MessageDirection, PaneLayout, SearchEntry, SearchStatus, TuiExit,
WrappedLog,
};
pub use browse::{BrowseState, BrowseStatus, BrowseTabs};
pub use file_display_data::FileDisplayData;
pub use rooms::{RoomLine, RoomsState, RoomsView};
pub use settings::{SettingsAction, SettingsMode, SettingsState};
pub use rooms::{
ChatFilter, OpenRoom, RoomLine, RoomsState, RoomsView, contains_filter,
matching_users,
};
pub use settings::{
AccountInfo, SettingsAction, SettingsMode, SettingsRow, SettingsState,
};
+144 -10
View File
@@ -1,4 +1,4 @@
use crate::models::LogView;
use crate::models::{LogView, WrappedLog};
use chrono::{DateTime, Local};
use soulseek_rs::types::{RoomEvent, RoomInfo};
@@ -41,6 +41,8 @@ pub struct OpenRoom {
pub input: String,
/// Where the log is being read.
pub view: LogView,
/// The log wrapped for drawing.
pub wrapped: WrappedLog,
}
impl OpenRoom {
@@ -77,8 +79,22 @@ pub struct RoomsState {
pub view: RoomsView,
/// Whether the compose input for the active room is capturing keys.
pub composing: bool,
/// Selected index into the active room's member list (for browse/PM).
/// Selected index into the active room's member list (for browse/PM),
/// as narrowed by `user_filter`.
pub user_selected: usize,
/// Narrows the active room's log to lines mentioning it.
pub log_filter: String,
/// Narrows the active room's member list to names containing it.
pub user_filter: String,
/// Which of the two, if either, typing goes to.
pub filtering: Option<ChatFilter>,
}
/// A room's two filterable lists.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChatFilter {
Log,
Users,
}
impl Default for RoomsState {
@@ -100,6 +116,31 @@ impl RoomsState {
view: RoomsView::List,
composing: false,
user_selected: 0,
log_filter: String::new(),
user_filter: String::new(),
filtering: None,
}
}
/// The active room's members the filter lets through.
#[must_use]
pub fn visible_users(&self) -> Vec<&str> {
self.active_room().map_or_else(Vec::new, |room| {
matching_users(&room.users, &self.user_filter)
})
}
/// Narrow the member list, starting over from its top.
pub fn set_user_filter(&mut self, filter: String) {
self.user_filter = filter;
self.user_selected = 0;
}
/// Narrow the log, back at its newest matching line.
pub fn set_log_filter(&mut self, filter: String) {
self.log_filter = filter;
if let Some(view) = self.active_view_mut() {
view.to_newest();
}
}
@@ -159,8 +200,9 @@ impl RoomsState {
/// The username highlighted in the active room's member list, if any.
#[must_use]
pub fn selected_user(&self) -> Option<String> {
self.active_room()
.and_then(|r| r.users.get(self.user_selected).cloned())
self.visible_users()
.get(self.user_selected)
.map(|user| (*user).to_string())
}
/// Where the active room's log is being read, to move it.
@@ -173,11 +215,9 @@ impl RoomsState {
}
pub fn select_user_down(&mut self) {
if let Some(room) = self.active_room()
&& !room.users.is_empty()
{
self.user_selected =
(self.user_selected + 1).min(room.users.len() - 1);
let len = self.visible_users().len();
if len > 0 {
self.user_selected = (self.user_selected + 1).min(len - 1);
}
}
@@ -296,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
@@ -305,7 +420,7 @@ impl RoomsState {
/// Keep `user_selected` within the active room's member list.
fn clamp_user_selected(&mut self) {
let len = self.active_room().map_or(0, |r| r.users.len());
let len = self.visible_users().len();
self.user_selected = self.user_selected.min(len.saturating_sub(1));
}
@@ -320,6 +435,25 @@ impl RoomsState {
}
}
/// Whether `text` contains `filter`, ignoring case; an empty filter matches.
#[must_use]
pub fn contains_filter(text: &str, filter: &str) -> bool {
filter.is_empty() || text.to_lowercase().contains(&filter.to_lowercase())
}
/// The names in `users` that contain `filter`, in their own order.
#[must_use]
pub fn matching_users<'a>(users: &'a [String], filter: &str) -> Vec<&'a str> {
let needle = filter.to_lowercase();
users
.iter()
.filter(|user| {
needle.is_empty() || user.to_lowercase().contains(&needle)
})
.map(String::as_str)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
+306 -43
View File
@@ -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<u16>,
/// 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<String>,
}
pub struct SettingsState {
pub account: AccountInfo,
pub download_dir: String,
pub share_dirs: Vec<String>,
/// 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<String>) -> Self {
pub const fn new(
account: AccountInfo,
download_dir: String,
share_dirs: Vec<String>,
) -> 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<SettingsRow> {
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);
}
}
+14
View File
@@ -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.
+66 -5
View File
@@ -12,6 +12,11 @@ pub struct FileConfig {
pub server: Option<String>,
pub listener_port: Option<u16>,
pub disable_listener: Option<bool>,
/// 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<bool>,
pub download_dir: Option<String>,
/// 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<String>,
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,
+90 -24
View File
@@ -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<Option<String>>;
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<Option<String>> {
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<String> {
(!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<std::collections::HashMap<String, String>>,
/// 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<Option<String>> {
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<HashMap<String, String>>,
fail: bool,
}
impl SecretStore for FakeStore {
fn get(&self, username: &str) -> Result<Option<String>> {
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 {
+24 -3
View File
@@ -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, 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<SharedDirectory> {
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<String>) -> Result<()> {
self.tell(Method::SharesSet, DirectoriesParams { directories })
}
@@ -830,6 +842,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)]
+136 -77
View File
@@ -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);
}
@@ -157,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)
}
@@ -211,12 +224,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 \
@@ -259,6 +267,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,
@@ -324,16 +333,7 @@ fn password(
resolved: &crate::persist::config::Resolved,
) -> CliResult<Option<String>> {
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(),
@@ -343,6 +343,29 @@ fn password(
))
}
/// Read a password from the first line of stdin, the way `docker login` does.
pub fn password_from_stdin() -> CliResult<String> {
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(
@@ -402,12 +425,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<PathBuf>,
file_config: &crate::persist::config::FileConfig,
) -> CliResult {
let out = Out::new(false, false);
let (server_host, server_port) =
@@ -425,13 +447,11 @@ 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;
let accept_children = resolved.accept_children;
let make_settings =
move |username: String, password: String| ClientSettings {
username,
@@ -440,60 +460,84 @@ fn run_default_tui(
enable_listen,
listen_port,
shared_directories: shared_directories.clone(),
accept_children,
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.
@@ -539,6 +583,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),
@@ -547,6 +593,7 @@ fn run_attached_tui(
None,
config_path,
)
.map(|_| ())
.map_err(|e| CliError::new(Exit::Failure, e.to_string()))
}
@@ -556,17 +603,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)
@@ -575,6 +615,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
+2 -2
View File
@@ -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],
+113 -35
View File
@@ -1,6 +1,6 @@
use super::MainTui;
use super::input::{jumped, list_jump};
use crate::models::{BrowseStatus, files_under, find_node};
use super::input::{FilterEdit, edit_filter, jumped, list_jump};
use crate::models::BrowseStatus;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::{thread, time::Duration};
@@ -9,11 +9,27 @@ const BROWSE_TIMEOUT: Duration = Duration::from_secs(20);
impl MainTui {
pub(super) fn handle_browse_input(&mut self, key: KeyEvent) {
// Typing goes to the filter; the other keys still move around.
if self.state.browse.active_tab().is_some_and(|b| b.filtering)
&& self.handle_browse_filter_input(key)
{
return;
}
// Esc peels back one level: a filter first, then the popup.
if key.code == KeyCode::Esc
&& let Some(browse) = self.state.browse.active_tab_mut()
&& !browse.filter().is_empty()
{
browse.set_filter(String::new());
return;
}
if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) {
self.state.show_browse = false;
return;
}
let jump = list_jump(key, self.popup_page());
// The tab bar takes a row once more than one user is open.
let chrome = u16::from(self.state.browse.tabs.len() > 1);
let jump = list_jump(key, self.popup_page(chrome));
// Control combinations are only ever jumps: ctrl-d pages, d downloads.
if jump.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) {
return;
@@ -38,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() {
@@ -45,11 +69,18 @@ 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;
}
_ => {}
}
// Snapshot the flattened rows + current selection, then drop the borrow.
let (rows, sel, row) = {
// The highlighted row and where it sits, then drop the borrow.
let (len, sel, row) = {
let Some(browse) = self.state.browse.active_tab() else {
self.state.show_browse = false;
return;
@@ -62,13 +93,12 @@ impl MainTui {
return;
}
let sel = browse.selected_row.min(rows.len() - 1);
let row = rows[sel].clone();
(rows, sel, row)
(rows.len(), sel, rows[sel].clone())
};
if let Some(jump) = jump {
if let Some(browse) = self.state.browse.active_tab_mut() {
browse.selected_row = jumped(sel, rows.len(), jump);
browse.selected_row = jumped(sel, len, jump);
}
self.sync_browse_selection();
return;
@@ -85,7 +115,11 @@ impl MainTui {
}
KeyCode::Char('d') => {
let files = if row.is_folder {
self.browse_folder_files(&row.path)
self.state
.browse
.active_tab()
.map(|b| b.folder_files(&row.path))
.unwrap_or_default()
} else {
vec![(row.path.clone(), row.size.unwrap_or(0))]
};
@@ -102,63 +136,82 @@ impl MainTui {
browse.selected_row = sel.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
browse.selected_row = (sel + 1).min(rows.len() - 1);
browse.selected_row = (sel + 1).min(len - 1);
}
// The folder-sized steps: the next or previous folder row.
KeyCode::Char('J') => {
if let Some(next) =
(sel + 1..len).find(|&i| browse.rows()[i].is_folder)
{
browse.selected_row = next;
}
}
KeyCode::Char('K') => {
if let Some(previous) =
(0..sel).rev().find(|&i| browse.rows()[i].is_folder)
{
browse.selected_row = previous;
}
}
KeyCode::Char('H') => browse.collapse_all(),
KeyCode::Char('L') => browse.expand_all(),
KeyCode::Char('/') => browse.filtering = true,
KeyCode::Right | KeyCode::Char('l') => {
if row.is_folder && !row.expanded {
browse.expanded.insert(row.path.clone());
browse.set_expanded(&row.path, true);
} else if row.is_folder {
browse.selected_row = (sel + 1).min(rows.len() - 1);
browse.selected_row = (sel + 1).min(len - 1);
}
}
KeyCode::Left | KeyCode::Char('h') => {
if row.is_folder && row.expanded {
browse.expanded.remove(&row.path);
} else if let Some(parent) =
(0..sel).rev().find(|&i| rows[i].depth < row.depth)
browse.set_expanded(&row.path, false);
} else if let Some(parent) = (0..sel)
.rev()
.find(|&i| browse.rows()[i].depth < row.depth)
{
browse.selected_row = parent;
}
}
KeyCode::Enter => {
// Folder toggle (files handled above).
if row.expanded {
browse.expanded.remove(&row.path);
} else {
browse.expanded.insert(row.path.clone());
}
browse.set_expanded(&row.path, !row.expanded);
}
_ => {}
}
// Re-clamp against the new flattened length.
let new_len = browse.rows().len();
browse.selected_row =
browse.selected_row.min(new_len.saturating_sub(1));
}
self.sync_browse_selection();
}
/// The keys that edit the filter while it is being typed. Says whether
/// `key` was one of them; the rest move around the narrowed rows.
fn handle_browse_filter_input(&mut self, key: KeyEvent) -> bool {
let Some(browse) = self.state.browse.active_tab_mut() else {
return false;
};
let mut filter = browse.filter().to_string();
let Some(edit) = edit_filter(&mut filter, key) else {
return false;
};
browse.filtering = edit == FilterEdit::Changed;
browse.set_filter(filter);
self.sync_browse_selection();
true
}
/// Point the browse table cursor at the active tab's selected row.
fn sync_browse_selection(&mut self) {
let selected = self.state.browse.active_tab().map(|b| b.selected_row);
self.state.browse_table_state.select(selected);
}
/// Files (`path`, `size`) under the active browse tab's folder at `path`.
fn browse_folder_files(&self, path: &str) -> Vec<(String, u64)> {
self.state
.browse
.active_tab()
.and_then(|b| find_node(&b.tree, path))
.map(files_under)
.unwrap_or_default()
}
/// 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;
};
@@ -204,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<String> {
self.highlighted_result().map(|f| f.username.clone())
+40
View File
@@ -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;
@@ -206,6 +231,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<String> {
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(),
+227 -65
View File
@@ -1,6 +1,7 @@
use super::MainTui;
use crate::models::{CommandBarMode, FocusedPane, LogView};
use crate::ui::pane_block;
use super::render::{MIN_PANE, shrink};
use crate::models::{CommandBarMode, FocusedPane, LogView, PaneLayout};
use crate::ui::page_of;
use crate::ui::panes::{
InfoSubject, name_end_offset, query_end_offset, selected_transfer,
transfer_name_end_offset, upload_display_name,
@@ -8,13 +9,14 @@ use crate::ui::panes::{
use ratatui::crossterm::event::{
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::layout::{Position, Rect};
use ratatui::layout::Position;
use ratatui::widgets::TableState;
const NAME_SCROLL_STEP: isize = 8;
impl MainTui {
pub(super) fn handle_key_event(&mut self, key: KeyEvent) {
/// One key press, routed to whatever owns the keyboard right now.
pub fn handle_key_event(&mut self, key: KeyEvent) {
// Command bar takes priority
if self.state.command_bar_active {
return self.handle_command_bar_input(key);
@@ -70,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('?') => {
@@ -100,6 +102,13 @@ impl MainTui {
self.state.hide_focused_pane();
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;
}
KeyCode::Esc if self.state.layout.zoomed => {
self.state.layout.zoomed = false;
return;
@@ -135,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.
@@ -142,6 +157,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;
@@ -186,6 +205,20 @@ impl MainTui {
return;
}
// Typing a filter captures the keys that edit it; the rest still
// move around.
if self.state.chat_filtering
&& let Some(edit) = edit_filter(&mut self.state.chat_filter, key)
{
if edit != FilterEdit::Changed {
self.state.chat_filtering = false;
}
if edit != FilterEdit::Kept {
self.state.chat_view.to_newest();
}
return;
}
// Paging keys move through the conversation's history.
if scroll_log(&mut self.state.chat_view, key) {
return;
@@ -195,9 +228,15 @@ impl MainTui {
}
match key.code {
// Esc peels back one level: a filter first, then the popup.
KeyCode::Esc if !self.state.chat_filter.is_empty() => {
self.state.chat_filter.clear();
self.state.chat_view.to_newest();
}
KeyCode::Char('i' | 'q') | KeyCode::Esc => {
self.state.show_messages = false;
}
KeyCode::Char('/') => self.state.chat_filtering = true,
// Move through the conversation list on the right.
KeyCode::Tab | KeyCode::Down | KeyCode::Char('j') => {
self.state.cycle_chat_peer(true);
@@ -326,30 +365,16 @@ impl MainTui {
}
fn handle_filter_input(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
self.state.results_is_filtering = false;
self.state.results_filter_query.clear();
self.state.results_filtered_items =
self.state.results_items.clone();
self.state.results_filtered_indices =
(0..self.state.results_items.len()).collect();
}
KeyCode::Char(c) => {
self.state.results_filter_query.push(c);
self.apply_filter();
}
KeyCode::Backspace => {
self.state.results_filter_query.pop();
self.apply_filter();
}
// Confirm the filter: leave typing mode but keep the query, so
// the normal Results keys (j/k, space, enter) act on the
// filtered list.
KeyCode::Enter => {
self.state.results_is_filtering = false;
}
_ => self.handle_results_input(key),
let Some(edit) = edit_filter(&mut self.state.results_filter_query, key)
else {
return self.handle_results_input(key);
};
// A kept query stays: the normal Results keys act on the filtered
// list. An empty one shows everything without a rebuild.
if edit == FilterEdit::Changed {
self.apply_filter();
} else {
self.state.results_is_filtering = false;
}
}
@@ -374,10 +399,10 @@ impl MainTui {
navigate_list(key, table, len, page)
}
/// Rows a popup shows at once: its inner height less a tab bar and a
/// compose line, which is what its list or log has left.
pub(super) fn popup_page(&self) -> usize {
page_of(self.state.popup_area, 2)
/// Rows a popup's list shows at once: its inner height less the `chrome`
/// rows above the list, a tab bar or a header.
pub(super) fn popup_page(&self, chrome: u16) -> usize {
page_of(self.state.popup_area, chrome)
}
/// Rows the focused pane shows at once: its inner height less the table
@@ -390,11 +415,10 @@ impl MainTui {
if self.navigate_focused_list(key) || self.scroll_focused_name(key) {
return;
}
let selected = self.state.searches_table_state.selected();
match key.code {
KeyCode::Enter => {
if let Some(selected) =
self.state.searches_table_state.selected()
{
if let Some(selected) = selected {
self.state.selected_search_index = Some(selected);
if let Some(search) = self.state.searches.get(selected) {
self.state.results_items = search.results.clone();
@@ -410,12 +434,16 @@ impl MainTui {
}
}
KeyCode::Char('d') => {
if let Some(selected) =
self.state.searches_table_state.selected()
{
if let Some(selected) = selected {
self.remove_search_at_index(selected);
}
}
// `s` asks for a query; `S` reuses the highlighted one.
KeyCode::Char('S') => {
if let Some(selected) = selected {
self.rerun_search_at_index(selected);
}
}
KeyCode::Char('C') => {
self.clear_all_searches();
}
@@ -440,10 +468,7 @@ impl MainTui {
self.state.results_selected_indices.insert(index);
}
}
KeyCode::Char('/') => {
self.state.results_is_filtering = true;
self.state.results_filter_query.clear();
}
KeyCode::Char('/') => self.state.results_is_filtering = true,
KeyCode::Char('a') => {
let indices: Vec<usize> =
if self.state.results_filter_query.is_empty() {
@@ -550,39 +575,176 @@ impl MainTui {
KeyCode::Char('c') => {
self.clear_finished_downloads();
}
KeyCode::Char('C') => {
self.clear_all_transfers();
}
_ => {}
}
}
pub(super) fn handle_mouse_event(&mut self, mouse: MouseEvent) {
if mouse.kind != MouseEventKind::Down(MouseButton::Left) {
return;
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;
}
_ => {}
}
}
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;
/// 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<ResizeDrag> {
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 => {}
}
}
}
}
}
/// Rows a list in `area` shows at once, less the `chrome` rows above it: a
/// table header, a popup's tab bar and compose line. At least one, so a page
/// key always moves, even before the first draw.
fn page_of(area: Option<Rect>, chrome: u16) -> usize {
area.map_or(0, |area| {
usize::from(pane_block(false).inner(area).height.saturating_sub(chrome))
})
.max(1)
/// 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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FilterEdit {
Changed,
Kept,
Cleared,
}
/// Apply `key` to `filter` the way every typed filter takes it: characters
/// and Backspace edit it, Enter keeps it, Esc clears it. A control or alt
/// chord is not typing. `None` when the key was none of those.
pub(super) fn edit_filter(
filter: &mut String,
key: KeyEvent,
) -> Option<FilterEdit> {
match key.code {
KeyCode::Esc => {
filter.clear();
Some(FilterEdit::Cleared)
}
KeyCode::Enter => Some(FilterEdit::Kept),
KeyCode::Backspace => {
filter.pop();
Some(FilterEdit::Changed)
}
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
filter.push(c);
Some(FilterEdit::Changed)
}
_ => None,
}
}
/// A move over a whole list at once: to either end, or by a number of rows
File diff suppressed because it is too large Load Diff
+509
View File
@@ -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<Constraint> = 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<Line> = 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)
}
@@ -1,21 +1,15 @@
use super::MainTui;
use crate::models::{CommandBarMode, FocusedPane, 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::{
//! 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, primary_style, render_download_stats,
warning_style,
};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Layout, Position, Rect},
style::Modifier,
text::{Line, Span},
widgets::Paragraph,
pane_block, plain_title, warning_style,
};
/// The keys overlay, grouped by where a key applies. Two columns, because a
@@ -29,7 +23,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, sizes reset"),
("click", "focus a pane"),
("drag", "resize panes by their borders"),
],
),
(
@@ -37,6 +33,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"),
@@ -55,6 +52,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)])] = &[
@@ -62,6 +67,7 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
"Searches",
&[
("Enter", "show its results"),
("S", "run the search again"),
("d", "remove the search"),
("C", "clear every search"),
("c", "chat rooms"),
@@ -86,6 +92,8 @@ 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"),
],
),
(
@@ -95,200 +103,83 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
("^u ^d", "half a page of them"),
("Home End / g G", "oldest / newest, or first / last row"),
("Tab / Shift-Tab", "next room, chat or user"),
("/", "filter the messages, the room list or the tree"),
("u", "filter a room's member list"),
],
),
(
"Browse popup",
&[
("← → / h l", "close / open a folder, or step out / in"),
("J K", "next / previous folder"),
("H L", "close / open every folder"),
("/", "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, re-index your own"),
("o", "shared folders (your own tab)"),
],
),
];
impl MainTui {
pub(super) 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
/// What the bar offers while a filter is being typed, wherever that is,
/// with the keys that still move around there.
fn filter_keys(
moves: &[(&'static str, &'static str)],
) -> Vec<(&'static str, &'static str)> {
let mut keys = vec![("Type", "filter")];
keys.extend_from_slice(moves);
keys.extend([("Enter", "keep filter"), ("Esc", "clear filter")]);
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")]
}
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);
SettingsMode::NewPassword => {
vec![
("Type", "password"),
("Enter", "repeat it"),
("Esc", "cancel"),
]
}
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;
if self.state.layout.zoomed {
self.render_pane(frame, area, self.state.focused_pane);
return;
SettingsMode::RepeatPassword(_) => {
vec![
("Type", "password"),
("Enter", "change it"),
("Esc", "cancel"),
]
}
// 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, bottom] = Layout::vertical([
Constraint::Fill(3),
Constraint::Fill(row_weight),
])
.areas(area);
self.render_pane(frame, top, FocusedPane::Results);
bottom
} else {
area
};
let mut constraints: Vec<Constraint> = row_panes
.iter()
.map(|(_, weight)| 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) {
self.render_pane(frame, *chunk, *pane);
SettingsMode::EditingDownloadDir | SettingsMode::AddingShare => {
vec![("Type", "path"), ("Enter", "save"), ("Esc", "cancel")]
}
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,
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"),
},
);
}
/// 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
),
);
("a", "add share"),
("r", "re-index"),
("Esc", "close"),
],
}
}
impl MainTui {
/// 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;
@@ -332,128 +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,
);
}
// 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;
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 mut lines: Vec<Line> = Vec::new();
let marker = |selected: bool| if selected { "> " } else { " " };
let entry = |selected: bool, label: &str, value: String| {
Line::from(vec![
Span::styled(marker(selected).to_string(), accent_style()),
Span::styled(label.to_string(), dimmed_style()),
Span::styled(value, primary_style()),
])
};
lines.push(if settings.mode == SettingsMode::EditingDownloadDir {
Line::from(vec![
Span::styled("> ", accent_style()),
Span::styled("Download folder: ", dimmed_style()),
Span::styled(settings.input.clone(), primary_style()),
Span::styled("▏", accent_style()),
])
} else {
entry(
settings.selected == 0,
"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()));
}
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(Line::from(vec![
Span::styled(" Add share: ", dimmed_style()),
Span::styled(settings.input.clone(), primary_style()),
Span::styled("▏", accent_style()),
]));
}
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 {
@@ -481,10 +250,15 @@ impl MainTui {
]
}
}
RoomsView::Chat if self.state.rooms.filtering.is_some() => {
filter_keys(&[("PgUp/PgDn", "scroll"), ("↑↓", "pick user")])
}
RoomsView::Chat => vec![
("Enter", "say"),
("PgUp/PgDn", "scroll"),
("↑↓", "pick user"),
("/", "find in chat"),
("u", "find user"),
("b", "browse user"),
("m", "message user"),
("Tab", "switch room"),
@@ -497,7 +271,7 @@ impl MainTui {
/// The keys every pane shares for moving between and resizing panes,
/// ending the bar the same way wherever the focus sits.
fn pane_shortcuts(&self) -> Vec<(&'static str, &'static str)> {
vec![
let mut keys = vec![
("Tab/1-3", "pane"),
(
"z",
@@ -508,9 +282,13 @@ impl MainTui {
},
),
("w", "hide"),
("?", "keys"),
("q", "quit"),
]
];
// Only once a pane is hidden or zoomed is there a layout to reset.
if self.state.layout != PaneLayout::default() {
keys.push(("W", "reset layout"));
}
keys.extend([("?", "keys"), ("q", "quit")]);
keys
}
/// Which shortcuts the bar offers, which is purely a question of what is
@@ -518,15 +296,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![
@@ -534,11 +305,14 @@ impl MainTui {
("Enter", "send"),
("Esc", "stop typing"),
]
} else if self.state.chat_filtering {
filter_keys(&[("PgUp/PgDn", "scroll")])
} else {
vec![
("Enter", "type"),
("PgUp/PgDn", "scroll"),
("↑↓/Tab", "switch chat"),
("/", "find"),
("m", "new chat"),
("i/Esc", "close"),
]
@@ -546,17 +320,40 @@ impl MainTui {
} else if self.state.show_rooms {
self.rooms_shortcuts()
} else if self.state.show_browse {
vec![
("↑↓", "move"),
("PgUp/PgDn", "page"),
("→←", "expand/collapse"),
("Enter", "open/download"),
("d", "download folder"),
("Tab", "switch user"),
("r", "retry"),
("w", "close tab"),
("Esc", "hide"),
]
if self.state.browse.active_tab().is_some_and(|b| b.filtering) {
// Typing goes to the filter, so no letter keys are on offer.
filter_keys(&[("↑/↓", "navigate"), ("PgUp/PgDn", "page")])
} else {
// 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"),
];
keys.extend(acts);
keys.extend([
("Tab", "switch user"),
("w", "close tab"),
("Esc", "hide"),
]);
keys
}
} else if self.state.command_bar_active {
match self.state.command_bar_mode {
CommandBarMode::Search => vec![
@@ -581,19 +378,15 @@ impl MainTui {
&& self.state.focused_pane == FocusedPane::Results
{
// Typing goes to the filter, so no letter keys are on offer here.
vec![
("Type", "filter"),
("↑/↓", "navigate"),
("PgUp/PgDn", "page"),
("Enter", "keep filter"),
("Esc", "clear filter"),
]
filter_keys(&[("↑/↓", "navigate"), ("PgUp/PgDn", "page")])
} else {
let mut keys = match self.state.focused_pane {
FocusedPane::Searches => vec![
("s", "search"),
("S", "search again"),
("Enter", "results"),
("d", "remove"),
("C", "clear all"),
("m", "message"),
("i", "inbox"),
("c", "chat"),
@@ -616,6 +409,7 @@ impl MainTui {
("r", "retry failed"),
("d", "delete queued/done"),
("c", "clear finished"),
("C", "clear all"),
("b", "browse user"),
("h/l", "scroll name"),
],
@@ -627,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<Line<'static>> {
pub(super) fn shortcut_rows(&self, width: u16) -> Vec<Line<'static>> {
// 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 };
@@ -646,7 +440,7 @@ impl MainTui {
)
}
fn render_shortcuts(
pub(super) fn render_shortcuts(
&self,
frame: &mut Frame,
area: Rect,
@@ -686,40 +480,6 @@ 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.
@@ -776,22 +536,6 @@ fn help_lines(sections: &[(&str, &[(&str, &str)])]) -> Vec<Line<'static>> {
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.
@@ -824,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;

Some files were not shown because too many files have changed in this diff Show More