Merge branch 'develop'
This commit is contained in:
Executable
+20
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
/target
|
||||
|
||||
# Editor and assistant scratch, not repository content
|
||||
.claude/
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 930 KiB After Width: | Height: | Size: 926 KiB |
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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:?}"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
+295
-523
@@ -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.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
+1729
-1
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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",
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
¶ms.password,
|
||||
)
|
||||
.map_err(|e| {
|
||||
RpcError::application(Exit::Connection, e.to_string())
|
||||
})?;
|
||||
if let Some(e) = stored {
|
||||
soulseek_rs::warn!(
|
||||
"Password changed, but not stored on this host: {e}"
|
||||
);
|
||||
}
|
||||
ok(Ack::OK)
|
||||
}
|
||||
|
||||
Method::SharesStatusOf => ok(self.shares()),
|
||||
Method::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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
+30
-1645
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
+146
-429
@@ -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
Reference in New Issue
Block a user